diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index f81b4494b8d..6263696599b 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -13,6 +13,10 @@ runs: - name: Update packages run: sudo apt-get update shell: bash + - name: Install BTX OCR system dependency + if: ${{ inputs.extra == 'btx' || inputs.extra == 'all' }} + run: sudo apt-get install -y tesseract-ocr + shell: bash - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a6c4c12d9ac..28a5cfdf9e7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - extra: ["", serial, usb, ftdi, hid, modbus, opentrons, sila, cytation-microscopy, pico] + extra: ["", serial, usb, ftdi, hid, btx, modbus, opentrons, sila, cytation-microscopy, pico] name: Tests (${{ matrix.extra }}, py3.12) runs-on: ${{ matrix.os }} diff --git a/docs/_static/devices.json b/docs/_static/devices.json index f950490f658..20bf0afaf05 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -441,6 +441,16 @@ "docs": "/user_guide/01_material-handling/sealers/a4s.html", "oem": "https://www.azenta.com/products/automated-roll-heat-sealer-formerly-a4s" }, + { + "vendor": "BTX", + "name": "Gemini X2", + "capabilities": [ + "electroporation" + ], + "status": "Mostly", + "docs": "/user_guide/btx/gemini_x2/hello-world.html", + "oem": "https://support.btxonline.com/hc/en-us/articles/6215664757907-Gemini-Twin-Wave-Electroporators-Manual-and-Quick-Start-guide" + }, { "vendor": "Opentrons", "name": "Thermocycler", @@ -687,4 +697,4 @@ "docs": "/user_guide/02_analytical/scales.html#mettler-toledo-wxs205sdu", "oem": "https://www.mt.com/us/en/home/products/Industrial_Weighing_Solutions/high-precision-weigh-sensors/weigh-module-wxs205sdu-15-11121008.html" } -] \ No newline at end of file +] diff --git a/docs/api/pylabrobot.btx.rst b/docs/api/pylabrobot.btx.rst new file mode 100644 index 00000000000..2455360fb9c --- /dev/null +++ b/docs/api/pylabrobot.btx.rst @@ -0,0 +1,45 @@ +.. currentmodule:: pylabrobot.btx + +pylabrobot.btx package +====================== + +Gemini X2 +--------- + +.. currentmodule:: pylabrobot.btx.gemini_x2 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + BTXGeminiX2 + BTXGeminiX2Driver + BTXGeminiX2ElectroporationBackend + +.. currentmodule:: pylabrobot.btx.file_transfer_control + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + FileTransferControl + +.. currentmodule:: pylabrobot.btx.the_ghost_touch + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + TheGhostTouch + +.. currentmodule:: pylabrobot.btx.ht200 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + BTXHT200 diff --git a/docs/api/pylabrobot.capabilities.rst b/docs/api/pylabrobot.capabilities.rst index 666ce292e20..31146a51909 100644 --- a/docs/api/pylabrobot.capabilities.rst +++ b/docs/api/pylabrobot.capabilities.rst @@ -220,6 +220,45 @@ Automated Retrieval AutomatedRetrievalBackend +Electroporation +--------------- + +.. currentmodule:: pylabrobot.capabilities.electroporation.electroporation + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Electroporation + +.. currentmodule:: pylabrobot.capabilities.electroporation.backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + ElectroporationBackend + +.. currentmodule:: pylabrobot.capabilities.electroporation.standard + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + ElectroporationProtocol + PreparedElectroporationRun + ElectroporationRunResult + ElectroporationCancellationResult + ElectroporationPreparationDetails + ElectroporationExecutionDetails + ElectroporationCancellationDetails + ElectroporationLogCapture + ElectroporationCleanup + + Plate Reading - Absorbance -------------------------- @@ -287,7 +326,7 @@ Devices Bulk Dispensing - Peristaltic ----------------------------- -.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.peristaltic.peristaltic +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.peristaltic.peristaltic8 .. autosummary:: :toctree: _autosummary @@ -296,7 +335,7 @@ Bulk Dispensing - Peristaltic PeristalticDispensing8 -.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.peristaltic.backend +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.peristaltic.backend8 .. autosummary:: :toctree: _autosummary @@ -309,7 +348,7 @@ Bulk Dispensing - Peristaltic Bulk Dispensing - Syringe ------------------------- -.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.syringe.syringe +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.syringe.syringe8 .. autosummary:: :toctree: _autosummary @@ -318,7 +357,7 @@ Bulk Dispensing - Syringe SyringeDispensing8 -.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.syringe.backend +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.syringe.backend8 .. autosummary:: :toctree: _autosummary diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index 53a04635937..a7ff61980eb 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -36,6 +36,7 @@ Manufacturers pylabrobot.azenta pylabrobot.bmg_labtech pylabrobot.brooks + pylabrobot.btx pylabrobot.byonoy pylabrobot.hamilton pylabrobot.inheco diff --git a/docs/user_guide/_getting-started/installation.md b/docs/user_guide/_getting-started/installation.md index bca89b7d671..b81294b4bff 100644 --- a/docs/user_guide/_getting-started/installation.md +++ b/docs/user_guide/_getting-started/installation.md @@ -47,6 +47,7 @@ Different machines use different communication modes. Replace `[usb]` with one o | `usb` | pyusb, libusb-package | USB devices: e.g. Hamilton STAR/STARlet, Tecan EVO (firmware) | | `ftdi` | pylibftdi, pyusb | FTDI devices: e.g. BioTek Synergy H1 plate reader | | `hid` | hid | HID devices: e.g. Inheco Incubator/Shaker (HID mode) | +| `btx` | pyserial, numpy, Pillow | BTX Gemini X2 electroporator | | `modbus` | pymodbus | Modbus devices: e.g. Agrow Pump Array | | `opentrons` | opentrons-http-api-client | e.g. Opentrons backend | | `cytation-microscopy` | numpy (1.26), opencv-python | Cytation imager | @@ -171,6 +172,19 @@ If you get a `usb.core.NoBackendError: No backend available` error: [this](https If you are still having trouble, please reach out on [discuss.pylabrobot.org](https://discuss.pylabrobot.org). +## BTX Gemini X2 + +The BTX Gemini X2 support uses serial communication and GhostTouch screenshot OCR. First, install +the Python dependencies: + +```bash +pip install "pylabrobot[btx]" +``` + +GhostTouch also requires the external [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) +executable. Install Tesseract for your operating system and make sure the `tesseract` command is +available on `PATH`. + ## Cytation imager In order to use imaging on the Cytation, you need to: diff --git a/docs/user_guide/btx/gemini_x2/hello-world.ipynb b/docs/user_guide/btx/gemini_x2/hello-world.ipynb new file mode 100644 index 00000000000..2b432e069ed --- /dev/null +++ b/docs/user_guide/btx/gemini_x2/hello-world.ipynb @@ -0,0 +1,221 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# BTX Gemini X2\n", + "\n", + "The BTX Gemini X2 is a twin-waveform electroporator. PyLabRobot controls it through a USB serial connection: protocol and log transfer use the Gemini file-transfer interface, while run-screen actions use GhostTouch on the instrument touchscreen.\n", + "\n", + "Install the Python extra and make sure the external `tesseract` command is available on `PATH`:\n", + "\n", + "```bash\n", + "pip install \"pylabrobot[btx]\"\n", + "```\n", + "\n", + "For the capability-level API, see [Electroporation](../../capabilities/electroporation)." + ] + }, + { + "cell_type": "markdown", + "id": "setup-md", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Identify the serial port on your control computer and create the device. On macOS this is often `/dev/cu.usbmodem...`; on Windows it is usually a `COM` port." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "setup-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.btx import BTXGeminiX2\n", + "\n", + "gemini = BTXGeminiX2(port=\"/dev/cu.usbmodemXXXX\")\n", + "await gemini.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "info-md", + "metadata": {}, + "source": [ + "## Device information\n", + "\n", + "The electroporation capability exposes Gemini identity, the temporary protocol prefix, and plate-handler support." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "info-code", + "metadata": {}, + "outputs": [], + "source": [ + "info = await gemini.electroporation.get_device_info()\n", + "print(info[\"model\"], info[\"version\"], info[\"serial_number\"])" + ] + }, + { + "cell_type": "markdown", + "id": "protocol-md", + "metadata": {}, + "source": [ + "## Define a protocol\n", + "\n", + "Square-wave protocols use `duration_us`; exponential-decay protocols use `resistance_ohms` and `capacitance_uf`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "protocol-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.capabilities.electroporation import ElectroporationProtocol\n", + "\n", + "protocol = ElectroporationProtocol(\n", + " protocol_type=\"square\",\n", + " pulse_amplitude_volts=250,\n", + " gap_mm=2.0,\n", + " pulse_count=1,\n", + " pulse_interval_seconds=0.0,\n", + " duration_us=1000,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "prepare-md", + "metadata": {}, + "source": [ + "## Prepare a temporary run\n", + "\n", + "`prepare_temporary_protocol` writes a temporary `!PLR_...` user protocol, opens it on the Gemini touchscreen, sets plate-handler columns when requested, and leaves the device on the run screen.\n", + "\n", + "When using the HT-200 plate handler, `plate_columns` requires an explicit `plate_handler_reset_state`. Use `reset_confirmed` only after manually returning the handler to column 1; use `continue_current_position` only when intentionally continuing from the current handler position." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "prepare-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.btx import BTXGeminiX2ElectroporationBackend\n", + "\n", + "prepared = await gemini.electroporation.prepare_temporary_protocol(\n", + " protocol=protocol,\n", + " plate_columns=3,\n", + " backend_params=BTXGeminiX2ElectroporationBackend.PrepareRunParams(\n", + " plate_handler_reset_state=\"reset_confirmed\",\n", + " ),\n", + ")\n", + "prepared.protocol_name" + ] + }, + { + "cell_type": "markdown", + "id": "serialize-md", + "metadata": {}, + "source": [ + "The prepared run can be serialized and passed to a later process. The serialized payload includes the temporary protocol name and baseline log listing used to match the new BTXDATA log after GO." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "serialize-code", + "metadata": {}, + "outputs": [], + "source": [ + "prepared_payload = prepared.as_dict()\n", + "prepared_payload[\"protocol_name\"]" + ] + }, + { + "cell_type": "markdown", + "id": "start-md", + "metadata": {}, + "source": [ + "## Start the prepared run\n", + "\n", + "The next cell presses GO on the prepared run screen and delivers the configured pulse. Confirm that the plate, electrodes, samples, safety cover, and plate-handler state are correct before running it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "start-code", + "metadata": {}, + "outputs": [], + "source": [ + "result = await gemini.electroporation.start_prepared_run(\n", + " prepared_run=prepared,\n", + " home_after=True,\n", + ")\n", + "result.log_capture.summary" + ] + }, + { + "cell_type": "markdown", + "id": "cancel-md", + "metadata": {}, + "source": [ + "## Cancel before pulse delivery\n", + "\n", + "If a run has been prepared but should not be started, cancel it. This returns the Gemini to a safe screen and deletes the temporary protocol." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cancel-code", + "metadata": {}, + "outputs": [], + "source": [ + "# cancelled = await gemini.electroporation.cancel_prepared_run(prepared)\n", + "# cancelled.cleanup.deleted" + ] + }, + { + "cell_type": "markdown", + "id": "teardown-md", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "teardown-code", + "metadata": {}, + "outputs": [], + "source": [ + "await gemini.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/btx/index.md b/docs/user_guide/btx/index.md new file mode 100644 index 00000000000..cc2d9934e1e --- /dev/null +++ b/docs/user_guide/btx/index.md @@ -0,0 +1,14 @@ +# BTX + +```{toctree} +:maxdepth: 1 + +gemini_x2/hello-world +``` + +## Gemini X2 + +| Model | PLR device | Capabilities | +|---|---|---| +| Gemini X2 | `BTXGeminiX2` | Electroporation | + diff --git a/docs/user_guide/capabilities/electroporation.ipynb b/docs/user_guide/capabilities/electroporation.ipynb new file mode 100644 index 00000000000..2d602454199 --- /dev/null +++ b/docs/user_guide/capabilities/electroporation.ipynb @@ -0,0 +1,170 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# Electroporation\n", + "\n", + "{class}`~pylabrobot.capabilities.electroporation.electroporation.Electroporation` prepares, starts, and cancels electroporation runs.\n", + "\n", + "The standard workflow is a prepared run: first create a temporary protocol on the device and leave the instrument armed on its run screen, then start that prepared run when the labware and safety interlocks are ready.\n", + "\n", + "## Walkthrough\n", + "\n", + "This example uses a chatterbox backend so the workflow can be run without hardware." + ] + }, + { + "cell_type": "code", + "id": "setup-code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.electroporation import (\n", + " Electroporation,\n", + " ElectroporationChatterboxBackend,\n", + " ElectroporationProtocol,\n", + ")\n", + "\n", + "electroporator = Electroporation(backend=ElectroporationChatterboxBackend())\n", + "await electroporator._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "protocol-md", + "metadata": {}, + "source": [ + "Define a protocol. Square-wave protocols use `duration_us`; exponential-decay protocols use `resistance_ohms` and `capacitance_uf`." + ] + }, + { + "cell_type": "code", + "id": "protocol-code", + "metadata": {}, + "source": [ + "protocol = ElectroporationProtocol(\n", + " protocol_type=\"square\",\n", + " pulse_amplitude_volts=250,\n", + " gap_mm=2.0,\n", + " pulse_count=1,\n", + " pulse_interval_seconds=0.0,\n", + " duration_us=1000,\n", + ")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "prepare-md", + "metadata": {}, + "source": [ + "Prepare the run. On real hardware this writes a temporary protocol and leaves the device on the run screen. `plate_columns` is used by high-throughput plate-handler workflows." + ] + }, + { + "cell_type": "code", + "id": "prepare-code", + "metadata": {}, + "source": [ + "prepared = await electroporator.prepare_temporary_protocol(\n", + " protocol=protocol,\n", + " plate_columns=3,\n", + ")\n", + "prepared.protocol_name" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "serialize-md", + "metadata": {}, + "source": [ + "Prepared runs are serializable so a later process can resume from the same armed state." + ] + }, + { + "cell_type": "code", + "id": "serialize-code", + "metadata": {}, + "source": [ + "prepared_payload = prepared.as_dict()\n", + "prepared_payload[\"protocol_name\"]" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "start-md", + "metadata": {}, + "source": [ + "Start the prepared run and collect the result. Hardware backends may include device logs and cleanup information in the result." + ] + }, + { + "cell_type": "code", + "id": "start-code", + "metadata": {}, + "source": [ + "result = await electroporator.start_prepared_run(prepared)\n", + "result.log_capture.summary" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "cancel-md", + "metadata": {}, + "source": [ + "To arm a protocol but then stop before pulse delivery, cancel the prepared run." + ] + }, + { + "cell_type": "code", + "id": "cancel-code", + "metadata": {}, + "source": [ + "prepared = await electroporator.prepare_temporary_protocol(protocol=protocol)\n", + "cancelled = await electroporator.cancel_prepared_run(prepared)\n", + "cancelled.cleanup.deleted" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "reference-md", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} electroporation\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.electroporation.electroporation.Electroporation`, {class}`~pylabrobot.capabilities.electroporation.backend.ElectroporationBackend`, and {class}`~pylabrobot.capabilities.electroporation.standard.ElectroporationProtocol`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/capabilities/index.md b/docs/user_guide/capabilities/index.md index 2a091c83f64..f1f0bd562e2 100644 --- a/docs/user_guide/capabilities/index.md +++ b/docs/user_guide/capabilities/index.md @@ -57,6 +57,7 @@ weighing barcode-scanning microscopy automated-retrieval +electroporation absorbance fluorescence luminescence diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index bb273f350cf..2f37b04440a 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -34,6 +34,7 @@ agilent/index azenta/index bmg_labtech/index brooks/index +btx/index byonoy/index hamilton/index inheco/index diff --git a/docs/user_guide/machines.md b/docs/user_guide/machines.md index dc057d79b15..205844ab8c5 100644 --- a/docs/user_guide/machines.md +++ b/docs/user_guide/machines.md @@ -33,6 +33,7 @@ tr > td:nth-child(5) { width: 15%; } .badge-absorbance { background: #ffe6cc; } .badge-fluorescence { background: #e0ffcc; } .badge-luminescence { background: #e6e6ff; } +.badge-electroporation { background: #ffe0f0; } .badge-time-resolved-fluo { background: #ddffdd; } .badge-fluo-polarization { background: #ddf2ffff; } @@ -127,6 +128,12 @@ tr > td:nth-child(5) { width: 15%; } |--------------|---------|-------------|--------| | Azenta Life Sciences | a4S | Full | [PLR](01_material-handling/sealers/a4s.ipynb) / [OEM](https://www.azenta.com/products/automated-roll-heat-sealer-formerly-a4s) | +### Electroporators + +| Manufacturer | Machine | Features | PLR-Support | Links | +|--------------|---------|----------|-------------|--------| +| BTX | Gemini X2 | electroporation | Mostly | [PLR](btx/gemini_x2/hello-world.ipynb) / [OEM](https://support.btxonline.com/hc/en-us/articles/6215664757907-Gemini-Twin-Wave-Electroporators-Manual-and-Quick-Start-guide) | + ### Thermocyclers | Manufacturer | Machine | PLR-Support | Links | diff --git a/pylabrobot/btx/__init__.py b/pylabrobot/btx/__init__.py new file mode 100644 index 00000000000..b0abb894680 --- /dev/null +++ b/pylabrobot/btx/__init__.py @@ -0,0 +1,4 @@ +from .file_transfer_control import FileTransferControl +from .gemini_x2 import BTXGeminiX2, BTXGeminiX2Driver, BTXGeminiX2ElectroporationBackend +from .ht200 import BTXHT200 +from .the_ghost_touch import TheGhostTouch diff --git a/pylabrobot/btx/file_transfer_control.py b/pylabrobot/btx/file_transfer_control.py new file mode 100644 index 00000000000..f4727e8a277 --- /dev/null +++ b/pylabrobot/btx/file_transfer_control.py @@ -0,0 +1,993 @@ +from __future__ import annotations + +import asyncio +import re +from datetime import datetime, timezone +from math import isfinite +from typing import Any, Dict, Mapping, Optional, Protocol, TypedDict, runtime_checkable + +from pylabrobot.capabilities.electroporation.standard import ElectroporationProtocol +from pylabrobot.io.binary import Reader, Writer +from pylabrobot.io.serial import Serial + +try: + import serial.tools.list_ports + + _HAS_LIST_PORTS = True +except ImportError: + _HAS_LIST_PORTS = False + + +@runtime_checkable +class _SerialLike(Protocol): + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def write(self, data: bytes) -> None: + pass + + async def read(self, num_bytes: int = 1) -> bytes: + pass + + async def readline(self) -> bytes: + pass + + +class _ProgramEntry(TypedDict): + name: str + size: int + + +class FileTransferControl: + """Protocol Manager style USB-serial control for the BTX Gemini X2. + + This control owns the PM shell path only: stored user protocols, SD-card access, + log retrieval, and device metadata. It does not drive the RSI touchscreen workflow. + """ + + USB_VID = 0x1FE9 + USB_PID = 0x5101 + SUPPORTED_USB_IDS = { + (0x1FE9, 0x5101), + (0x1FE9, 0x5201), + } + + METHOD_PAYLOAD_BYTES = 104 + METHOD_NAME_BYTES = 28 + UI_PROTOCOL_NAME_BYTES = 15 + METHOD_PROTOCOL_TYPES = {"exponential": 0, "square": 1} + FIELD_TRAILING_RESERVED_BYTES = METHOD_PAYLOAD_BYTES - 76 + + def __init__( + self, + port: Optional[str] = None, + vid: int = USB_VID, + pid: int = USB_PID, + baudrate: int = 9600, + timeout: float = 1.0, + write_timeout: float = 1.0, + supported_usb_ids: Optional[set[tuple[int, int]]] = None, + serial_io: Optional[_SerialLike] = None, + ) -> None: + self._serial: Optional[_SerialLike] = serial_io + self._serial_io_injected = serial_io is not None + self._port = port + self._vid = vid + self._pid = pid + self._baudrate = baudrate + self._timeout = timeout + self._write_timeout = write_timeout + self._supported_usb_ids = ( + set(supported_usb_ids) + if supported_usb_ids is not None + else set(self.SUPPORTED_USB_IDS) | {(vid, pid)} + ) + + @property + def port(self) -> Optional[str]: + return self._port + + async def setup(self) -> None: + """Open the Gemini USB-serial port, autodiscovering it when needed.""" + if not self._serial_io_injected: + if self._port is None: + self._port = self._resolve_port() + self._serial = Serial( + human_readable_device_name="BTX Gemini X2 FileTransferControl", + port=self._port, + baudrate=self._baudrate, + timeout=self._timeout, + write_timeout=self._write_timeout, + ) + + serial_dev = self._require_serial() + await serial_dev.setup() + resolved_port = getattr(serial_dev, "port", None) + if isinstance(resolved_port, str): + self._port = resolved_port + + async def stop(self) -> None: + """Close the Gemini USB-serial port.""" + await self._require_serial().stop() + + async def list_protocols_with_size(self) -> list[_ProgramEntry]: + """List user protocols currently stored on the Gemini.""" + isprog_response = await self.send_text_command("isprog") + isprog_error = self._extract_error(isprog_response) + if isprog_error is not None and "unknown command" not in isprog_response.lower(): + self._require_no_error(isprog_response, "isprog") + + response = await self.send_text_command('cat "*.BTX"') + self._require_no_error(response, 'cat "*.BTX"') + return self._parse_program_table(response) + + async def list_protocols(self) -> list[str]: + """Return only the stored Gemini user protocol names.""" + return [row["name"] for row in await self.list_protocols_with_size()] + + async def get_protocol(self, protocol_name: str) -> Dict[str, Any]: + """Fetch and decode a stored protocol payload by name.""" + name = self._sanitize_protocol_name(protocol_name) + command = f'sendmtd "{name}"' + response = await self.send_text_command(command) + self._require_no_error(response, command) + + payload_hex, payload = self._extract_method_payload(response) + decoded = self._decode_method_payload(payload) + return self._operation_result( + "get_protocol", + name, + payload_hex=payload_hex, + payload_bytes=len(payload), + decoded=decoded, + response=response, + ) + + async def add_protocol( + self, + protocol_name: str, + protocol: ElectroporationProtocol | Mapping[str, Any], + overwrite: bool = False, + ) -> Dict[str, Any]: + """Transfer a new user protocol to the Gemini over the PM serial interface.""" + name = self._sanitize_new_protocol_name(protocol_name) + existing = await self.list_protocols() + exists_before = name in existing + + if exists_before and not overwrite: + raise FileExistsError(f'Protocol "{name}" already exists. Use overwrite=True to replace it.') + if exists_before and overwrite: + await self.delete_protocol(name) + + payload = self._build_method_payload(name, protocol) + payload_hex = payload.hex().upper() + + meth_command = f"meth {payload_hex}" + meth_response = await self.send_text_command(meth_command) + self._require_no_error(meth_response, meth_command) + + mend_response = await self.send_text_command("mend") + self._require_no_error(mend_response, "mend") + + exists_after = name in await self.list_protocols() + if not exists_after: + raise RuntimeError(f'Protocol "{name}" was not visible after transfer.') + + decoded = self._decode_method_payload(payload) + return self._operation_result( + "add_protocol", + name, + overwrite=overwrite, + exists_before=exists_before, + exists_after=exists_after, + payload_hex=payload_hex, + decoded=decoded, + responses={"meth": meth_response, "mend": mend_response}, + ) + + async def delete_protocol(self, protocol_name: str, missing_ok: bool = False) -> Dict[str, Any]: + """Delete a stored user protocol from the Gemini.""" + name = self._sanitize_protocol_name(protocol_name) + exists_before = name in await self.list_protocols() + + if not exists_before: + if not missing_ok: + raise FileNotFoundError(f'Protocol "{name}" is not present on the device.') + return self._operation_result( + "delete_protocol", + name, + deleted=False, + exists_before=False, + exists_after=False, + ) + + command = f'delm "{name}"' + response = "" + for _ in range(8): + response = await self.send_text_command(command) + self._require_no_error(response, command) + if name not in await self.list_protocols(): + break + + exists_after = name in await self.list_protocols() + if exists_after: + raise RuntimeError(f'Protocol "{name}" still exists after repeated delete attempts.') + + return self._operation_result( + "delete_protocol", + name, + deleted=True, + exists_before=True, + exists_after=False, + response=response, + ) + + async def list_sd_dir(self, sd_path: str) -> list[str]: + """List entries in an SD-card directory path.""" + normalized = self._normalize_sd_path(sd_path) + command = f"sddir {normalized}" + response = await self.send_text_command(command) + self._require_no_error(response, command) + return self._parse_sd_dir_listing(response, command) + + async def fetch_sd_file(self, sd_path: str) -> str: + """Read a text file from the Gemini SD card.""" + normalized = self._normalize_sd_path(sd_path) + command = f"sdsend {normalized}" + response = await self.send_text_command(command) + self._require_no_error(response, command) + return self._strip_sd_file_response(response, command) + + async def list_log_files(self, root: str = "\\BTXDATA") -> list[str]: + """Recursively enumerate BTX run log files under ``BTXDATA``.""" + normalized_root = self._normalize_sd_path(root) + log_paths: list[str] = [] + + for month in await self.list_sd_dir(normalized_root): + if not re.fullmatch(r"\d{4}-\d{2}", month): + continue + month_path = self._join_sd_path(normalized_root, month) + for day in await self.list_sd_dir(month_path): + if not re.fullmatch(r"\d{6}", day): + continue + day_path = self._join_sd_path(month_path, day) + for entry in await self.list_sd_dir(day_path): + if re.fullmatch(r"[^\\/:*?\"<>|]+\.(TXT|txt)", entry): + log_paths.append(self._join_sd_path(day_path, entry)) + + log_paths.sort() + return log_paths + + async def get_version(self) -> str: + """Return the Gemini software version string.""" + return await self._read_single_value_command("version") + + async def get_serial_number(self) -> str: + """Return the Gemini serial number.""" + return await self._read_single_value_command("sn") + + async def get_device_time(self) -> str: + """Return the current date/time reported by the Gemini.""" + return await self._read_single_value_command("time") + + async def get_comm_stats(self) -> Dict[str, int]: + """Return the device communication counters from ``status``/``stat``.""" + response = await self.send_text_command("status") + error = self._extract_error(response) + if error is not None and "unknown command" in response.lower(): + response = await self.send_text_command("stat") + self._require_no_error(response, "status/stat") + + stats: Dict[str, int] = {} + for line in self._response_lines(response): + if ":" not in line: + continue + key, value = line.split(":", maxsplit=1) + key = key.strip() + value = value.strip() + if key in {"status", "stat"}: + continue + if value.isdigit(): + stats[key] = int(value) + return stats + + def parse_run_log(self, text: str) -> Dict[str, Any]: + """Parse a BTX run log into the small summary used by the Gemini backend.""" + cleaned = text.replace("\r\n", "\n").replace("\r", "\n") + fields = self._parse_log_fields(cleaned) + + date_text = self._field_text(fields, "date") + time_text = self._field_text(fields, "time") + date_time = self._field_text(fields, "date_time") + if date_time is None and date_text is not None and time_text is not None: + date_time = f"{date_text} {time_text}" + + summary = { + "date_time": date_time, + "protocol_name": self._field_text(fields, "protocol_name"), + "protocol_type": self._field_text(fields, "protocol_type"), + "pulse_amplitude_volts": self._field_number(fields, "pulse_amplitude", cast_type=int), + "plate_columns": self._field_number(fields, "plate_columns", cast_type=int), + "pulse_1_voltage_volts": self._field_number(fields, "pulse_1_voltage", cast_type=float), + "pulse_1_time_constant_us": self._field_number( + fields, "pulse_1_time_constant", cast_type=int + ), + "pulse_1_total_load_ohms": self._field_number(fields, "pulse_1_total_load", cast_type=int), + "protocol_result": self._field_text(fields, "protocol_result"), + "status_code": self._field_hex(fields, "status") or self._field_hex(fields, "status_code"), + "status_message": self._field_text(fields, "status_message") + or self._field_suffix(fields, "status", separator="-"), + } + return {"summary": summary, "text": text} + + async def write_raw(self, data: bytes) -> None: + """Write raw bytes to the Gemini serial interface.""" + await self._require_serial().write(data) + + async def read_raw(self, num_bytes: int = 1) -> bytes: + """Read raw bytes from the Gemini serial interface.""" + return await self._require_serial().read(num_bytes=num_bytes) + + async def readline_raw(self) -> bytes: + """Read one raw line from the Gemini serial interface.""" + return await self._require_serial().readline() + + async def send_text_command(self, command: str) -> str: + """Send one PM shell command and return the prompt-terminated response text.""" + await self.write_raw((command + "\r\n").encode("utf-8")) + response = await self._read_until_prompt() + return response.decode("utf-8", errors="replace") + + def _require_serial(self) -> _SerialLike: + if self._serial is None: + raise RuntimeError("Serial device not initialized. Call setup() first.") + return self._serial + + def _operation_result(self, operation: str, protocol_name: str, **details: Any) -> Dict[str, Any]: + return { + "operation": operation, + "timestamp_utc": self._now_utc_iso(), + "protocol": protocol_name, + **details, + } + + def _resolve_port(self) -> str: + if not _HAS_LIST_PORTS: + raise RuntimeError( + "pyserial is required for BTX port autodiscovery. Install with: pip install pylabrobot[btx]" + ) + + ports = serial.tools.list_ports.comports() + btx_ports = [p for p in ports if (p.vid, p.pid) in self._supported_usb_ids] + if len(btx_ports) == 0: + raise RuntimeError( + "No BTX Gemini found with supported VID:PID pairs: " + f"{sorted(self._supported_usb_ids)}. " + "If connected, provide the serial port explicitly (e.g., /dev/cu.usbmodem...)." + ) + if len(btx_ports) > 1: + available_ports = [f"{p.device} ({hex(p.vid)}:{hex(p.pid)})" for p in btx_ports] + raise RuntimeError( + f"Multiple BTX Gemini devices found: {available_ports}. Please specify the port explicitly." + ) + + detected = btx_ports[0] + if detected.vid is not None: + self._vid = detected.vid + if detected.pid is not None: + self._pid = detected.pid + return str(detected.device) + + async def _read_single_value_command(self, command: str) -> str: + response = await self.send_text_command(command) + self._require_no_error(response, command) + lines = [line for line in self._response_lines(response) if line not in {command, ":"}] + return lines[0] if len(lines) > 0 else "" + + async def _read_until_prompt(self, read_size: int = 512, max_reads: int = 24) -> bytes: + chunks: list[bytes] = [] + got_any = False + for _ in range(max_reads): + chunk = await self.read_raw(num_bytes=read_size) + if len(chunk) == 0: + if got_any: + break + await asyncio.sleep(0.05) + continue + got_any = True + chunks.append(chunk) + if chunk.endswith(b":"): + break + await asyncio.sleep(0.03) + return b"".join(chunks) + + def _response_lines(self, response: str) -> list[str]: + return [line.strip() for line in response.splitlines()] + + def _extract_error(self, response: str) -> Optional[str]: + for line in self._response_lines(response): + line_l = line.lower() + if line_l.startswith("command error:"): + return line + if line_l.startswith("error:"): + return line + if line_l in {"argument error", "delete failed", "get method failed"}: + return line + if line_l.startswith("failed:"): + continue + if "failed" in line_l and "successful" not in line_l: + return line + return None + + def _require_no_error(self, response: str, command: str) -> None: + error = self._extract_error(response) + if error is not None: + raise RuntimeError(f"BTX command failed ({command}): {error}") + + def _parse_program_table(self, response: str) -> list[_ProgramEntry]: + programs: list[_ProgramEntry] = [] + for line in self._response_lines(response): + if ( + line == "" + or line == ":" + or line == "isprog" + or line.startswith('cat "*.BTX"') + or line.startswith("Method name") + or line.startswith("----") + ): + continue + if "file(s) using" in line: + break + if line.startswith("Error:"): + raise RuntimeError(line) + + parts = line.split() + if len(parts) >= 2 and parts[-1].isdigit(): + programs.append({"name": " ".join(parts[:-1]), "size": int(parts[-1])}) + elif len(parts) >= 1: + programs.append({"name": parts[0], "size": 0}) + return programs + + def _normalize_sd_path(self, sd_path: str) -> str: + path = sd_path.strip().replace("/", "\\") + if not path.startswith("\\"): + path = "\\" + path + path = re.sub(r"\\+", r"\\", path) + return path.rstrip("\\") or "\\" + + def _join_sd_path(self, *parts: str) -> str: + cleaned = [part.strip().strip("\\/") for part in parts if part.strip()] + if len(cleaned) == 0: + return "\\" + return "\\" + "\\".join(cleaned) + + def _parse_sd_dir_listing(self, response: str, command: str) -> list[str]: + return [line for line in self._response_lines(response) if line not in {"", ":", command}] + + def _strip_sd_file_response(self, response: str, command: str) -> str: + lines = response.replace("\r\n", "\n").replace("\r", "\n").split("\n") + if len(lines) > 0 and lines[0].strip() == command: + lines = lines[1:] + while len(lines) > 0 and lines[-1].strip() == "": + lines.pop() + if len(lines) > 0 and lines[-1].strip() == ":": + lines.pop() + return "\n".join(lines).strip("\n") + + def _parse_log_fields(self, cleaned: str) -> Dict[str, Any]: + fields: Dict[str, Any] = {} + current_block: list[str] = [] + + for line in cleaned.splitlines(): + stripped = line.rstrip() + if stripped: + current_block.append(stripped) + continue + if len(current_block) > 0: + self._parse_tabular_log_block(current_block, fields) + current_block = [] + if len(current_block) > 0: + self._parse_tabular_log_block(current_block, fields) + + for line in [line.strip() for line in cleaned.splitlines() if line.strip()]: + if "\t" in line: + continue + match = re.match(r"^([^:]+):\s*(.+)$", line) + if match is not None: + self._store_log_field(fields, match.group(1), match.group(2).strip()) + + return fields + + def _normalize_log_key(self, key: str) -> str: + normalized = re.sub(r"[^a-z0-9]+", "_", key.lower()).strip("_") + return { + "date_mm_dd_yyyy": "date", + "time_hhmmss": "time", + "pulse_amplitude_v": "pulse_amplitude", + "pulse_1_voltage_v": "pulse_1_voltage", + "pulse_1_voltage": "pulse_1_voltage", + "pulse_1_time_constant_us": "pulse_1_time_constant", + "pulse_1_time_constant": "pulse_1_time_constant", + "pulse_1_total_load_ohms": "pulse_1_total_load", + "pulse_1_total_load": "pulse_1_total_load", + }.get(normalized, normalized) + + def _store_log_field(self, fields: Dict[str, Any], key: str, value: str) -> None: + normalized_key = self._normalize_log_key(key) + existing = fields.get(normalized_key) + if existing is None: + fields[normalized_key] = value + elif isinstance(existing, list): + existing.append(value) + else: + fields[normalized_key] = [existing, value] + + # BTX emits both verbose "Key: Value" logs and tabular exports; this block parser keeps a + # single normalized summary shape for both. + def _parse_tabular_log_block(self, block: list[str], fields: Dict[str, Any]) -> None: + if len(block) == 0: + return + + idx = 0 + while idx < len(block): + line = block[idx] + if idx + 1 < len(block) and "\t" in line and "\t" in block[idx + 1] and ":" not in line: + headers = [token.strip() for token in line.split("\t") if token.strip()] + values = [token.strip() for token in block[idx + 1].split("\t") if token.strip()] + self._store_tabular_header_rows(fields, headers, values) + idx += 2 + continue + + if "\t" not in line or ":" not in line: + idx += 1 + continue + + tokens = [token.strip() for token in line.split("\t") if token.strip()] + self._store_tabular_inline_pairs(fields, tokens) + idx += 1 + + def _store_tabular_header_rows( + self, + fields: Dict[str, Any], + headers: list[str], + values: list[str], + ) -> None: + if len(headers) == 0 or len(values) == 0: + return + + if headers[0] == "DC Pulses" and values[0].lower().startswith("pulse "): + pulse_label = values[0] + for header, value in zip(headers[1:], values[1:]): + self._store_log_field(fields, f"{pulse_label} {header}", value) + return + + for header, value in zip(headers, values): + self._store_log_field(fields, header, value) + + if headers[:2] == ["Protocol Result", "Status Code"] and len(values) > 2: + self._store_log_field(fields, "Status Message", " ".join(values[2:])) + + def _store_tabular_inline_pairs(self, fields: Dict[str, Any], tokens: list[str]) -> None: + if len(tokens) < 2: + return + + token_idx = 1 if tokens[0].endswith(":") and len(tokens) >= 3 else 0 + while token_idx + 1 < len(tokens): + key = tokens[token_idx] + value = tokens[token_idx + 1] + if not key.endswith(":") or value.endswith(":"): + token_idx += 1 + continue + self._store_log_field(fields, key[:-1], value) + token_idx += 2 + + def _field_text(self, fields: Mapping[str, Any], key: str) -> Optional[str]: + value = fields.get(key) + if isinstance(value, list): + return str(value[-1]) if len(value) > 0 else None + if value is None: + return None + return str(value) + + def _field_number( + self, + fields: Mapping[str, Any], + key: str, + cast_type: type[int] | type[float], + ) -> Optional[int | float]: + value = self._field_text(fields, key) + if value is None: + return None + pattern = r"-?\d+" if cast_type is int else r"-?\d+(?:\.\d+)?" + match = re.search(pattern, value) + if match is None: + return None + return cast_type(match.group(0)) + + def _field_hex(self, fields: Mapping[str, Any], key: str) -> Optional[str]: + value = self._field_text(fields, key) + if value is None: + return None + match = re.search(r"0x[0-9A-Fa-f.]+", value) + if match is None: + return None + return match.group(0) + + def _field_suffix(self, fields: Mapping[str, Any], key: str, separator: str) -> Optional[str]: + value = self._field_text(fields, key) + if value is None or separator not in value: + return None + return value.split(separator, maxsplit=1)[1].strip() + + def _sanitize_protocol_name(self, protocol_name: str) -> str: + name = protocol_name.strip() + if len(name) == 0: + raise ValueError("Protocol name cannot be empty.") + if '"' in name or "\n" in name or "\r" in name: + raise ValueError("Protocol name cannot contain quotes or newlines.") + try: + encoded = name.encode("ascii") + except UnicodeEncodeError as exc: + raise ValueError("Protocol name must be ASCII.") from exc + if len(encoded) > self.METHOD_NAME_BYTES: + raise ValueError( + f"Protocol name must be <= {self.METHOD_NAME_BYTES} ASCII bytes, got {len(encoded)}." + ) + return name + + def _sanitize_new_protocol_name(self, protocol_name: str) -> str: + name = self._sanitize_protocol_name(protocol_name) + encoded = name.encode("ascii") + if len(encoded) > self.UI_PROTOCOL_NAME_BYTES: + raise ValueError( + "New protocol names must be <= " + f"{self.UI_PROTOCOL_NAME_BYTES} ASCII bytes for Gemini UI compatibility, " + f"got {len(encoded)}." + ) + return name + + def _encode_protocol_name(self, protocol_name: str) -> bytes: + return protocol_name.encode("ascii").ljust(self.METHOD_NAME_BYTES, b"\x00") + + def _protocol_parameters( + self, + protocol: ElectroporationProtocol | Mapping[str, Any], + ) -> Mapping[str, Any]: + if isinstance(protocol, ElectroporationProtocol): + return protocol.as_parameters() + return protocol + + def _parameter_value(self, parameters: Mapping[str, Any], *keys: str) -> Any: + for key in keys: + value = parameters.get(key) + if value is not None: + return value + return None + + def _coerce_int_parameter(self, parameters: Mapping[str, Any], *keys: str) -> Optional[int]: + value = self._parameter_value(parameters, *keys) + if value is None: + return None + if isinstance(value, bool): + raise ValueError(f"Parameter {keys[0]} must be numeric, not bool.") + if isinstance(value, float): + if not value.is_integer(): + raise ValueError(f"Parameter {keys[0]} must be an integer value, got {value}.") + return int(value) + return int(value) + + def _coerce_float_parameter(self, parameters: Mapping[str, Any], *keys: str) -> Optional[float]: + value = self._parameter_value(parameters, *keys) + if value is None: + return None + if isinstance(value, bool): + raise ValueError(f"Parameter {keys[0]} must be numeric, not bool.") + result = float(value) + if not isfinite(result): + raise ValueError(f"Parameter {keys[0]} must be finite, got {value}.") + return result + + def _normalize_protocol_parameters( + self, + protocol: ElectroporationProtocol | Mapping[str, Any], + ) -> Dict[str, Any]: + parameters = self._protocol_parameters(protocol) + common = self._normalize_common_protocol_parameters(parameters) + if common["protocol_type"] == "square": + return self._normalize_square_protocol(parameters, common) + return self._normalize_exponential_protocol(parameters, common) + + def _normalize_common_protocol_parameters(self, parameters: Mapping[str, Any]) -> Dict[str, Any]: + protocol_type = str(parameters.get("protocol_type", "exponential")).lower() + if protocol_type not in self.METHOD_PROTOCOL_TYPES: + allowed = ", ".join(sorted(self.METHOD_PROTOCOL_TYPES)) + raise ValueError(f"Unsupported protocol_type={protocol_type!r}. Allowed: {allowed}.") + + amplitude_volts = self._coerce_int_parameter(parameters, "pulse_amplitude_volts", "voltage") + if amplitude_volts is None: + raise ValueError("Missing pulse amplitude. Use pulse_amplitude_volts (or voltage).") + self._validate_amplitude_volts(amplitude_volts) + + pulse_count = self._coerce_int_parameter(parameters, "pulse_count") or 1 + pulse_interval_seconds = self._coerce_float_parameter( + parameters, "pulse_interval_seconds", "pulse_interval_sec", "interval_seconds" + ) + if pulse_interval_seconds is None: + pulse_interval_seconds = 0.0 + + gap_mm = self._coerce_float_parameter(parameters, "gap_mm", "electrode_gap_mm", "electrode_gap") + if gap_mm is None: + raise ValueError("Missing electrode gap. Use gap_mm (or electrode_gap_mm).") + self._validate_gap_mm(gap_mm) + + return { + "protocol_type": protocol_type, + "pulse_amplitude_volts": amplitude_volts, + "pulse_count": pulse_count, + "pulse_interval_seconds": pulse_interval_seconds, + "electrode_gap_mm": gap_mm, + "pulse_interval_ms": 0, + } + + def _normalize_square_protocol( + self, + parameters: Mapping[str, Any], + common: Dict[str, Any], + ) -> Dict[str, Any]: + amplitude_volts = common["pulse_amplitude_volts"] + pulse_count = common["pulse_count"] + pulse_interval_seconds = common["pulse_interval_seconds"] + + self._validate_square_pulse_count(amplitude_volts, pulse_count) + self._validate_square_pulse_interval_seconds(pulse_count, pulse_interval_seconds) + + duration_us = self._coerce_int_parameter(parameters, "duration_us", "pulse_duration_us") + if duration_us is None: + raise ValueError("Square protocols require duration_us (or pulse_duration_us).") + self._validate_square_duration_us(amplitude_volts, duration_us) + + return { + **common, + "pulse_duration_us": duration_us, + "pulse_interval_ms": int(round(pulse_interval_seconds * 1000)), + } + + def _normalize_exponential_protocol( + self, + parameters: Mapping[str, Any], + common: Dict[str, Any], + ) -> Dict[str, Any]: + pulse_count = common["pulse_count"] + pulse_interval_seconds = common["pulse_interval_seconds"] + if pulse_count != 1 or abs(pulse_interval_seconds) > 1e-9: + raise ValueError( + "Exponential protocols currently support only pulse_count=1 in this backend. " + "The Gemini X2 manual mentions up to 2 pulses depending on amplitude limit, " + "but the PM payload/current-limit behavior is not documented well enough to " + "support that safely. Use pulse_count=1 and omit pulse_interval_seconds." + ) + + amplitude_volts = common["pulse_amplitude_volts"] + resistance_ohms = self._coerce_int_parameter(parameters, "resistance_ohms", "resistance") + if resistance_ohms is None: + raise ValueError("Exponential protocols require resistance_ohms.") + self._validate_exponential_resistance_ohms(amplitude_volts, resistance_ohms) + + capacitance_uf = self._coerce_int_parameter(parameters, "capacitance_uf", "capacitance") + if capacitance_uf is None: + raise ValueError("Exponential protocols require capacitance_uf.") + self._validate_exponential_capacitance_uf(amplitude_volts, capacitance_uf) + + return { + **common, + "resistance_ohms": resistance_ohms, + "capacitance_uf": capacitance_uf, + } + + def _validate_amplitude_volts(self, amplitude_volts: int) -> None: + if 5 <= amplitude_volts <= 500: + return + if 505 <= amplitude_volts <= 3000 and (amplitude_volts % 5) == 0: + return + raise ValueError( + "pulse_amplitude_volts must be 5..500 in 1 V steps or 505..3000 in 5 V steps, " + f"got {amplitude_volts}." + ) + + def _validate_gap_mm(self, gap_mm: float) -> None: + if gap_mm <= 0: + raise ValueError(f"gap_mm must be > 0, got {gap_mm}.") + + def _validate_square_duration_us(self, amplitude_volts: int, duration_us: int) -> None: + if duration_us <= 0: + raise ValueError(f"duration_us must be > 0, got {duration_us}.") + if amplitude_volts <= 500: + if 10 <= duration_us <= 999: + return + if 1000 <= duration_us <= 999_000 and (duration_us % 1000) == 0: + return + raise ValueError( + "Square-wave LV duration must be 10..999 us or 1..999 ms in 1 ms steps; " + f"got {duration_us} us." + ) + if 10 <= duration_us <= 600: + return + raise ValueError( + f"Square-wave HV duration must be 10..600 us in 1 us steps; got {duration_us} us." + ) + + def _validate_square_pulse_count(self, amplitude_volts: int, pulse_count: int) -> None: + max_pulses = 10 if amplitude_volts <= 500 else 3 + if 1 <= pulse_count <= max_pulses: + return + raise ValueError( + f"Square-wave pulse_count must be 1..{max_pulses} at {amplitude_volts} V, got {pulse_count}." + ) + + def _validate_square_pulse_interval_seconds( + self, + pulse_count: int, + pulse_interval_seconds: float, + ) -> None: + if pulse_count == 1: + if abs(pulse_interval_seconds) <= 1e-9: + return + raise ValueError( + "Square-wave pulse_interval_seconds must be 0 or omitted when pulse_count=1, " + f"got {pulse_interval_seconds}." + ) + if not 0.1 <= pulse_interval_seconds <= 10.0: + raise ValueError( + "Square-wave pulse_interval_seconds must be 0.1..10.0 s for multiple pulsing, " + f"got {pulse_interval_seconds}." + ) + step_value = round(pulse_interval_seconds * 10) + if abs((step_value / 10.0) - pulse_interval_seconds) > 1e-9: + raise ValueError( + f"Square-wave pulse_interval_seconds must use 0.1 s steps, got {pulse_interval_seconds}." + ) + + def _validate_exponential_resistance_ohms( + self, + amplitude_volts: int, + resistance_ohms: int, + ) -> None: + min_resistance = 25 if amplitude_volts <= 500 else 50 + if resistance_ohms < min_resistance or resistance_ohms > 1575 or (resistance_ohms % 25) != 0: + raise ValueError( + "Exponential resistance_ohms must be " + f"{min_resistance}..1575 in 25 ohm steps at {amplitude_volts} V, " + f"got {resistance_ohms}." + ) + + def _validate_exponential_capacitance_uf(self, amplitude_volts: int, capacitance_uf: int) -> None: + if amplitude_volts <= 500: + if 25 <= capacitance_uf <= 3275 and (capacitance_uf % 25) == 0: + return + raise ValueError( + f"Exponential LV capacitance_uf must be 25..3275 in 25 uF steps; got {capacitance_uf}." + ) + if capacitance_uf in {10, 25, 35, 50, 60, 75, 85}: + return + raise ValueError( + "Exponential HV capacitance_uf must be one of {10, 25, 35, 50, 60, 75, 85}; " + f"got {capacitance_uf}." + ) + + def _build_method_payload( + self, + protocol_name: str, + protocol: ElectroporationProtocol | Mapping[str, Any], + ) -> bytes: + name = self._sanitize_new_protocol_name(protocol_name) + normalized = self._normalize_protocol_parameters(protocol) + + protocol_type_code = self._require_u32( + self.METHOD_PROTOCOL_TYPES[normalized["protocol_type"]], + "protocol_type_code", + ) + pulse_amplitude_volts = self._require_u32( + normalized["pulse_amplitude_volts"], + "pulse_amplitude_volts", + ) + pulse_count = self._require_u32(normalized["pulse_count"], "pulse_count") + pulse_interval_ms = self._require_u32(normalized["pulse_interval_ms"], "pulse_interval_ms") + electrode_gap_mm = self._require_f32(normalized["electrode_gap_mm"], "electrode_gap_mm") + square_duration = 0 + resistance = 0 + capacitance = 0 + + if normalized["protocol_type"] == "square": + square_duration = self._require_u32(normalized["pulse_duration_us"], "pulse_duration_us") + else: + resistance = self._require_u32(normalized["resistance_ohms"], "resistance_ohms") + capacitance = self._require_u32(normalized["capacitance_uf"], "capacitance_uf") + + writer = Writer() + writer.u32(1) + writer.raw_bytes(self._encode_protocol_name(name)) + writer.u32(protocol_type_code) + writer.u32(0) + writer.u32(pulse_amplitude_volts) + writer.u32(0) + writer.u32(square_duration) + writer.u32(0) + writer.u32(resistance) + writer.u32(capacitance) + writer.u32(pulse_count) + writer.u32(pulse_interval_ms) + writer.f32(electrode_gap_mm) + writer.raw_bytes(b"\x00" * self.FIELD_TRAILING_RESERVED_BYTES) + payload = writer.finish() + if len(payload) != self.METHOD_PAYLOAD_BYTES: + raise RuntimeError( + f"Built unexpected method payload length {len(payload)} bytes " + f"(expected {self.METHOD_PAYLOAD_BYTES})." + ) + return payload + + def _decode_method_payload(self, payload: bytes) -> Dict[str, Any]: + if len(payload) != self.METHOD_PAYLOAD_BYTES: + raise ValueError(f"Expected {self.METHOD_PAYLOAD_BYTES} payload bytes, got {len(payload)}.") + + reader = Reader(payload) + version = reader.u32() + name_raw = reader.raw_bytes(self.METHOD_NAME_BYTES) + protocol_type_code = reader.u32() + reader.u32() + pulse_amplitude_volts = reader.u32() + reader.u32() + pulse_duration_us = reader.u32() + reader.u32() + resistance_ohms = reader.u32() + capacitance_uf = reader.u32() + pulse_count = reader.u32() + pulse_interval_ms = reader.u32() + electrode_gap_mm = reader.f32() + + protocol_type = next( + (name for name, code in self.METHOD_PROTOCOL_TYPES.items() if code == protocol_type_code), + f"unknown({protocol_type_code})", + ) + return { + "version": version, + "name": name_raw.split(b"\x00", maxsplit=1)[0].decode("ascii", errors="ignore"), + "protocol_type_code": protocol_type_code, + "protocol_type": protocol_type, + "pulse_amplitude_volts": pulse_amplitude_volts, + "pulse_duration_us": pulse_duration_us, + "resistance_ohms": resistance_ohms, + "capacitance_uf": capacitance_uf, + "pulse_count": pulse_count, + "pulse_interval_ms": pulse_interval_ms, + "pulse_interval_seconds": pulse_interval_ms / 1000.0, + "electrode_gap_mm": electrode_gap_mm, + } + + def _extract_method_payload(self, response: str) -> tuple[str, bytes]: + match = re.search(r"^meth\s+([0-9A-Fa-f]+)$", response, flags=re.MULTILINE) + if match is None: + raise RuntimeError(f"Device response did not contain meth payload: {response}") + payload_hex = match.group(1) + payload = bytes.fromhex(payload_hex) + if len(payload) != self.METHOD_PAYLOAD_BYTES: + raise RuntimeError( + f"Unexpected method payload length {len(payload)} bytes (expected {self.METHOD_PAYLOAD_BYTES})." + ) + return payload_hex.upper(), payload + + def _require_u32(self, value: int, field_name: str) -> int: + if value < 0 or value > 0xFFFFFFFF: + raise ValueError(f"{field_name} must fit in u32, got {value}.") + return value + + def _require_f32(self, value: float, field_name: str) -> float: + if not isfinite(value): + raise ValueError(f"{field_name} must be a finite float32 value, got {value}.") + return value + + def _now_utc_iso(self) -> str: + return datetime.now(timezone.utc).isoformat() diff --git a/pylabrobot/btx/file_transfer_control_tests.py b/pylabrobot/btx/file_transfer_control_tests.py new file mode 100644 index 00000000000..ab457fa5712 --- /dev/null +++ b/pylabrobot/btx/file_transfer_control_tests.py @@ -0,0 +1,486 @@ +import unittest +from collections import deque +import types +from typing import Deque, List, Sequence, Tuple +from unittest.mock import patch + +from pylabrobot.btx.file_transfer_control import FileTransferControl +from pylabrobot.capabilities.electroporation.standard import ElectroporationProtocol + + +def _program_listing(entries: Sequence[Tuple[str, int]]) -> bytes: + rows = [ + "Method name Size", + "--------------- ----", + ] + rows.extend([f"{name:<16} {size}" for name, size in entries]) + rows.append("") + rows.append(f"{len(entries)} file(s) using {sum(size for _, size in entries)} steps") + rows.append(":") + return "\n".join(rows).encode("utf-8") + + +def _sd_listing(command: str, entries: Sequence[str]) -> bytes: + rows = [command] + rows.extend(entries) + rows.append(":") + return "\n".join(rows).encode("utf-8") + + +class _FakeSerial: + def __init__(self) -> None: + self.setup_calls = 0 + self.stop_calls = 0 + self.writes: List[bytes] = [] + self.read_chunks: Deque[bytes] = deque() + self.readline_chunks: Deque[bytes] = deque() + + async def setup(self) -> None: + self.setup_calls += 1 + + async def stop(self) -> None: + self.stop_calls += 1 + + async def write(self, data: bytes) -> None: + self.writes.append(data) + + async def read(self, num_bytes: int = 1) -> bytes: + del num_bytes + if len(self.read_chunks) == 0: + return b"" + return self.read_chunks.popleft() + + async def readline(self) -> bytes: + if len(self.readline_chunks) == 0: + return b"" + return self.readline_chunks.popleft() + + +class _FakePortInfo: + def __init__(self, device: str, vid: int | None, pid: int | None) -> None: + self.device = device + self.vid = vid + self.pid = pid + + +class _ConstructedSerial: + instances: List["_ConstructedSerial"] = [] + + def __init__( + self, + human_readable_device_name: str, + port: str, + baudrate: int, + timeout: float, + write_timeout: float, + ) -> None: + self.human_readable_device_name = human_readable_device_name + self.port = port + self.baudrate = baudrate + self.timeout = timeout + self.write_timeout = write_timeout + self.setup_calls = 0 + self.stop_calls = 0 + _ConstructedSerial.instances.append(self) + + async def setup(self) -> None: + self.setup_calls += 1 + + async def stop(self) -> None: + self.stop_calls += 1 + + async def write(self, data: bytes) -> None: + del data + + async def read(self, num_bytes: int = 1) -> bytes: + del num_bytes + return b"" + + async def readline(self) -> bytes: + return b"" + + +class TestFileTransferControl(unittest.IsolatedAsyncioTestCase): + async def test_setup_stop(self): + fake = _FakeSerial() + control = FileTransferControl(serial_io=fake) + + await control.setup() + await control.stop() + + self.assertEqual(fake.setup_calls, 1) + self.assertEqual(fake.stop_calls, 1) + + async def test_setup_autodiscovers_btx_port_then_uses_shared_serial(self): + _ConstructedSerial.instances.clear() + fake_ports = [_FakePortInfo("/dev/cu.btx", 0x1FE9, 0x5201)] + fake_serial_module = types.SimpleNamespace( + tools=types.SimpleNamespace( + list_ports=types.SimpleNamespace(comports=lambda: fake_ports), + ) + ) + + with ( + patch("pylabrobot.btx.file_transfer_control._HAS_LIST_PORTS", True), + patch( + "pylabrobot.btx.file_transfer_control.serial", + fake_serial_module, + create=True, + ), + patch( + "pylabrobot.btx.file_transfer_control.Serial", + _ConstructedSerial, + ), + ): + control = FileTransferControl() + await control.setup() + await control.stop() + + self.assertEqual(len(_ConstructedSerial.instances), 1) + serial_io = _ConstructedSerial.instances[0] + self.assertEqual(serial_io.port, "/dev/cu.btx") + self.assertEqual(serial_io.baudrate, 9600) + self.assertEqual(serial_io.timeout, 1.0) + self.assertEqual(serial_io.write_timeout, 1.0) + self.assertEqual(serial_io.setup_calls, 1) + self.assertEqual(serial_io.stop_calls, 1) + self.assertEqual(control.port, "/dev/cu.btx") + + async def test_list_protocols_parses_program_table(self): + fake = _FakeSerial() + fake.read_chunks.append(b"Y\n:") + fake.read_chunks.append(_program_listing([("CD", 1), ("NECATOR", 8)])) + fake.read_chunks.append(b"Y\n:") + fake.read_chunks.append(_program_listing([("CD", 1), ("NECATOR", 8)])) + control = FileTransferControl(serial_io=fake) + + rows = await control.list_protocols_with_size() + names = await control.list_protocols() + + self.assertEqual(rows, [{"name": "CD", "size": 1}, {"name": "NECATOR", "size": 8}]) + self.assertEqual(names, ["CD", "NECATOR"]) + self.assertEqual( + fake.writes, + [b"isprog\r\n", b'cat "*.BTX"\r\n', b"isprog\r\n", b'cat "*.BTX"\r\n'], + ) + + async def test_add_exponential_protocol_success(self): + fake = _FakeSerial() + fake.read_chunks.append(b"Y\n:") + fake.read_chunks.append(_program_listing([("CD", 1)])) + fake.read_chunks.append(b":") + fake.read_chunks.append(b":") + fake.read_chunks.append(b"Y\n:") + fake.read_chunks.append(_program_listing([("CD", 1), ("TESTX", 1)])) + control = FileTransferControl(serial_io=fake) + + result = await control.add_protocol( + "TESTX", + ElectroporationProtocol( + protocol_type="exponential", + pulse_amplitude_volts=2400, + gap_mm=2.0, + resistance_ohms=200, + capacitance_uf=25, + ), + ) + + self.assertEqual(result["operation"], "add_protocol") + self.assertEqual(result["protocol"], "TESTX") + self.assertEqual(result["decoded"]["protocol_type"], "exponential") + self.assertEqual(result["decoded"]["pulse_amplitude_volts"], 2400) + self.assertEqual(result["decoded"]["resistance_ohms"], 200) + self.assertEqual(result["decoded"]["capacitance_uf"], 25) + self.assertEqual(result["decoded"]["pulse_count"], 1) + self.assertAlmostEqual(result["decoded"]["electrode_gap_mm"], 2.0) + self.assertTrue(fake.writes[2].startswith(b"meth ")) + self.assertEqual(fake.writes[3], b"mend\r\n") + + async def test_add_square_protocol_success(self): + fake = _FakeSerial() + fake.read_chunks.append(b"Y\n:") + fake.read_chunks.append(_program_listing([("CD", 1)])) + fake.read_chunks.append(b":") + fake.read_chunks.append(b":") + fake.read_chunks.append(b"Y\n:") + fake.read_chunks.append(_program_listing([("CD", 1), ("SQTEST", 1)])) + control = FileTransferControl(serial_io=fake) + + result = await control.add_protocol( + "SQTEST", + ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + ), + ) + + self.assertEqual(result["decoded"]["protocol_type"], "square") + self.assertEqual(result["decoded"]["pulse_amplitude_volts"], 250) + self.assertEqual(result["decoded"]["pulse_duration_us"], 1000) + self.assertAlmostEqual(result["decoded"]["electrode_gap_mm"], 1.0) + self.assertEqual(result["decoded"]["pulse_count"], 1) + self.assertEqual(result["decoded"]["pulse_interval_ms"], 0) + self.assertEqual(result["decoded"]["pulse_interval_seconds"], 0.0) + + async def test_add_square_protocol_supports_multiple_pulse_interval(self): + fake = _FakeSerial() + fake.read_chunks.append(b"Y\n:") + fake.read_chunks.append(_program_listing([("CD", 1)])) + fake.read_chunks.append(b":") + fake.read_chunks.append(b":") + fake.read_chunks.append(b"Y\n:") + fake.read_chunks.append(_program_listing([("CD", 1), ("SQMP", 1)])) + control = FileTransferControl(serial_io=fake) + + result = await control.add_protocol( + "SQMP", + ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=2400, + gap_mm=2.0, + pulse_count=3, + pulse_interval_seconds=2.0, + duration_us=500, + ), + ) + + self.assertEqual(result["decoded"]["protocol_type"], "square") + self.assertEqual(result["decoded"]["pulse_amplitude_volts"], 2400) + self.assertEqual(result["decoded"]["pulse_duration_us"], 500) + self.assertEqual(result["decoded"]["pulse_count"], 3) + self.assertEqual(result["decoded"]["pulse_interval_ms"], 2000) + self.assertEqual(result["decoded"]["pulse_interval_seconds"], 2.0) + + async def test_add_exponential_protocol_rejects_multiple_pulse_write(self): + control = FileTransferControl(serial_io=_FakeSerial()) + + with self.assertRaisesRegex(ValueError, "currently support only pulse_count=1"): + control._build_method_payload( + "TEST", + ElectroporationProtocol( + protocol_type="exponential", + pulse_amplitude_volts=250, + gap_mm=1.0, + pulse_count=2, + pulse_interval_seconds=5.0, + resistance_ohms=200, + capacitance_uf=25, + ), + ) + + async def test_get_protocol_decodes_payload(self): + fake = _FakeSerial() + fake.read_chunks.append( + ( + b"meth " + b"010000004A4A00000000000000000000000000000000000000000000000000000000000000000000" + b"19000000000000000000000000000000320000002C01000001000000000000000000004000000000" + b"000000000000000000000000000000000000000000000000\nmend\n:" + ) + ) + control = FileTransferControl(serial_io=fake) + + result = await control.get_protocol("JJ") + + self.assertEqual(result["protocol"], "JJ") + self.assertEqual(result["decoded"]["name"], "JJ") + self.assertEqual(result["decoded"]["pulse_amplitude_volts"], 25) + self.assertEqual(result["decoded"]["resistance_ohms"], 50) + self.assertEqual(result["decoded"]["capacitance_uf"], 300) + self.assertEqual(result["decoded"]["pulse_count"], 1) + self.assertEqual(result["decoded"]["pulse_interval_seconds"], 0.0) + self.assertAlmostEqual(result["decoded"]["electrode_gap_mm"], 2.0) + + async def test_decode_manual_square_protocol_includes_interval(self): + control = FileTransferControl(serial_io=_FakeSerial()) + payload = bytes.fromhex( + "01000000544553545351554152450000000000000000000000000000000000000100000000000000" + "6009000000000000F401000000000000000000000000000003000000D00700000000004000000000" + "000000000000000000000000000000000000000000000000" + ) + + decoded = control._decode_method_payload(payload) + + self.assertEqual(decoded["name"], "TESTSQUARE") + self.assertEqual(decoded["protocol_type"], "square") + self.assertEqual(decoded["pulse_amplitude_volts"], 2400) + self.assertEqual(decoded["pulse_duration_us"], 500) + self.assertEqual(decoded["pulse_count"], 3) + self.assertEqual(decoded["pulse_interval_ms"], 2000) + self.assertEqual(decoded["pulse_interval_seconds"], 2.0) + + async def test_build_square_payload_matches_known_manual_payload(self): + control = FileTransferControl(serial_io=_FakeSerial()) + + payload = control._build_method_payload( + "TESTSQUARE", + ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=2400, + gap_mm=2.0, + pulse_count=3, + pulse_interval_seconds=2.0, + duration_us=500, + ), + ) + + self.assertEqual( + payload.hex().upper(), + ( + "01000000544553545351554152450000000000000000000000000000000000000100000000000000" + "6009000000000000F401000000000000000000000000000003000000D00700000000004000000000" + "000000000000000000000000000000000000000000000000" + ), + ) + + async def test_delete_protocol(self): + fake = _FakeSerial() + fake.read_chunks.append(b"Y\n:") + fake.read_chunks.append(_program_listing([("CD", 1), ("TEST", 1)])) + fake.read_chunks.append(b":") + fake.read_chunks.append(b"Y\n:") + fake.read_chunks.append(_program_listing([("CD", 1)])) + fake.read_chunks.append(b"Y\n:") + fake.read_chunks.append(_program_listing([("CD", 1)])) + control = FileTransferControl(serial_io=fake) + + result = await control.delete_protocol("TEST") + + self.assertTrue(result["deleted"]) + self.assertFalse(result["exists_after"]) + + async def test_sd_dir_and_file_helpers(self): + fake = _FakeSerial() + fake.read_chunks.append(_sd_listing(r"sddir \BTXDATA", ["2026-03"])) + fake.read_chunks.append( + ( + "sdsend \\BTXDATA\\2026-03\\260309\\153425.TXT\n" + "Protocol Name: H16_C\n" + "Protocol Result: Complete\n" + ":\n" + ).encode("utf-8") + ) + control = FileTransferControl(serial_io=fake) + + entries = await control.list_sd_dir(r"\BTXDATA") + content = await control.fetch_sd_file(r"\BTXDATA\2026-03\260309\153425.TXT") + + self.assertEqual(entries, ["2026-03"]) + self.assertEqual(content, "Protocol Name: H16_C\nProtocol Result: Complete") + + async def test_list_log_files_walks_btxdata_tree(self): + fake = _FakeSerial() + fake.read_chunks.append(_sd_listing(r"sddir \BTXDATA", ["2026-03", "notes"])) + fake.read_chunks.append(_sd_listing(r"sddir \BTXDATA\2026-03", ["260308", "260309"])) + fake.read_chunks.append(_sd_listing(r"sddir \BTXDATA\2026-03\260308", ["113530PP.TXT"])) + fake.read_chunks.append( + _sd_listing(r"sddir \BTXDATA\2026-03\260309", ["153008.TXT", "153425.TXT"]) + ) + control = FileTransferControl(serial_io=fake) + + logs = await control.list_log_files() + + self.assertEqual( + logs, + [ + r"\BTXDATA\2026-03\260308\113530PP.TXT", + r"\BTXDATA\2026-03\260309\153008.TXT", + r"\BTXDATA\2026-03\260309\153425.TXT", + ], + ) + + async def test_device_info_helpers(self): + fake = _FakeSerial() + fake.read_chunks.append(b"BTX Gemini 4.0.4\nSerial number: 1135421\n:") + fake.read_chunks.append(b"1135421\n:") + fake.read_chunks.append(b"03/06/2026 2:36:11 PM\n:") + fake.read_chunks.append( + b"\nSuccessful Tx: 57295\nSuccessful Rx: 57296\nFailed: 0\nRetries: 0\n:" + ) + control = FileTransferControl(serial_io=fake) + + version = await control.get_version() + serial_number = await control.get_serial_number() + device_time = await control.get_device_time() + stats = await control.get_comm_stats() + + self.assertEqual(version, "BTX Gemini 4.0.4") + self.assertEqual(serial_number, "1135421") + self.assertEqual(device_time, "03/06/2026 2:36:11 PM") + self.assertEqual(stats["Successful Tx"], 57295) + self.assertEqual(stats["Successful Rx"], 57296) + + async def test_parse_run_log_extracts_summary_fields(self): + control = FileTransferControl(serial_io=_FakeSerial()) + parsed = control.parse_run_log( + "\n".join( + [ + "Date/Time: 03/09/2026 3:34:25 PM", + "Model: BTX Gemini", + "Mode: Electroporation", + "Serial Number: 1135421", + "GUI Software Version: 4.0.4", + "DC Pulse Generator Firmware Version: 4.0.4", + "Auto-PrePulse: On", + "Protocol Name: !PLR_154635", + "Protocol Type: Exponential", + "Pulse Amplitude: 2300 V", + "Number of Pulses: 1", + "Pulse Interval: 0 sec", + "Electrode Gap: 2.0 mm", + "Plate Columns: 3", + "Resistance: 200 ohms", + "Capacitance: 25 uF", + "PrePulse External Load: 5000 ohms", + "Droop: 0.0%", + "Pulse 1 Voltage: 2303.53 V", + "Pulse 1 Time Constant: 5021 us", + "Pulse 1 Total Load: 199 ohms", + "Protocol Result: Complete", + "Status: 0x00000000.00000000 - No error.", + ] + ) + ) + + self.assertEqual(parsed["summary"]["protocol_name"], "!PLR_154635") + self.assertEqual(parsed["summary"]["protocol_type"], "Exponential") + self.assertEqual(parsed["summary"]["plate_columns"], 3) + self.assertEqual(parsed["summary"]["pulse_amplitude_volts"], 2300) + self.assertEqual(parsed["summary"]["protocol_result"], "Complete") + self.assertEqual(parsed["summary"]["status_code"], "0x00000000.00000000") + self.assertEqual(parsed["summary"]["status_message"], "No error.") + self.assertNotIn("raw_fields", parsed) + self.assertNotIn("line_count", parsed) + + async def test_parse_run_log_extracts_tabular_fields(self): + control = FileTransferControl(serial_io=_FakeSerial()) + parsed = control.parse_run_log( + "\n".join( + [ + "Date (MM/DD/YYYY)\tTime (HHMMSS)\tModel\tMode\tSerial Number\tGUI Firmware\tDC Firmware\tAuto-PrePulse", + "03/09/2026\t3:34:25 PM\tBTX Gemini\tElectroporation\t1135421\t4.0.4\t4.0.4\tOn", + "", + "Protocol Name\tProtocol Type\tPulse Amplitude (V)\t# of Pulses\tPulse Interval (sec)\tGap (mm)\tPlate Columns\tResistance (Ohms)\tCapacitance (uF)", + "!PLR_0309160010\tExponential\t2300\t1\t0\t3.0\t3\t200\t25", + "", + "PrePulse:\tExternal Load (Ohms):\t5000\tDroop (%):\t0.0", + "DC Pulses\tVoltage (V)\tTime Constant (us)\tTotal Load (Ohms)", + "Pulse 1\t2303.53\t5021\t199", + "", + "Protocol Result\tStatus Code", + "Complete\t0x00000000.00000000\t(No error.)", + ] + ) + ) + + self.assertEqual(parsed["summary"]["date_time"], "03/09/2026 3:34:25 PM") + self.assertEqual(parsed["summary"]["protocol_name"], "!PLR_0309160010") + self.assertEqual(parsed["summary"]["pulse_amplitude_volts"], 2300) + self.assertEqual(parsed["summary"]["plate_columns"], 3) + self.assertAlmostEqual(parsed["summary"]["pulse_1_voltage_volts"], 2303.53) + self.assertEqual(parsed["summary"]["pulse_1_time_constant_us"], 5021) + self.assertEqual(parsed["summary"]["pulse_1_total_load_ohms"], 199) + self.assertEqual(parsed["summary"]["status_code"], "0x00000000.00000000") + self.assertEqual(parsed["summary"]["status_message"], "(No error.)") diff --git a/pylabrobot/btx/gemini_x2.py b/pylabrobot/btx/gemini_x2.py new file mode 100644 index 00000000000..5919f9aa722 --- /dev/null +++ b/pylabrobot/btx/gemini_x2.py @@ -0,0 +1,740 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import ( + Any, + Callable, + Dict, + Mapping, + Optional, + Protocol, + TypeVar, + Union, + cast, + runtime_checkable, +) + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.electroporation import ( + Electroporation, + ElectroporationBackend, + ElectroporationCancellationDetails, + ElectroporationCancellationResult, + ElectroporationCleanup, + ElectroporationExecutionDetails, + ElectroporationLogCapture, + ElectroporationPreparationDetails, + ElectroporationProtocol, + ElectroporationRunResult, + PreparedElectroporationRun, +) +from pylabrobot.device import Device, Driver + +from .file_transfer_control import FileTransferControl +from .ht200 import BTXHT200 +from .the_ghost_touch import ( + CancelledPreparedUserProtocolResult, + PreparedUserProtocolResult, + StartedPreparedUserProtocolResult, + TheGhostTouch, +) + + +@runtime_checkable +class _GhostTouchSession(Protocol): + def __enter__(self) -> "_GhostTouchSession": + pass + + def __exit__(self, exc_type, exc, tb) -> None: + pass + + def ensure_home(self) -> Any: + pass + + def prepare_user_protocol( + self, + protocol_name: str, + plate_columns: Optional[int] = None, + ) -> PreparedUserProtocolResult: + pass + + def start_prepared_user_protocol( + self, + protocol_name: str, + home_after: bool = True, + max_run_seconds: float = 420.0, + ) -> StartedPreparedUserProtocolResult: + pass + + def cancel_prepared_user_protocol( + self, home_after: bool = True + ) -> CancelledPreparedUserProtocolResult: + pass + + +GhostTouchFactory = Callable[..., _GhostTouchSession] +GhostTouchResult = TypeVar("GhostTouchResult") + + +def _result_dict(value: Any) -> Dict[str, Any]: + if hasattr(value, "as_dict"): + return cast(Dict[str, Any], value.as_dict()) + return cast(Dict[str, Any], value) + + +def _nested_state(payload: Mapping[str, Any], *path: str) -> Optional[str]: + current: Any = payload + for key in path: + if not isinstance(current, Mapping): + return None + current = current.get(key) + if isinstance(current, str): + return current + return None + + +@dataclass(frozen=True) +class TemporaryProtocolCleanupResult: + delete_result: Any + delete_retry_used: bool + delete_error: Optional[str] + + def as_dict(self) -> Dict[str, Any]: + return { + "delete_result": self.delete_result, + "delete_retry_used": self.delete_retry_used, + "delete_error": self.delete_error, + } + + +@dataclass(frozen=True) +class MatchedRunLogResult: + before_count: int + after_count: int + new_log_paths: tuple[str, ...] + matched_log_path: Optional[str] + matched_log: Any + + def as_dict(self) -> Dict[str, Any]: + return { + "before_count": self.before_count, + "after_count": self.after_count, + "new_log_paths": list(self.new_log_paths), + "matched_log_path": self.matched_log_path, + "matched_log": self.matched_log, + } + + +class BTXGeminiX2Driver(Driver): + """BTX Gemini X2 driver. + + Owns the file-transfer connection lifecycle and the temporary handoff into the RSI touch + control session. + """ + + def __init__( + self, + port: Optional[str] = None, + *, + file_transfer_control: Optional[FileTransferControl] = None, + ghost_touch_factory: Optional[GhostTouchFactory] = None, + ghost_touch_kwargs: Optional[dict[str, Any]] = None, + ) -> None: + super().__init__() + self.port = port or (file_transfer_control.port if file_transfer_control is not None else None) + self.file_transfer_control = file_transfer_control or FileTransferControl(port=port) + self._ghost_touch_factory = ghost_touch_factory or TheGhostTouch + self._ghost_touch_kwargs = dict(ghost_touch_kwargs or {}) + + async def setup(self, backend_params: Optional[BackendParams] = None): + del backend_params + await self.file_transfer_control.setup() + if self.file_transfer_control.port is not None: + self.port = self.file_transfer_control.port + + async def stop(self): + await self.file_transfer_control.stop() + + def serialize(self) -> dict: + return { + **super().serialize(), + "port": self.port, + } + + async def list_protocols(self) -> list[str]: + return await self.file_transfer_control.list_protocols() + + async def get_protocol(self, protocol_name: str) -> Dict[str, Any]: + return await self.file_transfer_control.get_protocol(protocol_name) + + async def add_protocol( + self, + protocol_name: str, + protocol: ElectroporationProtocol, + overwrite: bool = False, + ) -> Dict[str, Any]: + return await self.file_transfer_control.add_protocol( + protocol_name, protocol, overwrite=overwrite + ) + + async def delete_protocol(self, protocol_name: str, missing_ok: bool = False) -> Dict[str, Any]: + return await self.file_transfer_control.delete_protocol(protocol_name, missing_ok=missing_ok) + + async def list_log_files(self, root: str = "\\BTXDATA") -> list[str]: + return await self.file_transfer_control.list_log_files(root=root) + + async def fetch_sd_file(self, sd_path: str) -> str: + return await self.file_transfer_control.fetch_sd_file(sd_path) + + async def get_version(self) -> str: + return await self.file_transfer_control.get_version() + + async def get_serial_number(self) -> str: + return await self.file_transfer_control.get_serial_number() + + async def get_device_time(self) -> str: + return await self.file_transfer_control.get_device_time() + + def parse_run_log(self, text: str) -> Dict[str, Any]: + return self.file_transfer_control.parse_run_log(text) + + async def run_with_ghost_touch( + self, + action: Callable[[_GhostTouchSession], GhostTouchResult], + ) -> GhostTouchResult: + await self.file_transfer_control.stop() + try: + return await asyncio.to_thread(self._run_with_ghost_touch_sync, action) + finally: + await self.file_transfer_control.setup() + if self.file_transfer_control.port is not None: + self.port = self.file_transfer_control.port + + def _run_with_ghost_touch_sync( + self, + action: Callable[[_GhostTouchSession], GhostTouchResult], + ) -> GhostTouchResult: + with self._open_ghost_touch() as ghost_touch: + return action(ghost_touch) + + def _open_ghost_touch(self) -> _GhostTouchSession: + if self.port is None: + raise RuntimeError("Gemini X2 serial port is not resolved. Call setup() first.") + session = self._ghost_touch_factory(port=self.port, **self._ghost_touch_kwargs) + if not isinstance(session, _GhostTouchSession): + session = cast(_GhostTouchSession, session) + return session + + +class BTXGeminiX2ElectroporationBackend(ElectroporationBackend): + """Prepared-run BTX Gemini X2 backend. + + The Gemini X2 uses two separate control paths on the same USB-connected device: + `FileTransferControl` for Protocol Manager style file/protocol access, and `TheGhostTouch` + for the RSI touchscreen workflow that arms and starts a user protocol. + """ + + UI_PROTOCOL_NAME_BYTES = FileTransferControl.UI_PROTOCOL_NAME_BYTES + DEFAULT_TEMPORARY_PROTOCOL_PREFIX = "!PLR" + PLATE_HANDLER_RESET_STATE_UNKNOWN = "unknown" + PLATE_HANDLER_RESET_STATE_RESET_CONFIRMED = "reset_confirmed" + PLATE_HANDLER_RESET_STATE_CONTINUE_CURRENT_POSITION = "continue_current_position" + PLATE_HANDLER_RESET_STATES = { + PLATE_HANDLER_RESET_STATE_UNKNOWN, + PLATE_HANDLER_RESET_STATE_RESET_CONFIRMED, + PLATE_HANDLER_RESET_STATE_CONTINUE_CURRENT_POSITION, + } + + @dataclass(frozen=True) + class PrepareRunParams(BackendParams): + plate_handler_reset_state: str = "unknown" + + def __init__( + self, + driver: BTXGeminiX2Driver, + *, + plate_handler: Optional[BTXHT200] = None, + temporary_protocol_prefix: str = DEFAULT_TEMPORARY_PROTOCOL_PREFIX, + ) -> None: + self.driver = driver + self.plate_handler = plate_handler or BTXHT200() + self._temporary_protocol_prefix = temporary_protocol_prefix + self._is_setup = False + + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + del backend_params + try: + # Setup only enforces ordering safety so a later process can resume an already prepared + # `!PLR_...` run token without being blocked by the existing temp protocol. + await self._ensure_temporary_protocol_prefix_order_safe(self._temporary_protocol_prefix) + self._is_setup = True + except Exception: + self._is_setup = False + raise + + async def _on_stop(self): + self._is_setup = False + + def serialize(self) -> dict: + return { + "temporary_protocol_prefix": self._temporary_protocol_prefix, + "plate_handler": { + "device": self.plate_handler.__class__.__name__, + "model": "HT-200", + "assumed_pulse_count": self.plate_handler.assumed_pulse_count, + "assumed_column_adjust": self.plate_handler.assumed_column_adjust, + }, + } + + async def prepare_temporary_protocol( + self, + protocol: ElectroporationProtocol, + plate_columns: Optional[int] = None, + prefix: Optional[str] = None, + backend_params: Optional[BackendParams] = None, + ) -> PreparedElectroporationRun: + """Create a temporary protocol and leave the Gemini armed on ``Run Protocol``.""" + self._require_setup() + if backend_params is None: + backend_params = self.PrepareRunParams() + if not isinstance(backend_params, self.PrepareRunParams): + raise TypeError( + "backend_params must be BTXGeminiX2ElectroporationBackend.PrepareRunParams or None." + ) + resolved_prefix = self._temporary_protocol_prefix if prefix is None else prefix + resolved_reset_state = self._resolve_plate_handler_reset_state( + plate_columns=plate_columns, + plate_handler_reset_state=backend_params.plate_handler_reset_state, + ) + assumed_plate_handler_pulse_count, assumed_plate_handler_column_adjust = ( + self._resolve_plate_handler_manual_state(plate_columns=plate_columns) + ) + # Preparing a new temp protocol is stricter than setup: earlier-sorting names and same-prefix + # temp leftovers both make the "first user protocol" strategy unsafe. + await self._ensure_temporary_protocol_prefix_available(resolved_prefix) + + # This snapshot lets start_prepared_run() identify the new log by diffing BTXDATA after GO. + baseline_log_paths = tuple(await self.driver.list_log_files()) + protocol_name = self._make_temporary_protocol_name(resolved_prefix) + add_result = await self.driver.add_protocol( + protocol_name=protocol_name, + protocol=protocol, + overwrite=False, + ) + + try: + rsi_result = await self._run_with_ghost_touch( + lambda ghost_touch: ghost_touch.prepare_user_protocol( + protocol_name=protocol_name, + plate_columns=plate_columns, + ) + ) + except Exception: + await self._cleanup_temporary_protocol(protocol_name, missing_ok=True) + raise + + return PreparedElectroporationRun( + protocol_name=protocol_name, + protocol=protocol, + plate_columns=plate_columns, + prefix=resolved_prefix, + prepared_at_utc=self._now_utc_iso(), + baseline_log_paths=baseline_log_paths, + prepare_result=ElectroporationPreparationDetails( + prepared_state=_nested_state(_result_dict(rsi_result), "prepared_verification", "state"), + protocol_setup=_result_dict(add_result), + device_prepare={ + "plate_handler_reset_state": resolved_reset_state, + "assumed_plate_handler_pulse_count": assumed_plate_handler_pulse_count, + "assumed_plate_handler_column_adjust": assumed_plate_handler_column_adjust, + **_result_dict(rsi_result), + }, + ), + ) + + async def start_prepared_run( + self, + prepared_run: Union[PreparedElectroporationRun, Mapping[str, Any]], + home_after: bool = True, + max_run_seconds: float = 420.0, + ) -> ElectroporationRunResult: + """Verify, start, and collect the result for a previously prepared temporary run.""" + self._require_setup() + prepared = self._coerce_prepared_run(prepared_run) + + started_at_utc = self._now_utc_iso() + rsi_result = await self._run_with_ghost_touch( + lambda ghost_touch: ghost_touch.start_prepared_user_protocol( + protocol_name=prepared.protocol_name, + home_after=home_after, + max_run_seconds=max_run_seconds, + ) + ) + + try: + log_capture = await self._collect_matching_new_log( + before_logs=set(prepared.baseline_log_paths), + protocol_name=prepared.protocol_name, + ) + finally: + cleanup = await self._cleanup_temporary_protocol(prepared.protocol_name, missing_ok=True) + + return ElectroporationRunResult( + prepared_run=prepared, + started_at_utc=started_at_utc, + completed_at_utc=self._now_utc_iso(), + rsi_result=ElectroporationExecutionDetails( + verification_state=_nested_state(_result_dict(rsi_result), "verification", "state"), + completed_state=_nested_state(_result_dict(rsi_result), "completed", "state"), + final_state=( + _nested_state(_result_dict(rsi_result), "home", "state") + or _nested_state(_result_dict(rsi_result), "completed", "state") + ), + device_run=_result_dict(rsi_result), + ), + log_capture=ElectroporationLogCapture( + matched_log_path=cast(Optional[str], _result_dict(log_capture).get("matched_log_path")), + summary=dict( + cast(Mapping[str, Any], _result_dict(log_capture).get("matched_log", {})).get( + "summary", {} + ) + if isinstance(_result_dict(log_capture).get("matched_log"), Mapping) + else {} + ), + details=_result_dict(log_capture), + ), + cleanup=ElectroporationCleanup( + deleted=cast( + Optional[bool], + cast(Mapping[str, Any], _result_dict(cleanup).get("delete_result", {})).get("deleted"), + ) + if isinstance(_result_dict(cleanup).get("delete_result"), Mapping) + else None, + retry_used=bool(_result_dict(cleanup).get("delete_retry_used", False)), + error=cast(Optional[str], _result_dict(cleanup).get("delete_error")), + details=_result_dict(cleanup), + ), + ) + + async def cancel_prepared_run( + self, + prepared_run: Union[PreparedElectroporationRun, Mapping[str, Any]], + home_after: bool = True, + ) -> ElectroporationCancellationResult: + """Return the Gemini to a safe screen and delete the prepared temporary protocol.""" + self._require_setup() + prepared = self._coerce_prepared_run(prepared_run) + + rsi_result = await self.driver.run_with_ghost_touch( + lambda ghost_touch: ghost_touch.cancel_prepared_user_protocol(home_after=home_after) + ) + cleanup = await self._cleanup_temporary_protocol(prepared.protocol_name, missing_ok=True) + + return ElectroporationCancellationResult( + prepared_run=prepared, + cancelled_at_utc=self._now_utc_iso(), + rsi_result=ElectroporationCancellationDetails( + final_state=_nested_state(_result_dict(rsi_result), "final_state", "state"), + device_cancel=_result_dict(rsi_result), + ), + cleanup=ElectroporationCleanup( + deleted=cast( + Optional[bool], + cast(Mapping[str, Any], _result_dict(cleanup).get("delete_result", {})).get("deleted"), + ) + if isinstance(_result_dict(cleanup).get("delete_result"), Mapping) + else None, + retry_used=bool(_result_dict(cleanup).get("delete_retry_used", False)), + error=cast(Optional[str], _result_dict(cleanup).get("delete_error")), + details=_result_dict(cleanup), + ), + ) + + async def get_device_info(self) -> Dict[str, Any]: + """Return Gemini identity plus the supported electroporation workflow surface.""" + self._require_setup() + version = await self.driver.get_version() + serial_number = await self.driver.get_serial_number() + device_time = await self.driver.get_device_time() + protocols = await self.driver.list_protocols() + plate_handler_info = self.plate_handler.get_device_info() + return { + "backend": self.__class__.__name__, + "model": "Gemini X2", + "port": self.driver.port, + "version": version, + "serial_number": serial_number, + "device_time": device_time, + "protocol_count": len(protocols), + "supports_prepared_temporary_runs": True, + "supports_serialized_prepared_runs": True, + "supports_stored_protocol_runs": False, + "supports_plate_columns": True, + "supports_plate_handler_reset_state": True, + "plate_handler_reset_states": sorted(self.PLATE_HANDLER_RESET_STATES), + "plate_handler": plate_handler_info, + "temporary_protocol_prefix": self._temporary_protocol_prefix, + "protocol_transfer_control": "FileTransferControl", + "touch_control": "TheGhostTouch", + } + + async def list_protocols(self) -> list[str]: + """List all user protocols visible through the PM serial interface.""" + self._require_setup() + return await self.driver.list_protocols() + + async def get_protocol(self, protocol_name: str) -> Dict[str, Any]: + """Fetch one stored user protocol by name.""" + self._require_setup() + return await self.driver.get_protocol(protocol_name) + + async def add_protocol( + self, + protocol_name: str, + protocol: ElectroporationProtocol, + overwrite: bool = False, + ) -> Dict[str, Any]: + """Developer helper: write a user protocol directly through file transfer.""" + self._require_setup() + return await self.driver.add_protocol(protocol_name, protocol, overwrite=overwrite) + + async def delete_protocol(self, protocol_name: str, missing_ok: bool = False) -> Dict[str, Any]: + """Developer helper: delete a stored user protocol.""" + self._require_setup() + return await self.driver.delete_protocol(protocol_name, missing_ok=missing_ok) + + async def list_log_files(self, root: str = "\\BTXDATA") -> list[str]: + """Developer helper: enumerate run logs stored on the Gemini SD card.""" + self._require_setup() + return await self.driver.list_log_files(root=root) + + async def fetch_sd_file(self, sd_path: str) -> str: + """Developer helper: fetch one SD-card file from the Gemini.""" + self._require_setup() + return await self.driver.fetch_sd_file(sd_path) + + def parse_run_log(self, text: str) -> Dict[str, Any]: + """Developer helper: parse a BTX run log into normalized fields.""" + return self.driver.parse_run_log(text) + + def _require_setup(self) -> None: + if not self._is_setup: + raise RuntimeError("Call setup() before using the Gemini X2 backend.") + + def _resolve_plate_handler_reset_state( + self, + *, + plate_columns: Optional[int], + plate_handler_reset_state: str, + ) -> str: + if plate_handler_reset_state not in self.PLATE_HANDLER_RESET_STATES: + allowed = ", ".join(sorted(self.PLATE_HANDLER_RESET_STATES)) + raise ValueError( + f"Unsupported plate_handler_reset_state={plate_handler_reset_state!r}. Allowed: {allowed}." + ) + if plate_columns is None: + if plate_handler_reset_state != self.PLATE_HANDLER_RESET_STATE_UNKNOWN: + raise ValueError("plate_handler_reset_state is only valid when plate_columns is set.") + return plate_handler_reset_state + if plate_handler_reset_state == self.PLATE_HANDLER_RESET_STATE_UNKNOWN: + raise ValueError( + "plate_columns requires an explicit plate_handler_reset_state. Use " + "'reset_confirmed' after manually lid-cycling the HT-200 back to column 1, " + "or 'continue_current_position' to intentionally continue from the current handler position." + ) + return plate_handler_reset_state + + def _resolve_plate_handler_manual_state( + self, + *, + plate_columns: Optional[int], + ) -> tuple[Optional[int], Optional[int]]: + if plate_columns is None: + return None, None + return self.plate_handler.require_manual_state() + + async def _ensure_temporary_protocol_prefix_order_safe(self, prefix: str) -> None: + conflicts = self._temporary_protocol_preceding_conflicts( + await self.driver.list_protocols(), + prefix, + ) + if conflicts: + reserved_anchor = self._temporary_protocol_sort_anchor(prefix) + raise RuntimeError( + "Temporary protocol prefix " + f"{prefix!r} is not safe on this device. These user protocols would sort before " + f"{reserved_anchor!r}: {conflicts}. Remove/rename them before setup or choose " + "a different reserved prefix." + ) + + async def _ensure_temporary_protocol_prefix_available(self, prefix: str) -> None: + protocols = await self.driver.list_protocols() + preceding = self._temporary_protocol_preceding_conflicts(protocols, prefix) + collisions = self._temporary_protocol_prefix_collisions(protocols, prefix) + conflicts = sorted(set(preceding + collisions), key=str.casefold) + if conflicts: + reserved_anchor = self._temporary_protocol_sort_anchor(prefix) + raise RuntimeError( + "Temporary protocol prefix " + f"{prefix!r} is not available on this device. These user protocols would sort before " + f"or collide with {reserved_anchor!r}: {conflicts}. Remove/rename them before " + "preparing a temporary protocol or choose a different reserved prefix." + ) + + def _temporary_protocol_sort_anchor(self, prefix: str) -> str: + prefix_text = prefix.strip() + if len(prefix_text) == 0: + raise ValueError("prefix cannot be empty.") + try: + prefix_text.encode("ascii") + except UnicodeEncodeError as exc: + raise ValueError("prefix must be ASCII.") from exc + return f"{prefix_text}_" + + def _temporary_protocol_preceding_conflicts(self, protocols: list[str], prefix: str) -> list[str]: + reserved_anchor = self._temporary_protocol_sort_anchor(prefix) + anchor_key = reserved_anchor.casefold() + conflicts = [] + for protocol_name in protocols: + protocol_key = protocol_name.casefold() + if protocol_key < anchor_key: + conflicts.append(protocol_name) + return sorted(conflicts, key=str.casefold) + + def _temporary_protocol_prefix_collisions(self, protocols: list[str], prefix: str) -> list[str]: + reserved_anchor = self._temporary_protocol_sort_anchor(prefix) + anchor_key = reserved_anchor.casefold() + collisions = [] + for protocol_name in protocols: + if protocol_name.casefold().startswith(anchor_key): + collisions.append(protocol_name) + return sorted(collisions, key=str.casefold) + + def _coerce_prepared_run( + self, + prepared_run: Union[PreparedElectroporationRun, Mapping[str, Any]], + ) -> PreparedElectroporationRun: + if isinstance(prepared_run, PreparedElectroporationRun): + return prepared_run + return PreparedElectroporationRun.from_dict(prepared_run) + + async def _run_with_ghost_touch( + self, + action: Callable[[_GhostTouchSession], GhostTouchResult], + ) -> GhostTouchResult: + self._require_setup() + return await self.driver.run_with_ghost_touch(action) + + async def _force_home_via_ghost_touch(self) -> None: + await self.driver.run_with_ghost_touch(lambda ghost_touch: ghost_touch.ensure_home()) + + async def _cleanup_temporary_protocol( + self, + protocol_name: str, + *, + missing_ok: bool, + ) -> TemporaryProtocolCleanupResult: + delete_result: Dict[str, Any] | None = None + delete_error: str | None = None + delete_retry_used = False + + try: + delete_result = await self.driver.delete_protocol( + protocol_name, + missing_ok=missing_ok, + ) + except RuntimeError as exc: + if "still exists after repeated delete attempts" not in str(exc): + delete_error = str(exc) + else: + delete_retry_used = True + try: + await self._force_home_via_ghost_touch() + delete_result = await self.driver.delete_protocol( + protocol_name, + missing_ok=missing_ok, + ) + except Exception as retry_exc: # pragma: no cover - hardware-specific recovery + delete_error = str(retry_exc) + except Exception as exc: # pragma: no cover - hardware-specific recovery + delete_error = str(exc) + + return TemporaryProtocolCleanupResult( + delete_result=delete_result, + delete_retry_used=delete_retry_used, + delete_error=delete_error, + ) + + async def _collect_matching_new_log( + self, + before_logs: set[str], + protocol_name: str, + ) -> MatchedRunLogResult: + # Logs are matched by "new since prepare" plus protocol name, rather than by "latest log", + # to avoid picking up unrelated historical runs. + after_logs = set(await self.driver.list_log_files()) + new_logs = sorted(after_logs - before_logs) + + for log_path in new_logs: + text = await self.driver.fetch_sd_file(log_path) + parsed = self.driver.parse_run_log(text) + if parsed["summary"]["protocol_name"] == protocol_name: + return MatchedRunLogResult( + before_count=len(before_logs), + after_count=len(after_logs), + new_log_paths=tuple(new_logs), + matched_log_path=log_path, + matched_log=parsed, + ) + + raise RuntimeError( + f"No new BTXDATA log matched protocol '{protocol_name}'. New logs: {new_logs}" + ) + + def _make_temporary_protocol_name(self, prefix: str) -> str: + reserved_anchor = self._temporary_protocol_sort_anchor(prefix) + timestamp = datetime.now().strftime("%m%d%H%M%S") + name = f"{reserved_anchor}{timestamp}" + if len(name.encode("ascii")) > self.UI_PROTOCOL_NAME_BYTES: + raise ValueError( + f"Generated temp protocol name {name!r} exceeds the " + f"{self.UI_PROTOCOL_NAME_BYTES}-byte Gemini UI limit. Shorten prefix={prefix!r}." + ) + return name + + def _now_utc_iso(self) -> str: + return datetime.now(timezone.utc).isoformat() + + +class BTXGeminiX2(Device): + """BTX Gemini X2 electroporator.""" + + def __init__( + self, + port: Optional[str] = None, + *, + file_transfer_control: Optional[FileTransferControl] = None, + ghost_touch_factory: Optional[GhostTouchFactory] = None, + ghost_touch_kwargs: Optional[dict[str, Any]] = None, + plate_handler: Optional[BTXHT200] = None, + temporary_protocol_prefix: str = BTXGeminiX2ElectroporationBackend.DEFAULT_TEMPORARY_PROTOCOL_PREFIX, + ) -> None: + driver = BTXGeminiX2Driver( + port=port, + file_transfer_control=file_transfer_control, + ghost_touch_factory=ghost_touch_factory, + ghost_touch_kwargs=ghost_touch_kwargs, + ) + super().__init__(driver=driver) + self.driver: BTXGeminiX2Driver = driver + self.plate_handler = plate_handler or BTXHT200() + self.electroporation = Electroporation( + backend=BTXGeminiX2ElectroporationBackend( + driver=driver, + plate_handler=self.plate_handler, + temporary_protocol_prefix=temporary_protocol_prefix, + ) + ) + self._capabilities = [self.electroporation] diff --git a/pylabrobot/btx/gemini_x2_tests.py b/pylabrobot/btx/gemini_x2_tests.py new file mode 100644 index 00000000000..eba634163cc --- /dev/null +++ b/pylabrobot/btx/gemini_x2_tests.py @@ -0,0 +1,637 @@ +import unittest +from typing import Any, Dict, List, Optional, cast + +from pylabrobot.btx.file_transfer_control import FileTransferControl +from pylabrobot.btx.gemini_x2 import ( + BTXGeminiX2Driver, + BTXGeminiX2ElectroporationBackend, + GhostTouchFactory, +) +from pylabrobot.btx.ht200 import BTXHT200 +from pylabrobot.capabilities.electroporation.standard import ( + ElectroporationPreparationDetails, + ElectroporationProtocol, + PreparedElectroporationRun, +) + + +class _DummySerial: + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def write(self, data: bytes) -> None: + del data + + async def read(self, num_bytes: int = 1) -> bytes: + del num_bytes + return b"" + + async def readline(self) -> bytes: + return b"" + + +class _FakeFileTransferControl: + def __init__(self) -> None: + self.port = "/dev/fake-btx" + self.setup_calls = 0 + self.stop_calls = 0 + self.protocols = ["CD", "JJ"] + self.log_snapshots: List[List[str]] = [] + self.log_contents: Dict[str, str] = {} + self.add_calls: List[Dict[str, Any]] = [] + self.delete_calls: List[Dict[str, Any]] = [] + self.delete_failures_before_success = 0 + self.version = "BTX Gemini 4.0.4" + self.serial_number = "1135421" + self.device_time = "03/09/2026 5:00:00 PM" + self._parser = FileTransferControl(port=self.port, serial_io=_DummySerial()) + + async def setup(self) -> None: + self.setup_calls += 1 + + async def stop(self) -> None: + self.stop_calls += 1 + + async def list_protocols(self) -> list[str]: + return list(self.protocols) + + async def add_protocol( + self, + protocol_name: str, + protocol: ElectroporationProtocol, + overwrite: bool = False, + ) -> Dict[str, Any]: + self.add_calls.append( + { + "protocol_name": protocol_name, + "protocol": protocol, + "overwrite": overwrite, + } + ) + self.protocols = sorted(self.protocols + [protocol_name]) + return {"operation": "add_protocol", "protocol": protocol_name, "overwrite": overwrite} + + async def delete_protocol(self, protocol_name: str, missing_ok: bool = False) -> Dict[str, Any]: + self.delete_calls.append({"protocol_name": protocol_name, "missing_ok": missing_ok}) + if self.delete_failures_before_success > 0: + self.delete_failures_before_success -= 1 + raise RuntimeError(f'Protocol "{protocol_name}" still exists after repeated delete attempts.') + if protocol_name not in self.protocols: + if missing_ok: + return {"operation": "delete_protocol", "deleted": False, "protocol": protocol_name} + raise FileNotFoundError(protocol_name) + self.protocols = [name for name in self.protocols if name != protocol_name] + return {"operation": "delete_protocol", "deleted": True, "protocol": protocol_name} + + async def list_log_files(self, root: str = "\\BTXDATA") -> list[str]: + del root + if self.log_snapshots: + return list(self.log_snapshots.pop(0)) + return sorted(self.log_contents) + + async def fetch_sd_file(self, sd_path: str) -> str: + return self.log_contents[sd_path] + + async def get_version(self) -> str: + return self.version + + async def get_serial_number(self) -> str: + return self.serial_number + + async def get_device_time(self) -> str: + return self.device_time + + def parse_run_log(self, text: str) -> Dict[str, Any]: + return self._parser.parse_run_log(text) + + +class _FakeGhostTouchSession: + def __init__(self, factory: "_FakeGhostTouchFactory", kwargs: Dict[str, Any]) -> None: + self.factory = factory + self.kwargs = kwargs + + def __enter__(self) -> "_FakeGhostTouchSession": + self.factory.entered += 1 + return self + + def __exit__(self, exc_type, exc, tb) -> None: + del exc_type, exc, tb + self.factory.exited += 1 + + def ensure_home(self) -> Dict[str, Any]: + self.factory.ensure_home_calls += 1 + return {"state": "main_menu"} + + def prepare_user_protocol( + self, + protocol_name: str, + plate_columns: Optional[int] = None, + ) -> dict[str, object]: + if self.factory.prepare_error is not None: + raise self.factory.prepare_error + call = { + "protocol_name": protocol_name, + "plate_columns": plate_columns, + "port": self.kwargs["port"], + } + self.factory.prepare_calls.append(call) + return { + "protocol_name": protocol_name, + "plate_columns": plate_columns, + "run_view": {"state": "protocol_run_view"}, + "prepared_verification": {"state": "protocol_run_view"}, + } + + def start_prepared_user_protocol( + self, + protocol_name: str, + home_after: bool = True, + max_run_seconds: float = 420.0, + ) -> dict[str, object]: + if self.factory.start_error is not None: + raise self.factory.start_error + call = { + "protocol_name": protocol_name, + "home_after": home_after, + "max_run_seconds": max_run_seconds, + "port": self.kwargs["port"], + } + self.factory.start_calls.append(call) + return { + "protocol_name": protocol_name, + "verification": {"state": "protocol_run_view"}, + "after_start": {"state": "protocol_run_view"}, + "completed": {"state": "protocol_finish"}, + } + + def cancel_prepared_user_protocol(self, home_after: bool = True) -> dict[str, object]: + if self.factory.cancel_error is not None: + raise self.factory.cancel_error + self.factory.cancel_calls.append({"home_after": home_after, "port": self.kwargs["port"]}) + return { + "cancelled": True, + "final_state": {"state": "main_menu"}, + "home_after": home_after, + } + + +class _FakeGhostTouchFactory: + def __init__(self) -> None: + self.created: List[Dict[str, Any]] = [] + self.prepare_calls: List[Dict[str, Any]] = [] + self.start_calls: List[Dict[str, Any]] = [] + self.cancel_calls: List[Dict[str, Any]] = [] + self.entered = 0 + self.exited = 0 + self.ensure_home_calls = 0 + self.prepare_error: Exception | None = None + self.start_error: Exception | None = None + self.cancel_error: Exception | None = None + + def __call__(self, **kwargs: Any) -> _FakeGhostTouchSession: + self.created.append(dict(kwargs)) + return _FakeGhostTouchSession(self, dict(kwargs)) + + +def _make_backend( + *, + file_transfer_control: Optional[_FakeFileTransferControl] = None, + plate_handler: Optional[BTXHT200] = None, + ghost_touch_factory: Optional[_FakeGhostTouchFactory] = None, + temporary_protocol_prefix: str = ( + BTXGeminiX2ElectroporationBackend.DEFAULT_TEMPORARY_PROTOCOL_PREFIX + ), +) -> BTXGeminiX2ElectroporationBackend: + driver = BTXGeminiX2Driver( + file_transfer_control=cast(Optional[FileTransferControl], file_transfer_control), + ghost_touch_factory=cast(Optional[GhostTouchFactory], ghost_touch_factory), + ) + return BTXGeminiX2ElectroporationBackend( + driver=driver, + plate_handler=plate_handler, + temporary_protocol_prefix=temporary_protocol_prefix, + ) + + +async def _setup_backend(backend: BTXGeminiX2ElectroporationBackend) -> None: + await backend.driver.setup() + await backend._on_setup() + + +def _prepare_params( + backend: BTXGeminiX2ElectroporationBackend, + state: str, +) -> BTXGeminiX2ElectroporationBackend.PrepareRunParams: + return backend.PrepareRunParams(plate_handler_reset_state=state) + + +class TestBTXGeminiX2Backend(unittest.IsolatedAsyncioTestCase): + async def test_prepare_temporary_protocol_adds_protocol_and_arms_run_view(self): + file_control = _FakeFileTransferControl() + file_control.log_snapshots = [[r"\BTXDATA\2026-03\260309\100000.TXT"]] + ghost_factory = _FakeGhostTouchFactory() + backend = _make_backend( + file_transfer_control=file_control, + plate_handler=BTXHT200(assumed_pulse_count=2, assumed_column_adjust=0), + ghost_touch_factory=ghost_factory, + ) + protocol = ElectroporationProtocol( + protocol_type="exponential", + pulse_amplitude_volts=2300, + gap_mm=2.0, + resistance_ohms=200, + capacitance_uf=25, + ) + + await _setup_backend(backend) + prepared = await backend.prepare_temporary_protocol( + protocol, + plate_columns=3, + backend_params=_prepare_params( + backend, + backend.PLATE_HANDLER_RESET_STATE_RESET_CONFIRMED, + ), + ) + + self.assertTrue(prepared.protocol_name.startswith("!PLR_")) + self.assertEqual(prepared.plate_columns, 3) + self.assertEqual(prepared.baseline_log_paths, (r"\BTXDATA\2026-03\260309\100000.TXT",)) + self.assertEqual(file_control.add_calls[0]["protocol"], protocol) + self.assertEqual(ghost_factory.prepare_calls[0]["protocol_name"], prepared.protocol_name) + self.assertEqual(prepared.prepare_result.prepared_state, "protocol_run_view") + self.assertEqual( + prepared.prepare_result.device_prepare["plate_handler_reset_state"], + backend.PLATE_HANDLER_RESET_STATE_RESET_CONFIRMED, + ) + self.assertEqual(prepared.prepare_result.device_prepare["assumed_plate_handler_pulse_count"], 2) + self.assertEqual( + prepared.prepare_result.device_prepare["assumed_plate_handler_column_adjust"], 0 + ) + + async def test_prepare_temporary_protocol_cleans_up_if_ui_prepare_fails(self): + file_control = _FakeFileTransferControl() + file_control.log_snapshots = [[r"\BTXDATA\2026-03\260309\100000.TXT"]] + ghost_factory = _FakeGhostTouchFactory() + ghost_factory.prepare_error = RuntimeError("prepare failed") + backend = _make_backend( + file_transfer_control=file_control, + ghost_touch_factory=ghost_factory, + ) + protocol = ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + ) + + await _setup_backend(backend) + with self.assertRaisesRegex(RuntimeError, "prepare failed"): + await backend.prepare_temporary_protocol(protocol) + + self.assertEqual(len(file_control.delete_calls), 1) + self.assertTrue(file_control.delete_calls[0]["missing_ok"]) + + async def test_prepare_temporary_protocol_uses_backend_default_prefix_when_not_overridden(self): + file_control = _FakeFileTransferControl() + ghost_factory = _FakeGhostTouchFactory() + backend = _make_backend( + file_transfer_control=file_control, + ghost_touch_factory=ghost_factory, + temporary_protocol_prefix="!TMP", + ) + protocol = ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + ) + + await _setup_backend(backend) + prepared = await backend.prepare_temporary_protocol(protocol) + + self.assertEqual(prepared.prefix, "!TMP") + self.assertTrue(prepared.protocol_name.startswith("!TMP_")) + + async def test_prepare_temporary_protocol_requires_explicit_plate_handler_reset_state(self): + file_control = _FakeFileTransferControl() + backend = _make_backend( + file_transfer_control=file_control, + plate_handler=BTXHT200(assumed_pulse_count=2, assumed_column_adjust=0), + ) + protocol = ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + ) + + await _setup_backend(backend) + + with self.assertRaisesRegex(ValueError, "requires an explicit plate_handler_reset_state"): + await backend.prepare_temporary_protocol(protocol, plate_columns=3) + + async def test_prepare_temporary_protocol_requires_assumed_plate_handler_manual_state(self): + file_control = _FakeFileTransferControl() + backend = _make_backend( + file_transfer_control=file_control, + plate_handler=BTXHT200(), + ) + protocol = ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + ) + + await _setup_backend(backend) + + with self.assertRaisesRegex( + ValueError, + "Missing: assumed_pulse_count, assumed_column_adjust", + ): + await backend.prepare_temporary_protocol( + protocol, + plate_columns=3, + backend_params=_prepare_params( + backend, + backend.PLATE_HANDLER_RESET_STATE_RESET_CONFIRMED, + ), + ) + + async def test_prepare_temporary_protocol_rejects_plate_handler_reset_state_without_columns(self): + file_control = _FakeFileTransferControl() + backend = _make_backend(file_transfer_control=file_control) + protocol = ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + ) + + await _setup_backend(backend) + + with self.assertRaisesRegex(ValueError, "only valid when plate_columns is set"): + await backend.prepare_temporary_protocol( + protocol, + backend_params=_prepare_params( + backend, + backend.PLATE_HANDLER_RESET_STATE_RESET_CONFIRMED, + ), + ) + + async def test_serialize_includes_plate_handler_backend_manual_state(self): + backend = _make_backend( + file_transfer_control=_FakeFileTransferControl(), + plate_handler=BTXHT200(assumed_pulse_count=2, assumed_column_adjust=1), + ) + + serialized_handler = backend.serialize()["plate_handler"] + self.assertEqual(serialized_handler["device"], "BTXHT200") + self.assertEqual(serialized_handler["assumed_pulse_count"], 2) + self.assertEqual(serialized_handler["assumed_column_adjust"], 1) + + async def test_start_prepared_run_verifies_runs_collects_log_and_cleans_up(self): + file_control = _FakeFileTransferControl() + file_control.log_snapshots = [ + [r"\BTXDATA\2026-03\260309\100000.TXT", r"\BTXDATA\2026-03\260309\100100.TXT"], + ] + file_control.log_contents[r"\BTXDATA\2026-03\260309\100100.TXT"] = "\n".join( + [ + "Protocol Name: !PLR_123456789", + "Protocol Result: Complete", + "Status: 0x00000000.00000000 - No error.", + ] + ) + ghost_factory = _FakeGhostTouchFactory() + backend = _make_backend( + file_transfer_control=file_control, + ghost_touch_factory=ghost_factory, + ) + prepared = PreparedElectroporationRun( + protocol_name="!PLR_123456789", + protocol=ElectroporationProtocol( + protocol_type="exponential", + pulse_amplitude_volts=2300, + gap_mm=2.0, + resistance_ohms=200, + capacitance_uf=25, + ), + plate_columns=3, + prefix="!PLR", + prepared_at_utc="2026-03-09T10:00:00+00:00", + baseline_log_paths=(r"\BTXDATA\2026-03\260309\100000.TXT",), + prepare_result=ElectroporationPreparationDetails( + prepared_state="protocol_run_view", + protocol_setup={}, + device_prepare={"prepared_verification": {"state": "protocol_run_view"}}, + ), + ) + + await _setup_backend(backend) + file_control.protocols.append("!PLR_123456789") + result = await backend.start_prepared_run(prepared.as_dict(), max_run_seconds=100.0) + + self.assertEqual(result.prepared_run.protocol_name, prepared.protocol_name) + self.assertEqual(ghost_factory.start_calls[0]["protocol_name"], prepared.protocol_name) + self.assertEqual(result.log_capture.matched_log_path, r"\BTXDATA\2026-03\260309\100100.TXT") + self.assertTrue(result.cleanup.deleted) + self.assertIsNone(result.cleanup.error) + + async def test_start_prepared_run_leaves_protocol_for_explicit_cancel_if_verification_fails(self): + file_control = _FakeFileTransferControl() + ghost_factory = _FakeGhostTouchFactory() + ghost_factory.start_error = RuntimeError("verification failed") + backend = _make_backend( + file_transfer_control=file_control, + ghost_touch_factory=ghost_factory, + ) + prepared = PreparedElectroporationRun( + protocol_name="!PLR_123456789", + protocol=ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + ), + plate_columns=None, + prefix="!PLR", + prepared_at_utc="2026-03-09T10:00:00+00:00", + baseline_log_paths=(), + prepare_result=ElectroporationPreparationDetails( + prepared_state=None, + protocol_setup={}, + device_prepare={}, + ), + ) + + await _setup_backend(backend) + file_control.protocols.append("!PLR_123456789") + with self.assertRaisesRegex(RuntimeError, "verification failed"): + await backend.start_prepared_run(prepared.as_dict()) + + self.assertEqual(file_control.delete_calls, []) + self.assertIn(prepared.protocol_name, file_control.protocols) + + async def test_cancel_prepared_run_homes_and_deletes(self): + file_control = _FakeFileTransferControl() + ghost_factory = _FakeGhostTouchFactory() + backend = _make_backend( + file_transfer_control=file_control, + ghost_touch_factory=ghost_factory, + ) + prepared = PreparedElectroporationRun( + protocol_name="!PLR_123456789", + protocol=ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + ), + plate_columns=None, + prefix="!PLR", + prepared_at_utc="2026-03-09T10:00:00+00:00", + baseline_log_paths=(), + prepare_result=ElectroporationPreparationDetails( + prepared_state=None, + protocol_setup={}, + device_prepare={}, + ), + ) + + await _setup_backend(backend) + file_control.protocols.append("!PLR_123456789") + result = await backend.cancel_prepared_run(prepared.as_dict()) + + self.assertTrue(result.cleanup.deleted) + self.assertEqual(ghost_factory.cancel_calls[0]["home_after"], True) + self.assertNotIn(prepared.protocol_name, file_control.protocols) + + async def test_setup_rejects_unsafe_default_temp_prefix(self): + file_control = _FakeFileTransferControl() + file_control.protocols = ["!AAA", "CD"] + backend = _make_backend(file_transfer_control=file_control) + + with self.assertRaisesRegex(RuntimeError, r"Temporary protocol prefix '!PLR' is not safe"): + await _setup_backend(backend) + + self.assertEqual(file_control.setup_calls, 1) + self.assertEqual(file_control.stop_calls, 0) + + async def test_setup_allows_existing_reserved_temp_prefix_for_resume(self): + file_control = _FakeFileTransferControl() + file_control.protocols = ["!PLR_OLD", "CD"] + backend = _make_backend(file_transfer_control=file_control) + + await _setup_backend(backend) + + async def test_prepare_temporary_protocol_rejects_unsafe_custom_prefix(self): + file_control = _FakeFileTransferControl() + file_control.protocols = ["!PLX_OLD", "CD"] + backend = _make_backend(file_transfer_control=file_control) + protocol = ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + ) + + await _setup_backend(backend) + + with self.assertRaisesRegex(RuntimeError, r"Temporary protocol prefix '!PLY' is not available"): + await backend.prepare_temporary_protocol(protocol, prefix="!PLY") + + async def test_prepare_temporary_protocol_rejects_existing_reserved_temp_prefix(self): + file_control = _FakeFileTransferControl() + file_control.protocols = ["!PLR_OLD", "CD"] + backend = _make_backend(file_transfer_control=file_control) + protocol = ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + ) + + await _setup_backend(backend) + + with self.assertRaisesRegex(RuntimeError, r"not available.*!PLR_OLD"): + await backend.prepare_temporary_protocol(protocol) + + async def test_cancel_prepared_run_retries_delete_after_forcing_home(self): + file_control = _FakeFileTransferControl() + file_control.delete_failures_before_success = 1 + ghost_factory = _FakeGhostTouchFactory() + backend = _make_backend( + file_transfer_control=file_control, + ghost_touch_factory=ghost_factory, + ) + prepared = PreparedElectroporationRun( + protocol_name="!PLR_123456789", + protocol=ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + ), + plate_columns=None, + prefix="!PLR", + prepared_at_utc="2026-03-09T10:00:00+00:00", + baseline_log_paths=(), + prepare_result=ElectroporationPreparationDetails( + prepared_state=None, + protocol_setup={}, + device_prepare={}, + ), + ) + + await _setup_backend(backend) + file_control.protocols.append("!PLR_123456789") + result = await backend.cancel_prepared_run(prepared.as_dict()) + + self.assertTrue(result.cleanup.retry_used) + self.assertEqual(ghost_factory.ensure_home_calls, 1) + self.assertEqual(len(file_control.delete_calls), 2) + + async def test_get_device_info(self): + file_control = _FakeFileTransferControl() + backend = _make_backend( + file_transfer_control=file_control, + plate_handler=BTXHT200(assumed_pulse_count=2, assumed_column_adjust=1), + ) + + await _setup_backend(backend) + info = await backend.get_device_info() + + self.assertEqual(info["model"], "Gemini X2") + self.assertEqual(info["serial_number"], "1135421") + self.assertEqual(info["protocol_count"], 2) + self.assertTrue(info["supports_prepared_temporary_runs"]) + self.assertTrue(info["supports_serialized_prepared_runs"]) + self.assertFalse(info["supports_stored_protocol_runs"]) + self.assertTrue(info["supports_plate_handler_reset_state"]) + self.assertIn("reset_confirmed", info["plate_handler_reset_states"]) + self.assertEqual(info["plate_handler"]["model"], "HT-200") + self.assertEqual(info["plate_handler"]["assumed_pulse_count"], 2) + self.assertEqual(info["plate_handler"]["assumed_column_adjust"], 1) + self.assertEqual(info["temporary_protocol_prefix"], "!PLR") + + async def test_requires_setup_before_use(self): + backend = _make_backend(file_transfer_control=_FakeFileTransferControl()) + + with self.assertRaisesRegex(RuntimeError, r"Call setup\(\) before"): + await backend.prepare_temporary_protocol( + ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + ) + ) + + async def test_temp_name_rejects_overlong_prefix(self): + backend = _make_backend(file_transfer_control=_FakeFileTransferControl()) + + with self.assertRaisesRegex(ValueError, "exceeds the 15-byte"): + backend._make_temporary_protocol_name("!PLR_TOO_LONG") diff --git a/pylabrobot/btx/ht200.py b/pylabrobot/btx/ht200.py new file mode 100644 index 00000000000..62b5305ecc6 --- /dev/null +++ b/pylabrobot/btx/ht200.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + + +class BTXHT200: + """Manual-state model for the BTX HT-200 plate handler. + + The HT-200 has no separate software control path here. Column handling is driven through the + Gemini X2 UI, so this object owns only the caller's assumed manual handler state. + """ + + def __init__( + self, + *, + assumed_pulse_count: Optional[int] = None, + assumed_column_adjust: Optional[int] = None, + ) -> None: + self._assumed_pulse_count = self._coerce_assumed_pulse_count(assumed_pulse_count) + self._assumed_column_adjust = self._coerce_assumed_column_adjust(assumed_column_adjust) + + @property + def assumed_pulse_count(self) -> Optional[int]: + return self._assumed_pulse_count + + @property + def assumed_column_adjust(self) -> Optional[int]: + return self._assumed_column_adjust + + def configure_manual_state( + self, + *, + pulse_count: Optional[int] = None, + column_adjust: Optional[int] = None, + ) -> None: + """Record the caller's current HT-200 manual configuration assumptions.""" + self._assumed_pulse_count = self._coerce_assumed_pulse_count(pulse_count) + self._assumed_column_adjust = self._coerce_assumed_column_adjust(column_adjust) + + def clear_manual_state(self) -> None: + """Forget the current HT-200 manual configuration assumptions.""" + self._assumed_pulse_count = None + self._assumed_column_adjust = None + + def require_manual_state(self) -> tuple[int, int]: + """Return the configured manual assumptions needed for a Gemini plate-handler run.""" + pulse_count = self._assumed_pulse_count + column_adjust = self._assumed_column_adjust + missing = [] + if pulse_count is None: + missing.append("assumed_pulse_count") + if column_adjust is None: + missing.append("assumed_column_adjust") + if missing: + raise ValueError( + "HT-200 manual state is not fully configured. Missing: " + f"{', '.join(missing)}. Configure the HT-200 before preparing a run " + "that uses plate_columns." + ) + assert pulse_count is not None + assert column_adjust is not None + return pulse_count, column_adjust + + def get_device_info(self) -> Dict[str, Any]: + return { + "device": self.__class__.__name__, + "model": "HT-200", + "access_control_mode": "manual", + "manual_access_effect": "lid_cycle_resets_column_start_to_1", + "assumed_pulse_count": self._assumed_pulse_count, + "assumed_column_adjust": self._assumed_column_adjust, + } + + def _coerce_assumed_pulse_count(self, value: Optional[int]) -> Optional[int]: + if value is None: + return None + pulse_count = int(value) + if pulse_count <= 0: + raise ValueError("assumed_pulse_count must be a positive integer or None.") + return pulse_count + + def _coerce_assumed_column_adjust(self, value: Optional[int]) -> Optional[int]: + if value is None: + return None + return int(value) diff --git a/pylabrobot/btx/test_data/gemini_x2/screens/00_main_menu.png b/pylabrobot/btx/test_data/gemini_x2/screens/00_main_menu.png new file mode 100644 index 00000000000..d090cda6864 Binary files /dev/null and b/pylabrobot/btx/test_data/gemini_x2/screens/00_main_menu.png differ diff --git a/pylabrobot/btx/test_data/gemini_x2/screens/01_user_protocols_top.png b/pylabrobot/btx/test_data/gemini_x2/screens/01_user_protocols_top.png new file mode 100644 index 00000000000..194e265a28c Binary files /dev/null and b/pylabrobot/btx/test_data/gemini_x2/screens/01_user_protocols_top.png differ diff --git a/pylabrobot/btx/test_data/gemini_x2/screens/02_protocol_summary.png b/pylabrobot/btx/test_data/gemini_x2/screens/02_protocol_summary.png new file mode 100644 index 00000000000..1001888cb95 Binary files /dev/null and b/pylabrobot/btx/test_data/gemini_x2/screens/02_protocol_summary.png differ diff --git a/pylabrobot/btx/test_data/gemini_x2/screens/03_run_protocol_prerun.png b/pylabrobot/btx/test_data/gemini_x2/screens/03_run_protocol_prerun.png new file mode 100644 index 00000000000..7362a51f821 Binary files /dev/null and b/pylabrobot/btx/test_data/gemini_x2/screens/03_run_protocol_prerun.png differ diff --git a/pylabrobot/btx/test_data/gemini_x2/screens/04_set_plate_columns_open.png b/pylabrobot/btx/test_data/gemini_x2/screens/04_set_plate_columns_open.png new file mode 100644 index 00000000000..86dc18de237 Binary files /dev/null and b/pylabrobot/btx/test_data/gemini_x2/screens/04_set_plate_columns_open.png differ diff --git a/pylabrobot/btx/test_data/gemini_x2/screens/05_set_plate_columns_after_first_confirm.png b/pylabrobot/btx/test_data/gemini_x2/screens/05_set_plate_columns_after_first_confirm.png new file mode 100644 index 00000000000..6651677e52f Binary files /dev/null and b/pylabrobot/btx/test_data/gemini_x2/screens/05_set_plate_columns_after_first_confirm.png differ diff --git a/pylabrobot/btx/test_data/gemini_x2/screens/06_set_plate_columns_confirmed_run_view.png b/pylabrobot/btx/test_data/gemini_x2/screens/06_set_plate_columns_confirmed_run_view.png new file mode 100644 index 00000000000..5f538588299 Binary files /dev/null and b/pylabrobot/btx/test_data/gemini_x2/screens/06_set_plate_columns_confirmed_run_view.png differ diff --git a/pylabrobot/btx/test_data/gemini_x2/screens/07_go_prerun.png b/pylabrobot/btx/test_data/gemini_x2/screens/07_go_prerun.png new file mode 100644 index 00000000000..01553f0f7ca Binary files /dev/null and b/pylabrobot/btx/test_data/gemini_x2/screens/07_go_prerun.png differ diff --git a/pylabrobot/btx/test_data/gemini_x2/screens/08_go_delivering_pulse.png b/pylabrobot/btx/test_data/gemini_x2/screens/08_go_delivering_pulse.png new file mode 100644 index 00000000000..977b034b940 Binary files /dev/null and b/pylabrobot/btx/test_data/gemini_x2/screens/08_go_delivering_pulse.png differ diff --git a/pylabrobot/btx/test_data/gemini_x2/screens/09_go_pulses_delivered.png b/pylabrobot/btx/test_data/gemini_x2/screens/09_go_pulses_delivered.png new file mode 100644 index 00000000000..0d583fb6b84 Binary files /dev/null and b/pylabrobot/btx/test_data/gemini_x2/screens/09_go_pulses_delivered.png differ diff --git a/pylabrobot/btx/test_data/gemini_x2/screens/10_returned_home_after_go.png b/pylabrobot/btx/test_data/gemini_x2/screens/10_returned_home_after_go.png new file mode 100644 index 00000000000..8e5ba0d52ec Binary files /dev/null and b/pylabrobot/btx/test_data/gemini_x2/screens/10_returned_home_after_go.png differ diff --git a/pylabrobot/btx/test_data/gemini_x2/screens/contact_sheet.png b/pylabrobot/btx/test_data/gemini_x2/screens/contact_sheet.png new file mode 100644 index 00000000000..e58ff52f70a Binary files /dev/null and b/pylabrobot/btx/test_data/gemini_x2/screens/contact_sheet.png differ diff --git a/pylabrobot/btx/test_data/gemini_x2/screens/metadata.json b/pylabrobot/btx/test_data/gemini_x2/screens/metadata.json new file mode 100644 index 00000000000..96355131d06 --- /dev/null +++ b/pylabrobot/btx/test_data/gemini_x2/screens/metadata.json @@ -0,0 +1,167 @@ +{ + "schema_version": 2, + "description": "Selected BTX Gemini X2 RSI screen fixtures from a physical end-to-end run with GO.", + "device": { + "model": "BTX Gemini X2", + "firmware": "BTX Gemini 4.0.4" + }, + "capture": { + "kind": "physical_device_rsi_capture", + "captured_at_utc": "2026-05-26T07:42:48+00:00", + "go_pressed": true, + "plate_columns": 3, + "temporary_protocol_deleted": true, + "matched_log_path": "\\BTXDATA\\2026-05\\260526\\153650.TXT", + "notes": [ + "Screens were captured from the physical RSI framebuffer at 800x480.", + "Only selected fixture PNGs and this portable manifest are kept in the repository.", + "Raw capture paths and host-specific serial port details are intentionally omitted.", + "The Set Plate Columns flow returns protocol_details after the first confirm and protocol_run_view after the second confirm." + ] + }, + "temporary_protocol": { + "name": "!PLR_0526154053", + "protocol_type": "square", + "pulse_amplitude_volts": 250, + "gap_mm": 2.0, + "pulse_count": 1, + "pulse_interval_seconds": 0.0, + "duration_us": 1000, + "resistance_ohms": null, + "capacitance_uf": null + }, + "run_log_summary": { + "protocol_name": "!PLR_0526154053", + "protocol_type": "Square Wave", + "pulse_amplitude_volts": 250, + "plate_columns": 3, + "pulse_1_voltage_volts": 262.45, + "protocol_result": "Complete", + "status_code": "0x00000000.00000000", + "status_message": "(No error.)" + }, + "screens": [ + { + "label": "00_main_menu", + "state": "main_menu", + "confidence": 1.0, + "image": "00_main_menu.png", + "rgb_sha1": "c4566e00637c64a464d80d0f9df77f51f9b53e65", + "matched": [ + "main menu" + ] + }, + { + "label": "01_user_protocols_top", + "state": "user_protocols", + "confidence": 1.0, + "image": "01_user_protocols_top.png", + "rgb_sha1": "98c19ee5064b42a3d781edc48eccbb3a3172a138", + "matched": [ + "user protocols" + ] + }, + { + "label": "02_protocol_summary", + "state": "unknown", + "confidence": 0.0, + "image": "02_protocol_summary.png", + "rgb_sha1": "5178b2eceefa0501787ec0267ba57390efbd7d11", + "matched": [] + }, + { + "label": "03_run_protocol_prerun", + "state": "protocol_run_view", + "confidence": 0.82, + "image": "03_run_protocol_prerun.png", + "rgb_sha1": "ff5ed097b3fb46c9a3718d9314eded590e45ae9e", + "matched": [ + "run protocol", + "set meas", + "go" + ] + }, + { + "label": "04_set_plate_columns_open", + "state": "protocol_details", + "confidence": 1.0, + "image": "04_set_plate_columns_open.png", + "rgb_sha1": "0ebad043b0e09499d09ec5e54cda28621ae01967", + "matched": [ + "protocol details marker" + ] + }, + { + "label": "05_set_plate_columns_after_first_confirm", + "state": "protocol_details", + "confidence": 1.0, + "image": "05_set_plate_columns_after_first_confirm.png", + "rgb_sha1": "e766c8f9472f95aa75ed0e27deb29e34e6ab5be5", + "matched": [ + "protocol details marker" + ] + }, + { + "label": "06_set_plate_columns_confirmed_run_view", + "state": "protocol_run_view", + "confidence": 0.82, + "image": "06_set_plate_columns_confirmed_run_view.png", + "rgb_sha1": "d048afe888bc0a2f1ca0e82d586757495d652f96", + "matched": [ + "run protocol", + "set meas", + "go" + ] + }, + { + "label": "07_go_prerun", + "state": "protocol_run_view", + "confidence": 0.82, + "image": "07_go_prerun.png", + "rgb_sha1": "65db3191e1be29e0e52296a831ca60f2fe7cc5ce", + "matched": [ + "run protocol", + "set meas", + "go" + ] + }, + { + "label": "08_go_delivering_pulse", + "state": "protocol_run_view", + "confidence": 0.82, + "image": "08_go_delivering_pulse.png", + "rgb_sha1": "a476cc0f193e37a6edd336f32af7138311948376", + "matched": [ + "run protocol", + "set meas", + "delivering pulse" + ] + }, + { + "label": "09_go_pulses_delivered", + "state": "protocol_finish", + "confidence": 1.0, + "image": "09_go_pulses_delivered.png", + "rgb_sha1": "8482a5f644f1fd307b60884efa1efb053d7ced2c", + "matched": [ + "run protocol", + "pulses delivered", + "press to clear message" + ] + }, + { + "label": "10_returned_home_after_go", + "state": "main_menu", + "confidence": 1.0, + "image": "10_returned_home_after_go.png", + "rgb_sha1": "dca38d5e9c5af6fe94796017a09669d72007ef23", + "matched": [ + "main menu" + ] + } + ], + "extra_fixtures": { + "user_protocols_double_up_active": "user_protocols_double_up_active.png", + "user_protocols_double_up_inactive": "user_protocols_double_up_inactive.png" + } +} diff --git a/pylabrobot/btx/test_data/gemini_x2/screens/user_protocols_double_up_active.png b/pylabrobot/btx/test_data/gemini_x2/screens/user_protocols_double_up_active.png new file mode 100644 index 00000000000..16cf8cb703e Binary files /dev/null and b/pylabrobot/btx/test_data/gemini_x2/screens/user_protocols_double_up_active.png differ diff --git a/pylabrobot/btx/test_data/gemini_x2/screens/user_protocols_double_up_inactive.png b/pylabrobot/btx/test_data/gemini_x2/screens/user_protocols_double_up_inactive.png new file mode 100644 index 00000000000..194e265a28c Binary files /dev/null and b/pylabrobot/btx/test_data/gemini_x2/screens/user_protocols_double_up_inactive.png differ diff --git a/pylabrobot/btx/the_ghost_touch.py b/pylabrobot/btx/the_ghost_touch.py new file mode 100644 index 00000000000..e6008de5d67 --- /dev/null +++ b/pylabrobot/btx/the_ghost_touch.py @@ -0,0 +1,1069 @@ +from __future__ import annotations + +import asyncio +import hashlib +import os +import re +import shutil +import subprocess +import tempfile +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional, Protocol, cast, runtime_checkable + +try: + import numpy as np + + _HAS_NUMPY = True +except ImportError as e: + _HAS_NUMPY = False + _NUMPY_IMPORT_ERROR = e + np = cast(Any, None) + +try: + from PIL import Image + + _HAS_PIL = True +except ImportError as e: + _HAS_PIL = False + _PIL_IMPORT_ERROR = e + Image = cast(Any, None) + +try: + import serial + + _HAS_SERIAL = True +except ImportError as e: + _HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e + serial = cast(Any, None) + +from pylabrobot.io.serial import Serial + +FRAME_W = 800 +FRAME_H = 480 +FRAME_BYTES = FRAME_W * FRAME_H * 4 + +STATE_MAIN_MENU = "main_menu" +STATE_USER_PROTOCOLS = "user_protocols" +STATE_PROTOCOL_RUN_VIEW = "protocol_run_view" +STATE_PROTOCOL_DETAILS = "protocol_details" +STATE_PROTOCOL_RAN = "protocol_ran" +STATE_PROTOCOL_FINISH = "protocol_finish" +STATE_UNKNOWN = "unknown" + +HOME_COORD = (726, 326) +USER_PROTOCOLS_MENU_COORD = (164, 183) +USER_PROTOCOLS_SCROLL_DOUBLE_UP_COORD = (449, 127) +USER_PROTOCOLS_DOUBLE_UP_BBOX = (395, 88, 478, 165) +USER_PROTOCOLS_FIRST_ROW_COORD = (176, 183) +DETAIL_CONFIRM_COORD = (739, 414) +GO_COORD = (739, 414) +SET_COLUMNS_OPEN_COORD = (660, 239) +SET_COLUMNS_CHECK_COORD = (739, 414) +SET_COLUMNS_KEY_COORDS = { + "7": (85, 261), + "8": (178, 261), + "9": (272, 261), + "4": (85, 314), + "5": (178, 314), + "6": (272, 314), + "1": (85, 367), + "2": (178, 367), + "3": (272, 367), + "0": (178, 420), + "delete": (272, 420), +} + + +@dataclass +class FrameCapture: + """One raw RSI frame plus hashes used for debugging and stability checks.""" + + rgba: np.ndarray + raw_len: int + frame_sha1: str + stable_sha1: str + + +def _decode_rsi_framebuffer(framebuffer: bytes) -> np.ndarray: + """Convert one Gemini RSI `scap` framebuffer into opaque RGBA pixels.""" + arr = np.frombuffer(framebuffer, dtype=np.uint8).reshape((FRAME_H, FRAME_W, 4)) + rgba = np.empty((FRAME_H, FRAME_W, 4), dtype=np.uint8) + # Live captures and the original RSI pcap previews decode correctly as BGRX/BGRA. + # The fourth byte is not a usable PNG alpha channel, so snapshots are saved opaque. + rgba[:, :, :3] = arr[:, :, [2, 1, 0]] + rgba[:, :, 3] = 255 + return rgba + + +@dataclass +class Detection: + """OCR-derived interpretation of a Gemini screen snapshot.""" + + state: str + confidence: float + matched: list[str] + text: str + text_norm: str + + +@dataclass +class Snapshot: + """Saved frame plus the screen-state detection produced from it.""" + + frame: FrameCapture + image_path: str + detection: Detection + + +@dataclass(frozen=True) +class ScreenSnapshotResult: + state: str + image_path: str + + def as_dict(self) -> dict[str, str]: + return { + "state": self.state, + "image_path": self.image_path, + } + + +@dataclass(frozen=True) +class PreparedUserProtocolResult: + protocol_name: str + plate_columns: Optional[int] + run_view: ScreenSnapshotResult + after_set_plate_columns: Optional[ScreenSnapshotResult] + prepared_verification: ScreenSnapshotResult + + def as_dict(self) -> dict[str, Any]: + result = { + "protocol_name": self.protocol_name, + "plate_columns": self.plate_columns, + "run_view": self.run_view.as_dict(), + "prepared_verification": self.prepared_verification.as_dict(), + } + if self.after_set_plate_columns is not None: + result["after_set_plate_columns"] = self.after_set_plate_columns.as_dict() + return result + + +@dataclass(frozen=True) +class StartedPreparedUserProtocolResult: + protocol_name: str + verification: ScreenSnapshotResult + after_start: ScreenSnapshotResult + completed: ScreenSnapshotResult + home: Optional[ScreenSnapshotResult] + + def as_dict(self) -> dict[str, Any]: + result = { + "protocol_name": self.protocol_name, + "verification": self.verification.as_dict(), + "after_start": self.after_start.as_dict(), + "completed": self.completed.as_dict(), + } + if self.home is not None: + result["home"] = self.home.as_dict() + return result + + +@dataclass(frozen=True) +class CancelledPreparedUserProtocolResult: + cancelled: bool + home_after: bool + final_state: ScreenSnapshotResult + + def as_dict(self) -> dict[str, Any]: + return { + "cancelled": self.cancelled, + "home_after": self.home_after, + "final_state": self.final_state.as_dict(), + } + + +class TheGhostTouch: + """Verified RSI touchscreen control for the BTX Gemini X2. + + This control intentionally supports only the user-protocol path used by the BTX end-to-end + workflow: Home -> User Protocols -> first sorted protocol -> Run Protocol -> optional plate + columns -> GO -> wait done. + """ + + def __init__( + self, + port: str, + baud: int = 115200, + artifact_dir: Optional[str] = None, + timeout: float = 15.0, + retries: int = 5, + min_conf: float = 0.70, + down_ms: int = 70, + ) -> None: + self.port = port + self.baud = baud + self.timeout = timeout + self.retries = retries + self.min_conf = min_conf + self.down_ms = down_ms + if artifact_dir is None: + artifact_dir = str(Path(tempfile.gettempdir()) / "pylabrobot-btx-the-ghost-touch") + self.artifact_dir = artifact_dir + self._transport = _RSITransport(port=port, baud=baud, timeout=timeout, retries=retries) + self._detector = _GeminiScreenDetector(min_conf=min_conf) + self.ser: serial.Serial | None = None + + def __enter__(self) -> "TheGhostTouch": + """Open the RSI serial session.""" + self._require_dependencies() + self._get_transport().open() + self.ser = self._get_transport().ser + return self + + def __exit__(self, exc_type, exc, tb) -> None: + """Close the RSI serial session.""" + self._get_transport().close() + self.ser = None + + def _require_dependencies(self) -> None: + if not _HAS_SERIAL: + raise RuntimeError( + "pyserial is required for TheGhostTouch. Install with: pip install pylabrobot[btx]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) + if not _HAS_NUMPY: + raise RuntimeError( + "numpy is required for TheGhostTouch frame handling. Install with: pip install pylabrobot[btx]. " + f"Import error: {_NUMPY_IMPORT_ERROR}" + ) + if not _HAS_PIL: + raise RuntimeError( + "Pillow is required for TheGhostTouch image handling. Install with: pip install pylabrobot[btx]. " + f"Import error: {_PIL_IMPORT_ERROR}" + ) + if shutil.which("tesseract") is None: + raise RuntimeError( + "TheGhostTouch requires the external `tesseract` command for OCR. " + "Install the Python dependencies with `pip install pylabrobot[btx]`, then install " + "Tesseract for your operating system and make the `tesseract` command available on PATH." + ) + + def _get_transport(self) -> _RSITransport: + transport = getattr(self, "_transport", None) + if transport is None: + transport = _RSITransport( + port=self.port, + baud=self.baud, + timeout=self.timeout, + retries=self.retries, + ) + self._transport = transport + return cast(_RSITransport, transport) + + def _get_detector(self) -> _GeminiScreenDetector: + detector = getattr(self, "_detector", None) + if detector is None: + detector = _GeminiScreenDetector(min_conf=self.min_conf) + self._detector = detector + return cast(_GeminiScreenDetector, detector) + + def prepare_user_protocol( + self, + protocol_name: str, + plate_columns: Optional[int] = None, + ) -> PreparedUserProtocolResult: + """Navigate to ``Run Protocol`` and optionally configure HT-200 plate columns.""" + run_view = self.goto_user_protocol_run_view(protocol_name) + after_set_plate_columns: ScreenSnapshotResult | None = None + if plate_columns is not None: + after_columns = self.set_plate_columns(plate_columns) + after_set_plate_columns = self._snapshot_result(after_columns) + + verified = self.verify_prepared_user_protocol(protocol_name) + return PreparedUserProtocolResult( + protocol_name=protocol_name, + plate_columns=plate_columns, + run_view=self._snapshot_result(run_view), + after_set_plate_columns=after_set_plate_columns, + prepared_verification=self._snapshot_result(verified), + ) + + def start_prepared_user_protocol( + self, + protocol_name: str, + home_after: bool = True, + max_run_seconds: float = 420.0, + ) -> StartedPreparedUserProtocolResult: + """Verify the armed screen, press ``GO``, wait until done, and optionally return home.""" + verified = self.verify_prepared_user_protocol(protocol_name) + start = self.start_run() + done = self.wait_run_done(max_seconds=max_run_seconds) + home = None if not home_after else self.ensure_home() + + return StartedPreparedUserProtocolResult( + protocol_name=protocol_name, + verification=self._snapshot_result(verified), + after_start=self._snapshot_result(start), + completed=self._snapshot_result(done), + home=None if home is None else self._snapshot_result(home), + ) + + def cancel_prepared_user_protocol( + self, home_after: bool = True + ) -> CancelledPreparedUserProtocolResult: + """Leave the prepared UI state without starting electroporation.""" + home = self.ensure_home() if home_after else self.snapshot("cancel-prepared-current") + return CancelledPreparedUserProtocolResult( + cancelled=True, + home_after=home_after, + final_state=self._snapshot_result(home), + ) + + def ensure_home(self) -> Snapshot: + """Return the Gemini UI to ``Main Menu`` using the fixed Home control.""" + current = self.snapshot("ensure-home-start") + if current.detection.state == STATE_MAIN_MENU and current.detection.confidence >= self.min_conf: + return current + if current.detection.state == STATE_PROTOCOL_DETAILS: + current = self._close_protocol_details(current) + if ( + current.detection.state == STATE_MAIN_MENU and current.detection.confidence >= self.min_conf + ): + return current + + for idx in range(6): + snap = self.tap_and_wait( + HOME_COORD[0], + HOME_COORD[1], + expected_states={STATE_MAIN_MENU}, + timeout=6.0, + interval=0.4, + prefix=f"ensure-home-{idx}", + ) + if snap is not None: + return snap + + raise RuntimeError("Failed to reach Main Menu via Home.") + + def _close_protocol_details(self, current: Snapshot) -> Snapshot: + """Close the protocol-details modal before trying fixed-position Home.""" + if current.detection.state != STATE_PROTOCOL_DETAILS: + return current + + for attempt in range(3): + closed = self.tap_and_wait( + SET_COLUMNS_CHECK_COORD[0], + SET_COLUMNS_CHECK_COORD[1], + expected_states={STATE_PROTOCOL_RUN_VIEW, STATE_PROTOCOL_DETAILS}, + timeout=8.0, + interval=0.45, + prefix=f"close-protocol-details-{attempt}", + down_ms=max(self.down_ms, 90), + initial_delay=0.4, + ) + if closed is None: + raise RuntimeError("Lost screen state while closing Protocol Details.") + current = closed + if current.detection.state == STATE_PROTOCOL_RUN_VIEW: + return current + + raise RuntimeError("Failed to close Protocol Details.") + + def goto_user_protocol_run_view(self, protocol_name: str) -> Snapshot: + """Open the first sorted user protocol and reach its ``Run Protocol`` screen.""" + current = self.snapshot("goto-user-run-start") + if current.detection.state == STATE_PROTOCOL_RUN_VIEW: + if self._run_view_matches_protocol(current.image_path, protocol_name) is not False: + return current + + last_error = "not attempted" + for attempt in range(3): + if current.detection.state != STATE_MAIN_MENU: + current = self.ensure_home() + if current.detection.state != STATE_MAIN_MENU: + raise RuntimeError(f"Expected Main Menu, got {current.detection.state}.") + + try: + current = self._open_user_protocols(attempt) + current = self._select_first_user_protocol(attempt) + current = self._confirm_user_protocol_summary(current, protocol_name, attempt) + self._verify_run_view_protocol(current, protocol_name) + except RuntimeError as exc: + last_error = str(exc) + current = self.ensure_home() + time.sleep(1.0) + continue + return current + + raise RuntimeError(f"Failed to reach Run Protocol for '{protocol_name}': {last_error}") + + def set_plate_columns(self, columns: int) -> Snapshot: + """Open ``Set Plate Columns`` and confirm the requested HT-200 column count.""" + if not 0 <= columns <= 12: + raise RuntimeError("plate_columns must be in the range 0..12.") + + current = self.snapshot("set-cols-start") + if current.detection.state != STATE_PROTOCOL_RUN_VIEW: + raise RuntimeError(f"Expected Run Protocol view, got {current.detection.state}.") + + opened = self.tap_and_wait( + SET_COLUMNS_OPEN_COORD[0], + SET_COLUMNS_OPEN_COORD[1], + expected_states={STATE_PROTOCOL_DETAILS}, + timeout=8.0, + interval=0.45, + prefix="set-cols-open", + down_ms=max(self.down_ms, 80), + ) + if opened is None: + raise RuntimeError("Failed to open Set Plate Columns.") + + self._enter_set_columns_value(columns) + closed = self.tap_and_wait( + SET_COLUMNS_CHECK_COORD[0], + SET_COLUMNS_CHECK_COORD[1], + expected_states={STATE_PROTOCOL_RUN_VIEW, STATE_PROTOCOL_DETAILS}, + timeout=8.0, + interval=0.45, + prefix="set-cols-check", + down_ms=max(self.down_ms, 90), + ) + if closed is not None and closed.detection.state == STATE_PROTOCOL_RUN_VIEW: + return closed + if closed is None or closed.detection.state != STATE_PROTOCOL_DETAILS: + raise RuntimeError("Unexpected state after first Set Plate Columns confirm.") + + confirmed = self.tap_and_wait( + SET_COLUMNS_CHECK_COORD[0], + SET_COLUMNS_CHECK_COORD[1], + expected_states={STATE_PROTOCOL_RUN_VIEW, STATE_PROTOCOL_DETAILS}, + timeout=8.0, + interval=0.45, + prefix="set-cols-check-confirm", + down_ms=max(self.down_ms, 90), + ) + if confirmed is not None and confirmed.detection.state == STATE_PROTOCOL_RUN_VIEW: + return confirmed + raise RuntimeError("Second Set Plate Columns confirm did not return to Run Protocol.") + + def verify_prepared_user_protocol(self, protocol_name: str) -> Snapshot: + """Confirm that the current screen is the expected pre-run view for ``protocol_name``.""" + last_reason = "unknown" + for attempt in range(3): + snap = self.snapshot(f"verify-prepared-{attempt}") + if snap.detection.state != STATE_PROTOCOL_RUN_VIEW: + last_reason = f"Expected Run Protocol view, got {snap.detection.state}." + time.sleep(0.35) + continue + + protocol_match = self._run_view_matches_protocol(snap.image_path, protocol_name) + if protocol_match is False: + header = self._get_detector().run_header_text(snap.image_path).strip() + raise RuntimeError( + f"Prepared run screen does not match protocol '{protocol_name}'. header='{header}'" + ) + if protocol_match is None: + last_reason = "Could not verify the protocol header on the prepared run screen." + time.sleep(0.35) + continue + + if not self._get_detector().looks_prerun(snap.detection): + last_reason = "Run screen is not in the pre-run state." + time.sleep(0.35) + continue + + return snap + + raise RuntimeError(f"Prepared run verification failed for '{protocol_name}': {last_reason}") + + def start_run(self) -> Snapshot: + """Press ``GO`` from the prepared run screen and wait for visible run start feedback.""" + before = self.snapshot("run-start-before-go") + if before.detection.state != STATE_PROTOCOL_RUN_VIEW: + raise RuntimeError(f"Expected Run Protocol view before GO, got {before.detection.state}.") + + self.tap(GO_COORD[0], GO_COORD[1], down_ms=90) + after = self.wait_for_states( + states={STATE_PROTOCOL_RUN_VIEW, STATE_PROTOCOL_RAN, STATE_PROTOCOL_FINISH, STATE_UNKNOWN}, + timeout=8.0, + interval=0.45, + prefix="run-start-after-go", + ) + if after is None: + raise RuntimeError("No visible response after GO.") + if self._get_detector().is_run_done(after.detection): + return after + + if self._get_detector().has_confirm_dialog( + after.detection + ) or self._get_detector().looks_prerun(after.detection): + self.tap(GO_COORD[0], GO_COORD[1], down_ms=90) + after_confirm = self.wait_for_states( + states={STATE_PROTOCOL_RUN_VIEW, STATE_PROTOCOL_RAN, STATE_PROTOCOL_FINISH, STATE_UNKNOWN}, + timeout=8.0, + interval=0.45, + prefix="run-start-after-confirm", + ) + if after_confirm is not None: + return after_confirm + + return after + + def wait_run_done(self, max_seconds: float) -> Snapshot: + """Poll the RSI screen until the run has finished.""" + deadline = time.time() + max_seconds + idx = 0 + while time.time() < deadline: + snap = self.snapshot(f"run-wait-{idx:02d}") + if self._get_detector().is_run_done(snap.detection): + return snap + idx += 1 + time.sleep(0.7) + raise TimeoutError(f"Timed out waiting for run completion after {max_seconds} seconds.") + + def read_frame(self) -> FrameCapture: + """Read one full RGB frame from the RSI ``scap`` stream.""" + return self._get_transport().read_frame() + + def _save_frame(self, frame: FrameCapture, prefix: str) -> str: + os.makedirs(self.artifact_dir, exist_ok=True) + path = os.path.join(self.artifact_dir, f"{prefix}-{time.strftime('%Y%m%d-%H%M%S')}.png") + Image.fromarray(frame.rgba, mode="RGBA").save(path) + return path + + def snapshot(self, prefix: str) -> Snapshot: + """Capture a frame, save it, OCR it, and classify the current screen state.""" + frame = self.read_frame() + image_path = self._save_frame(frame, prefix) + detection = self._get_detector().classify_image(image_path) + return Snapshot(frame=frame, image_path=image_path, detection=detection) + + def tap(self, x: int, y: int, down_ms: Optional[int] = None) -> None: + """Send one touchscreen tap at the given screen coordinate.""" + hold = self.down_ms if down_ms is None else down_ms + self._get_transport().tap(x, y, hold_ms=hold) + + def wait_for_states( + self, + states: set[str], + timeout: float, + interval: float, + prefix: str, + initial_delay: float = 0.0, + ) -> Snapshot | None: + """Poll screenshots until one of the expected screen states is visible.""" + deadline = time.time() + timeout + idx = 0 + if initial_delay > 0: + time.sleep(initial_delay) + while time.time() < deadline: + snap = self.snapshot(f"{prefix}-{idx:02d}") + if snap.detection.state in states and snap.detection.confidence >= self.min_conf: + return snap + idx += 1 + time.sleep(interval) + return None + + def tap_and_wait( + self, + x: int, + y: int, + expected_states: set[str], + timeout: float, + interval: float, + prefix: str, + down_ms: Optional[int] = None, + initial_delay: float = 1.0, + ) -> Snapshot | None: + """Tap a fixed control and wait for one of the expected states.""" + self.tap(x, y, down_ms=down_ms) + return self.wait_for_states( + expected_states, + timeout=timeout, + interval=interval, + prefix=prefix, + initial_delay=initial_delay, + ) + + def _summary_matches_protocol(self, image_path: str, protocol_name: str) -> bool | None: + return self._get_detector().summary_matches_protocol(image_path, protocol_name) + + def _run_view_matches_protocol(self, image_path: str, protocol_name: str) -> bool | None: + return self._get_detector().run_view_matches_protocol(image_path, protocol_name) + + def _scroll_user_protocols_to_top(self, current: Snapshot) -> Snapshot: + if current.detection.state != STATE_USER_PROTOCOLS: + raise RuntimeError(f"Expected User Protocols screen, got {current.detection.state}.") + if self._get_detector().user_protocols_at_top(current): + return current + + for attempt in range(8): + next_snapshot = self.tap_and_wait( + USER_PROTOCOLS_SCROLL_DOUBLE_UP_COORD[0], + USER_PROTOCOLS_SCROLL_DOUBLE_UP_COORD[1], + expected_states={STATE_USER_PROTOCOLS}, + timeout=6.0, + interval=0.45, + prefix=f"user-top-{attempt}", + down_ms=max(self.down_ms, 80), + ) + if next_snapshot is None: + raise RuntimeError("Lost User Protocols screen while scrolling to top.") + current = next_snapshot + if self._get_detector().user_protocols_at_top(current): + return current + + raise RuntimeError("Failed to reach the top of User Protocols.") + + def _open_user_protocols(self, attempt: int) -> Snapshot: + current = self.tap_and_wait( + USER_PROTOCOLS_MENU_COORD[0], + USER_PROTOCOLS_MENU_COORD[1], + expected_states={STATE_USER_PROTOCOLS}, + timeout=8.0, + interval=0.45, + prefix=f"goto-user-protocols-{attempt}", + down_ms=max(self.down_ms, 80), + ) + if current is None: + raise RuntimeError("Failed to open User Protocols.") + return self._scroll_user_protocols_to_top(current) + + def _select_first_user_protocol(self, attempt: int) -> Snapshot: + self.tap( + USER_PROTOCOLS_FIRST_ROW_COORD[0], + USER_PROTOCOLS_FIRST_ROW_COORD[1], + down_ms=max(self.down_ms, 80), + ) + time.sleep(1.0) + current = self.snapshot(f"goto-user-first-row-selected-{attempt}") + detector = self._get_detector() + if current.detection.state == STATE_USER_PROTOCOLS: + self.tap( + DETAIL_CONFIRM_COORD[0], + DETAIL_CONFIRM_COORD[1], + down_ms=max(self.down_ms, 80), + ) + time.sleep(1.0) + current = self.snapshot(f"goto-user-summary-{attempt}-00") + if ( + current.detection.state != STATE_PROTOCOL_RUN_VIEW + and not detector.looks_user_protocol_summary(current.detection) + ): + time.sleep(0.45) + current = self.snapshot(f"goto-user-summary-{attempt}-01") + elif ( + current.detection.state != STATE_PROTOCOL_RUN_VIEW + and not detector.looks_user_protocol_summary(current.detection) + ): + time.sleep(0.45) + current = self.snapshot(f"goto-user-summary-{attempt}-01") + return current + + def _confirm_user_protocol_summary( + self, + current: Snapshot, + protocol_name: str, + attempt: int, + ) -> Snapshot: + detector = self._get_detector() + if ( + current.detection.state != STATE_PROTOCOL_RUN_VIEW + and not detector.looks_user_protocol_summary(current.detection) + ): + raise RuntimeError("Failed to open the selected user protocol summary.") + + if current.detection.state == STATE_PROTOCOL_RUN_VIEW: + return current + + summary_match = self._summary_matches_protocol(current.image_path, protocol_name) + if summary_match is False: + header = detector.summary_header_text(current.image_path).strip() + raise RuntimeError( + f"Summary header does not match target protocol '{protocol_name}'. header='{header}'" + ) + + next_snapshot = self.tap_and_wait( + DETAIL_CONFIRM_COORD[0], + DETAIL_CONFIRM_COORD[1], + expected_states={STATE_PROTOCOL_RUN_VIEW}, + timeout=8.0, + interval=0.45, + prefix=f"goto-user-summary-confirm-{attempt}", + down_ms=max(self.down_ms, 80), + ) + if next_snapshot is None: + raise RuntimeError("Failed to reach Run Protocol from the user protocol summary.") + return next_snapshot + + def _verify_run_view_protocol(self, current: Snapshot, protocol_name: str) -> None: + protocol_match = self._run_view_matches_protocol(current.image_path, protocol_name) + if protocol_match is False: + header = self._get_detector().run_header_text(current.image_path).strip() + raise RuntimeError( + f"Run header does not match target protocol '{protocol_name}'. header='{header}'" + ) + + def _tap_set_columns_key(self, key: str, pause_s: float = 0.08) -> None: + if key not in SET_COLUMNS_KEY_COORDS: + raise RuntimeError(f"Unsupported Set Plate Columns keypad key '{key}'.") + x, y = SET_COLUMNS_KEY_COORDS[key] + self.tap(x, y, down_ms=max(self.down_ms, 70)) + time.sleep(pause_s) + + def _enter_set_columns_value(self, columns: int) -> None: + for _ in range(4): + self._tap_set_columns_key("delete") + for digit in str(columns): + self._tap_set_columns_key(digit) + time.sleep(0.04) + + def _snapshot_result(self, snap: Snapshot) -> ScreenSnapshotResult: + return ScreenSnapshotResult( + state=snap.detection.state, + image_path=snap.image_path, + ) + + +@runtime_checkable +class _AsyncSerialLike(Protocol): + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def write(self, data: bytes) -> None: + pass + + async def read(self, num_bytes: int = 1) -> bytes: + pass + + async def reset_input_buffer(self) -> None: + pass + + +class _RSITransport: + """RSI transport built on PLR Serial plus Gemini-specific frame handling.""" + + READ_CHUNK_BYTES = 8192 + + def __init__( + self, + port: str, + baud: int, + timeout: float, + retries: int, + serial_io: Optional[_AsyncSerialLike] = None, + ) -> None: + self.port = port + self.baud = baud + self.timeout = timeout + self.retries = retries + self._serial = serial_io or Serial( + human_readable_device_name="BTX Gemini X2 TheGhostTouch", + port=port, + baudrate=baud, + timeout=0.05, + ) + self._loop: asyncio.AbstractEventLoop | None = None + self._loop_thread: threading.Thread | None = None + self.ser: Any | None = None + + def open(self) -> None: + if self._loop is not None: + return + + ready = threading.Event() + transport = self + + def _loop_main() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + transport._loop = loop + ready.set() + loop.run_forever() + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + loop.close() + + self._loop_thread = threading.Thread( + target=_loop_main, + name="TheGhostTouch-RSITransport", + daemon=True, + ) + self._loop_thread.start() + ready.wait() + self._run(self._serial.setup()) + self.ser = self._serial + + def close(self) -> None: + if self._loop is None: + self.ser = None + return + + try: + self._run(self._serial.stop()) + finally: + loop = self._loop + thread = self._loop_thread + self._loop = None + self._loop_thread = None + self.ser = None + if loop is not None: + loop.call_soon_threadsafe(loop.stop) + if thread is not None: + thread.join(timeout=2.0) + + def _run(self, awaitable: Any) -> Any: + if self._loop is None: + raise RuntimeError("TheGhostTouch serial session is not open.") + future = asyncio.run_coroutine_threadsafe(awaitable, self._loop) + return future.result() + + def ensure_open(self) -> _AsyncSerialLike: + if self._loop is None: + raise RuntimeError("TheGhostTouch serial session is not open.") + return self._serial + + def drain_input(self, seconds: float = 0.12) -> int: + del seconds + self._run(self.ensure_open().reset_input_buffer()) + return 0 + + def write_line(self, line: str) -> None: + self._run(self.ensure_open().write(line.encode("ascii") + b"\r")) + + def _read_frame_once(self) -> FrameCapture: + self.ensure_open() + self.drain_input(0.12) + self.write_line("echo off") + time.sleep(0.03) + self._run(self._serial.reset_input_buffer()) + self.write_line("scap") + + buf = bytearray() + t0 = time.time() + while time.time() - t0 < self.timeout: + chunk = self._run(self._serial.read(self.READ_CHUNK_BYTES)) + if chunk: + buf.extend(chunk) + else: + time.sleep(0.01) + + if len(buf) < FRAME_BYTES + 1: + continue + + end = buf.rfind(b":") + if end >= FRAME_BYTES: + fb = bytes(buf[end - FRAME_BYTES : end]) + rgba = _decode_rsi_framebuffer(fb) + stable = rgba[0:160, 0:430, :] + return FrameCapture( + rgba=rgba, + raw_len=len(buf), + frame_sha1=hashlib.sha1(fb).hexdigest(), + stable_sha1=hashlib.sha1(stable.tobytes()).hexdigest(), + ) + + raise TimeoutError(f"Failed to read full scap frame, collected {len(buf)} bytes") + + def read_frame(self) -> FrameCapture: + last_err: Exception | None = None + for _ in range(self.retries): + try: + return self._read_frame_once() + except Exception as exc: # pragma: no cover - live hardware path + last_err = exc + self.drain_input(0.15) + time.sleep(0.06) + assert last_err is not None + raise last_err + + def tap(self, x: int, y: int, hold_ms: int) -> None: + self.write_line(f"@key {x} {y}") + time.sleep(hold_ms / 1000.0) + self.write_line("@key") + + +class _GeminiScreenDetector: + """OCR and state classification for Gemini RSI screenshots.""" + + def __init__(self, min_conf: float) -> None: + self.min_conf = min_conf + + def ocr_text(self, image_path: str, psm: int) -> str: + try: + out = subprocess.check_output( + ["tesseract", image_path, "stdout", "--psm", str(psm)], + stderr=subprocess.DEVNULL, + text=True, + ) + except Exception: + return "" + return "\n".join([ln.strip() for ln in out.splitlines() if ln.strip()]) + + def normalize_text(self, text: str) -> str: + lowered = text.lower() + lowered = lowered.replace("geminix2", "gemini x2") + lowered = lowered.replace("protocois", "protocols") + lowered = lowered.replace("protocals", "protocols") + lowered = lowered.replace("protocal", "protocol") + lowered = re.sub(r"[^a-z0-9]+", " ", lowered) + return re.sub(r"\s+", " ", lowered).strip() + + def contains_marker(self, text_norm: str, marker: str) -> bool: + marker_norm = self.normalize_text(marker) + if not marker_norm: + return False + if marker_norm in text_norm: + return True + return marker_norm.replace(" ", "") in text_norm.replace(" ", "") + + def detect_state(self, text: str) -> Detection: + normalized = self.normalize_text(text) + + if self.contains_marker(normalized, "main menu"): + return Detection(STATE_MAIN_MENU, 1.0, ["main menu"], text, normalized) + + if self.contains_marker(normalized, "run protocol"): + if self.contains_marker(normalized, "pulses delivered"): + finish_markers = [] + for marker in ("press to clear message", "run complete", "finished", "completed"): + if self.contains_marker(normalized, marker): + finish_markers.append(marker) + if finish_markers: + return Detection( + STATE_PROTOCOL_FINISH, + 1.0, + ["run protocol", "pulses delivered", *finish_markers], + text, + normalized, + ) + return Detection( + STATE_PROTOCOL_RAN, 0.9, ["run protocol", "pulses delivered"], text, normalized + ) + + markers = ["run protocol"] + for marker in ("set meas", "go", "delivering pulse", "in progress", "current column", "stop"): + if self.contains_marker(normalized, marker): + markers.append(marker) + confidence = min(1.0, 0.70 + 0.06 * (len(markers) - 1)) + return Detection(STATE_PROTOCOL_RUN_VIEW, confidence, markers, text, normalized) + + if ( + self.contains_marker(normalized, "set plate columns") + or self.contains_marker(normalized, "set the plate handler") + or self.contains_marker(normalized, "number of columns") + or self.contains_marker(normalized, "protocol details") + ): + return Detection(STATE_PROTOCOL_DETAILS, 1.0, ["protocol details marker"], text, normalized) + + if self.contains_marker(normalized, "user protocols"): + return Detection(STATE_USER_PROTOCOLS, 1.0, ["user protocols"], text, normalized) + + return Detection(STATE_UNKNOWN, 0.0, [], text, normalized) + + def classify_image(self, image_path: str) -> Detection: + text = self.ocr_text(image_path, psm=6) + detection = self.detect_state(text) + if detection.state == STATE_UNKNOWN or detection.confidence < self.min_conf: + sparse = self.ocr_text(image_path, psm=11) + if sparse: + merged = "\n".join(part for part in [text, sparse] if part) + detection = self.detect_state(merged) + return detection + + def crop_ocr_text(self, image_path: str, bbox: tuple[int, int, int, int], psm: int) -> str: + temp_path = "" + try: + with Image.open(image_path) as img: + crop = img.crop(bbox) + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + temp_path = tmp.name + crop.save(temp_path) + return self.ocr_text(temp_path, psm=psm) + finally: + if temp_path and os.path.exists(temp_path): + os.unlink(temp_path) + + def summary_header_text(self, image_path: str) -> str: + return self.crop_ocr_text(image_path, (10, 10, 360, 130), psm=11) + + def run_header_text(self, image_path: str) -> str: + return self.crop_ocr_text(image_path, (10, 80, 350, 170), psm=11) + + def summary_matches_protocol(self, image_path: str, protocol_name: str) -> bool | None: + header_norm = self.normalize_text(self.summary_header_text(image_path)) + if not header_norm: + return None + target_norm = self.normalize_text(protocol_name) + if not target_norm: + return None + return target_norm.replace(" ", "") in header_norm.replace(" ", "") + + def run_view_matches_protocol(self, image_path: str, protocol_name: str) -> bool | None: + header_norm = self.normalize_text(self.run_header_text(image_path)) + if not header_norm: + return None + target_norm = self.normalize_text(protocol_name) + if not target_norm: + return None + return target_norm.replace(" ", "") in header_norm.replace(" ", "") + + def looks_user_protocol_summary(self, detection: Detection) -> bool: + if self.contains_marker(detection.text_norm, "set protocol"): + return False + if self.contains_marker(detection.text_norm, "run protocol"): + return False + markers = ( + "square wave", + "exponential decay", + "voltage", + "duration", + "number of pulses", + "pulse interval", + "electrode gap", + "resistance", + "capacitance", + ) + hits = sum(1 for marker in markers if self.contains_marker(detection.text_norm, marker)) + return hits >= 3 + + def user_protocols_double_up_active(self, image_path: str) -> bool: + with Image.open(image_path) as img: + crop = np.array(img.crop(USER_PROTOCOLS_DOUBLE_UP_BBOX).convert("RGB")) + active_pixels = ((crop[:, :, 1] >= 180) & (crop[:, :, 2] >= 180)).sum() + return int(active_pixels) >= 80 + + def user_protocols_at_top(self, snap: Snapshot) -> bool: + # "New Protocol" stays visible even when scrolled, so top-of-list is keyed off the + # double-up control becoming grey/inactive. + return not self.user_protocols_double_up_active(snap.image_path) + + def has_confirm_dialog(self, detection: Detection) -> bool: + return any( + self.contains_marker(detection.text_norm, marker) + for marker in ("are you sure", "confirm", "yes", "no") + ) + + def looks_prerun(self, detection: Detection) -> bool: + if detection.state != STATE_PROTOCOL_RUN_VIEW: + return False + return ( + self.contains_marker(detection.text_norm, "set meas") + and self.contains_marker(detection.text_norm, "go") + and not self.contains_marker(detection.text_norm, "delivering pulse") + and not self.contains_marker(detection.text_norm, "pulses delivered") + ) + + def is_run_done(self, detection: Detection) -> bool: + return detection.state in {STATE_PROTOCOL_RAN, STATE_PROTOCOL_FINISH} or self.contains_marker( + detection.text_norm, "pulses delivered" + ) diff --git a/pylabrobot/btx/the_ghost_touch_tests.py b/pylabrobot/btx/the_ghost_touch_tests.py new file mode 100644 index 00000000000..08a21520a8a --- /dev/null +++ b/pylabrobot/btx/the_ghost_touch_tests.py @@ -0,0 +1,407 @@ +import json +from pathlib import Path +import shutil +import unittest +from typing import Optional, cast +from unittest.mock import patch + +import pytest + +pytest.importorskip("numpy") +pytest.importorskip("PIL") +pytest.importorskip("serial") + +from pylabrobot.btx.the_ghost_touch import ( + Detection, + FRAME_BYTES, + FRAME_H, + FRAME_W, + FrameCapture, + _RSITransport, + _GeminiScreenDetector, + _decode_rsi_framebuffer, + Snapshot, + STATE_MAIN_MENU, + STATE_PROTOCOL_DETAILS, + STATE_PROTOCOL_FINISH, + STATE_PROTOCOL_RUN_VIEW, + STATE_UNKNOWN, + STATE_USER_PROTOCOLS, + TheGhostTouch, +) + +SCREEN_FIXTURES = Path(__file__).parent / "test_data/gemini_x2/screens" + + +class _FakeAsyncSerial: + def __init__(self, reads: Optional[list[bytes]] = None): + self.reads: list[bytes] = list(reads or []) + self.writes: list[bytes] = [] + self.setup_calls = 0 + self.stop_calls = 0 + self.reset_calls = 0 + + async def setup(self) -> None: + self.setup_calls += 1 + + async def stop(self) -> None: + self.stop_calls += 1 + + async def write(self, data: bytes) -> None: + self.writes.append(data) + + async def read(self, num_bytes: int = 1) -> bytes: + del num_bytes + if not self.reads: + return b"" + return self.reads.pop(0) + + async def reset_input_buffer(self) -> None: + self.reset_calls += 1 + + +class _TestGhostTouch(TheGhostTouch): + def __init__(self) -> None: + self.port = "/dev/test" + self.baud = 115200 + self.timeout = 15.0 + self.retries = 1 + self.min_conf = 0.70 + self.down_ms = 70 + self.artifact_dir = "/tmp" + self.ser = None + self._snapshots: list[Snapshot] = [] + self.taps: list[tuple[int, int, Optional[int]]] = [] + + def queue_snapshot( + self, state: str, text: str = "", text_norm: str = "", image_path: str = "img" + ) -> None: + detection = Detection( + state=state, + confidence=1.0 if state != STATE_UNKNOWN else 0.0, + matched=[], + text=text, + text_norm=text_norm or text, + ) + self._snapshots.append( + Snapshot(frame=cast(FrameCapture, None), image_path=image_path, detection=detection) + ) + + def snapshot(self, prefix: str) -> Snapshot: + del prefix + if not self._snapshots: + raise AssertionError("No queued snapshots left") + return self._snapshots.pop(0) + + def tap(self, x: int, y: int, down_ms=None) -> None: + self.taps.append((x, y, down_ms)) + + def tap_and_wait( + self, + x: int, + y: int, + expected_states, + timeout, + interval, + prefix, + down_ms=None, + initial_delay=1.0, + ): + del expected_states, timeout, interval, prefix, initial_delay + self.taps.append((x, y, down_ms)) + return self.snapshot("tap-and-wait") + + def _scroll_user_protocols_to_top(self, current: Snapshot) -> Snapshot: + return current + + def _summary_matches_protocol(self, image_path: str, protocol_name: str): + del image_path, protocol_name + return True + + def _run_view_matches_protocol(self, image_path: str, protocol_name: str): + del image_path, protocol_name + return True + + +class TestTheGhostTouch(unittest.TestCase): + def _fixture_protocol_name(self) -> str: + metadata = json.loads((SCREEN_FIXTURES / "metadata.json").read_text()) + return str(metadata["temporary_protocol"]["name"]) + + def test_require_dependencies_reports_missing_tesseract(self): + touch = TheGhostTouch(port="/dev/test") + + with patch("pylabrobot.btx.the_ghost_touch.shutil.which", return_value=None): + with self.assertRaisesRegex(RuntimeError, "external `tesseract` command"): + touch._require_dependencies() + + def test_decode_rsi_framebuffer_uses_bgrx_pixels_and_opaque_alpha(self): + framebuffer = bytes((12, 34, 56, 0)) * (FRAME_W * FRAME_H) + + rgba = _decode_rsi_framebuffer(framebuffer) + + self.assertEqual(rgba.shape, (FRAME_H, FRAME_W, 4)) + self.assertEqual(rgba[0, 0].tolist(), [56, 34, 12, 255]) + self.assertEqual(int(rgba[:, :, 3].min()), 255) + self.assertEqual(int(rgba[:, :, 3].max()), 255) + + def test_rsi_transport_reads_bgrx_frame_via_shared_serial_interface(self): + framebuffer = bytes((12, 34, 56, 0)) * (FRAME_W * FRAME_H) + fake = _FakeAsyncSerial(reads=[framebuffer[:900000], framebuffer[900000:] + b":"]) + transport = _RSITransport( + port="/dev/test", + baud=115200, + timeout=0.2, + retries=1, + serial_io=fake, + ) + + transport.open() + try: + frame = transport.read_frame() + finally: + transport.close() + + self.assertEqual(fake.setup_calls, 1) + self.assertEqual(fake.stop_calls, 1) + self.assertGreaterEqual(fake.reset_calls, 2) + self.assertEqual(fake.writes[:2], [b"echo off\r", b"scap\r"]) + self.assertEqual(frame.raw_len, FRAME_BYTES + 1) + self.assertEqual(frame.rgba.shape, (FRAME_H, FRAME_W, 4)) + self.assertEqual(frame.rgba[0, 0].tolist(), [56, 34, 12, 255]) + + def test_user_protocols_top_detector_uses_double_up_arrow_state(self): + detector = _GeminiScreenDetector(min_conf=0.70) + + self.assertTrue( + detector.user_protocols_double_up_active( + str(SCREEN_FIXTURES / "user_protocols_double_up_active.png") + ) + ) + self.assertFalse( + detector.user_protocols_double_up_active( + str(SCREEN_FIXTURES / "user_protocols_double_up_inactive.png") + ) + ) + + @pytest.mark.skipif(shutil.which("tesseract") is None, reason="requires tesseract OCR") + def test_selected_screen_fixtures_match_detector_states(self): + detector = _GeminiScreenDetector(min_conf=0.70) + cases = ( + ("00_main_menu.png", STATE_MAIN_MENU), + ("01_user_protocols_top.png", STATE_USER_PROTOCOLS), + ("02_protocol_summary.png", STATE_UNKNOWN), + ("03_run_protocol_prerun.png", STATE_PROTOCOL_RUN_VIEW), + ("04_set_plate_columns_open.png", STATE_PROTOCOL_DETAILS), + ("05_set_plate_columns_after_first_confirm.png", STATE_PROTOCOL_DETAILS), + ("06_set_plate_columns_confirmed_run_view.png", STATE_PROTOCOL_RUN_VIEW), + ("07_go_prerun.png", STATE_PROTOCOL_RUN_VIEW), + ("08_go_delivering_pulse.png", STATE_PROTOCOL_RUN_VIEW), + ("09_go_pulses_delivered.png", STATE_PROTOCOL_FINISH), + ("10_returned_home_after_go.png", STATE_MAIN_MENU), + ) + + for filename, expected_state in cases: + with self.subTest(filename=filename): + detection = detector.classify_image(str(SCREEN_FIXTURES / filename)) + + self.assertEqual(detection.state, expected_state) + if expected_state != STATE_UNKNOWN: + self.assertGreaterEqual(detection.confidence, 0.70) + + @pytest.mark.skipif(shutil.which("tesseract") is None, reason="requires tesseract OCR") + def test_selected_screen_fixtures_cover_protocol_name_crops(self): + detector = _GeminiScreenDetector(min_conf=0.70) + protocol_name = self._fixture_protocol_name() + + summary = detector.classify_image(str(SCREEN_FIXTURES / "02_protocol_summary.png")) + + self.assertTrue(detector.looks_user_protocol_summary(summary)) + self.assertTrue( + detector.summary_matches_protocol( + str(SCREEN_FIXTURES / "02_protocol_summary.png"), protocol_name + ) + ) + run_view_fixtures = ( + "03_run_protocol_prerun.png", + "06_set_plate_columns_confirmed_run_view.png", + "07_go_prerun.png", + "08_go_delivering_pulse.png", + "09_go_pulses_delivered.png", + ) + for filename in run_view_fixtures: + with self.subTest(filename=filename): + self.assertTrue( + detector.run_view_matches_protocol(str(SCREEN_FIXTURES / filename), protocol_name) + ) + + @pytest.mark.skipif(shutil.which("tesseract") is None, reason="requires tesseract OCR") + def test_selected_screen_fixtures_cover_two_step_plate_columns_confirm(self): + detector = _GeminiScreenDetector(min_conf=0.70) + + opened = detector.classify_image(str(SCREEN_FIXTURES / "04_set_plate_columns_open.png")) + first_confirm = detector.classify_image( + str(SCREEN_FIXTURES / "05_set_plate_columns_after_first_confirm.png") + ) + confirmed = detector.classify_image( + str(SCREEN_FIXTURES / "06_set_plate_columns_confirmed_run_view.png") + ) + + self.assertEqual(opened.state, STATE_PROTOCOL_DETAILS) + self.assertEqual(first_confirm.state, STATE_PROTOCOL_DETAILS) + self.assertEqual(confirmed.state, STATE_PROTOCOL_RUN_VIEW) + + @pytest.mark.skipif(shutil.which("tesseract") is None, reason="requires tesseract OCR") + def test_selected_screen_fixtures_cover_go_to_completion(self): + detector = _GeminiScreenDetector(min_conf=0.70) + + prerun = detector.classify_image(str(SCREEN_FIXTURES / "07_go_prerun.png")) + delivering = detector.classify_image(str(SCREEN_FIXTURES / "08_go_delivering_pulse.png")) + finished = detector.classify_image(str(SCREEN_FIXTURES / "09_go_pulses_delivered.png")) + home = detector.classify_image(str(SCREEN_FIXTURES / "10_returned_home_after_go.png")) + + self.assertTrue(detector.looks_prerun(prerun)) + self.assertEqual(delivering.state, STATE_PROTOCOL_RUN_VIEW) + self.assertIn("delivering pulse", delivering.matched) + self.assertFalse(detector.looks_prerun(delivering)) + self.assertTrue(detector.is_run_done(finished)) + self.assertIn("pulses delivered", finished.matched) + self.assertEqual(home.state, STATE_MAIN_MENU) + + def test_prepare_user_protocol_accepts_direct_summary_after_row_tap(self): + touch = _TestGhostTouch() + touch.queue_snapshot(STATE_MAIN_MENU, text="Main Menu", text_norm="main menu") + touch.queue_snapshot(STATE_USER_PROTOCOLS, text="User Protocols", text_norm="user protocols") + touch.queue_snapshot( + STATE_UNKNOWN, + text="Exponential Decay Voltage Resistance Capacitance Number of Pulses", + text_norm="exponential decay voltage resistance capacitance number of pulses", + image_path="summary", + ) + touch.queue_snapshot( + STATE_PROTOCOL_RUN_VIEW, + text="Run Protocol GO Set Meas", + text_norm="run protocol go set meas", + image_path="run-view", + ) + touch.queue_snapshot( + STATE_PROTOCOL_RUN_VIEW, + text="Run Protocol GO Set Meas", + text_norm="run protocol go set meas", + image_path="verify", + ) + + result = touch.prepare_user_protocol("!PLR_123") + + self.assertEqual(result.run_view.state, STATE_PROTOCOL_RUN_VIEW) + self.assertEqual(result.prepared_verification.state, STATE_PROTOCOL_RUN_VIEW) + self.assertGreaterEqual(len(touch.taps), 3) + + def test_start_prepared_user_protocol_verifies_then_waits_done(self): + touch = _TestGhostTouch() + touch.queue_snapshot( + STATE_PROTOCOL_RUN_VIEW, + text="Run Protocol GO Set Meas", + text_norm="run protocol go set meas", + image_path="verify", + ) + touch.queue_snapshot( + STATE_PROTOCOL_RUN_VIEW, + text="Run Protocol GO Set Meas", + text_norm="run protocol go set meas", + image_path="before-go", + ) + touch.queue_snapshot( + STATE_PROTOCOL_RUN_VIEW, + text="Run Protocol delivering pulse", + text_norm="run protocol delivering pulse", + image_path="after-go", + ) + touch.queue_snapshot( + STATE_PROTOCOL_FINISH, + text="Run Protocol pulses delivered completed", + text_norm="run protocol pulses delivered completed", + image_path="done", + ) + touch.queue_snapshot( + STATE_MAIN_MENU, text="Main Menu", text_norm="main menu", image_path="home" + ) + + result = touch.start_prepared_user_protocol("!PLR_123", home_after=True, max_run_seconds=10.0) + + self.assertEqual(result.verification.image_path, "verify") + self.assertEqual(result.completed.state, STATE_PROTOCOL_FINISH) + self.assertIsNotNone(result.home) + assert result.home is not None + self.assertEqual(result.home.state, STATE_MAIN_MENU) + + def test_ensure_home_closes_protocol_details_before_home(self): + touch = _TestGhostTouch() + touch.queue_snapshot( + STATE_PROTOCOL_DETAILS, + text="Set Plate Columns", + text_norm="set plate columns", + image_path="details", + ) + touch.queue_snapshot( + STATE_PROTOCOL_RUN_VIEW, + text="Run Protocol GO Set Meas", + text_norm="run protocol go set meas", + image_path="run-view", + ) + touch.queue_snapshot( + STATE_MAIN_MENU, + text="Main Menu", + text_norm="main menu", + image_path="home", + ) + + result = touch.ensure_home() + + self.assertEqual(result.image_path, "home") + self.assertEqual(touch.taps[0][:2], (739, 414)) + self.assertEqual(touch.taps[1][:2], (726, 326)) + + def test_set_plate_columns_confirms_again_when_details_remains_open(self): + touch = _TestGhostTouch() + touch.queue_snapshot( + STATE_PROTOCOL_RUN_VIEW, + text="Run Protocol GO Set Meas", + text_norm="run protocol go set meas", + image_path="run-view-start", + ) + touch.queue_snapshot( + STATE_PROTOCOL_DETAILS, + text="Set Plate Columns", + text_norm="set plate columns", + image_path="details-open", + ) + touch.queue_snapshot( + STATE_PROTOCOL_DETAILS, + text="Set Plate Columns", + text_norm="set plate columns", + image_path="details-after-first-confirm", + ) + touch.queue_snapshot( + STATE_PROTOCOL_RUN_VIEW, + text="Run Protocol GO Set Meas", + text_norm="run protocol go set meas", + image_path="run-view-confirmed", + ) + + result = touch.set_plate_columns(3) + + self.assertEqual(result.image_path, "run-view-confirmed") + self.assertEqual(touch.taps[-2][:2], (739, 414)) + self.assertEqual(touch.taps[-1][:2], (739, 414)) + + def test_cancel_prepared_user_protocol_homes(self): + touch = _TestGhostTouch() + touch.queue_snapshot( + STATE_MAIN_MENU, text="Main Menu", text_norm="main menu", image_path="home" + ) + + result = touch.cancel_prepared_user_protocol(home_after=True) + + self.assertTrue(result.cancelled) + self.assertEqual(result.final_state.image_path, "home") diff --git a/pylabrobot/capabilities/electroporation/__init__.py b/pylabrobot/capabilities/electroporation/__init__.py new file mode 100644 index 00000000000..2fd1b64782a --- /dev/null +++ b/pylabrobot/capabilities/electroporation/__init__.py @@ -0,0 +1,14 @@ +from .backend import ElectroporationBackend +from .chatterbox import ElectroporationChatterboxBackend +from .electroporation import Electroporation +from .standard import ( + ElectroporationCancellationDetails, + ElectroporationCancellationResult, + ElectroporationCleanup, + ElectroporationExecutionDetails, + ElectroporationLogCapture, + ElectroporationPreparationDetails, + ElectroporationProtocol, + ElectroporationRunResult, + PreparedElectroporationRun, +) diff --git a/pylabrobot/capabilities/electroporation/backend.py b/pylabrobot/capabilities/electroporation/backend.py new file mode 100644 index 00000000000..a97d252b153 --- /dev/null +++ b/pylabrobot/capabilities/electroporation/backend.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import Any, Dict, Mapping, Optional, Union + +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend +from pylabrobot.capabilities.electroporation.standard import ( + ElectroporationCancellationResult, + ElectroporationProtocol, + ElectroporationRunResult, + PreparedElectroporationRun, +) + + +class ElectroporationBackend(CapabilityBackend, metaclass=ABCMeta): + """Minimal backend contract for electroporators. + + The common surface is built around the real lab workflow: + prepare a temporary protocol before loading the plate, then start or cancel the prepared run. + Device-specific developer helpers belong on concrete vendor backends. + """ + + @abstractmethod + async def prepare_temporary_protocol( + self, + protocol: ElectroporationProtocol, + plate_columns: Optional[int] = None, + prefix: Optional[str] = None, + backend_params: Optional[BackendParams] = None, + ) -> PreparedElectroporationRun: + """Create a temporary protocol and leave the device armed on the pre-run screen.""" + + @abstractmethod + async def start_prepared_run( + self, + prepared_run: Union[PreparedElectroporationRun, Mapping[str, Any]], + home_after: bool = True, + max_run_seconds: float = 420.0, + ) -> ElectroporationRunResult: + """Verify and start a previously prepared run.""" + + @abstractmethod + async def cancel_prepared_run( + self, + prepared_run: Union[PreparedElectroporationRun, Mapping[str, Any]], + home_after: bool = True, + ) -> ElectroporationCancellationResult: + """Cancel a previously prepared run and remove the temporary protocol.""" + + @abstractmethod + async def get_device_info(self) -> Dict[str, Any]: + """Return device identity and capability information.""" diff --git a/pylabrobot/capabilities/electroporation/chatterbox.py b/pylabrobot/capabilities/electroporation/chatterbox.py new file mode 100644 index 00000000000..5b086f9f2cb --- /dev/null +++ b/pylabrobot/capabilities/electroporation/chatterbox.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any, Dict, Mapping, Optional, Union + +from pylabrobot.capabilities.capability import BackendParams + +from .backend import ElectroporationBackend +from .standard import ( + ElectroporationCancellationDetails, + ElectroporationCancellationResult, + ElectroporationCleanup, + ElectroporationExecutionDetails, + ElectroporationLogCapture, + ElectroporationPreparationDetails, + ElectroporationProtocol, + ElectroporationRunResult, + PreparedElectroporationRun, +) + +logger = logging.getLogger(__name__) + + +class ElectroporationChatterboxBackend(ElectroporationBackend): + """Chatterbox backend for device-free electroporation workflow tests.""" + + DEFAULT_TEMPORARY_PROTOCOL_PREFIX = "!PLR" + + def __init__( + self, + temporary_protocol_prefix: str = DEFAULT_TEMPORARY_PROTOCOL_PREFIX, + ) -> None: + self.temporary_protocol_prefix = temporary_protocol_prefix + self._counter = 0 + self.prepared_runs: Dict[str, PreparedElectroporationRun] = {} + + async def prepare_temporary_protocol( + self, + protocol: ElectroporationProtocol, + plate_columns: Optional[int] = None, + prefix: Optional[str] = None, + backend_params: Optional[BackendParams] = None, + ) -> PreparedElectroporationRun: + del backend_params + resolved_prefix = self.temporary_protocol_prefix if prefix is None else prefix + protocol_name = self._make_temporary_protocol_name(resolved_prefix) + logger.info( + "Preparing simulated electroporation protocol %s with plate_columns=%s.", + protocol_name, + plate_columns, + ) + + prepared = PreparedElectroporationRun( + protocol_name=protocol_name, + protocol=protocol, + plate_columns=plate_columns, + prefix=resolved_prefix, + prepared_at_utc=self._now_utc_iso(), + baseline_log_paths=(), + prepare_result=ElectroporationPreparationDetails( + prepared_state="protocol_run_view", + protocol_setup={ + "operation": "add_protocol", + "protocol": protocol_name, + "simulated": True, + "exists_after": True, + }, + device_prepare={ + "prepared_verification": {"state": "protocol_run_view"}, + "plate_columns": plate_columns, + "simulated": True, + }, + ), + ) + self.prepared_runs[protocol_name] = prepared + return prepared + + async def start_prepared_run( + self, + prepared_run: Union[PreparedElectroporationRun, Mapping[str, Any]], + home_after: bool = True, + max_run_seconds: float = 420.0, + ) -> ElectroporationRunResult: + prepared = self._coerce_prepared_run(prepared_run) + logger.info( + "Starting simulated electroporation protocol %s with max_run_seconds=%s.", + prepared.protocol_name, + max_run_seconds, + ) + completed_state = "protocol_finish" + final_state = "main_menu" if home_after else completed_state + + return ElectroporationRunResult( + prepared_run=prepared, + started_at_utc=self._now_utc_iso(), + completed_at_utc=self._now_utc_iso(), + rsi_result=ElectroporationExecutionDetails( + verification_state="protocol_run_view", + completed_state=completed_state, + final_state=final_state, + device_run={ + "verification": {"state": "protocol_run_view"}, + "after_start": {"state": "protocol_run_view"}, + "completed": {"state": completed_state}, + "home": None if not home_after else {"state": "main_menu"}, + "simulated": True, + }, + ), + log_capture=ElectroporationLogCapture( + matched_log_path=None, + summary={ + "protocol": prepared.protocol_name, + "pulse_count": prepared.protocol.pulse_count, + "simulated": True, + }, + details={ + "before_count": 0, + "after_count": 0, + "new_log_paths": [], + "matched_log_path": None, + "matched_log": None, + "simulated": True, + }, + ), + cleanup=self._cleanup_temporary_protocol(prepared.protocol_name), + ) + + async def cancel_prepared_run( + self, + prepared_run: Union[PreparedElectroporationRun, Mapping[str, Any]], + home_after: bool = True, + ) -> ElectroporationCancellationResult: + prepared = self._coerce_prepared_run(prepared_run) + final_state = "main_menu" if home_after else "protocol_run_view" + logger.info("Cancelling simulated electroporation protocol %s.", prepared.protocol_name) + + return ElectroporationCancellationResult( + prepared_run=prepared, + cancelled_at_utc=self._now_utc_iso(), + rsi_result=ElectroporationCancellationDetails( + final_state=final_state, + device_cancel={ + "cancelled": True, + "final_state": {"state": final_state}, + "simulated": True, + }, + ), + cleanup=self._cleanup_temporary_protocol(prepared.protocol_name), + ) + + async def get_device_info(self) -> Dict[str, Any]: + return { + "backend": self.__class__.__name__, + "model": "Chatterbox Electroporator", + "supports_prepared_temporary_runs": True, + "supports_serialized_prepared_runs": True, + "supports_stored_protocol_runs": False, + "supports_plate_columns": True, + "temporary_protocol_prefix": self.temporary_protocol_prefix, + "simulated": True, + } + + def _make_temporary_protocol_name(self, prefix: str) -> str: + self._counter += 1 + return f"{prefix}_{self._counter:06d}" + + def _coerce_prepared_run( + self, + prepared_run: Union[PreparedElectroporationRun, Mapping[str, Any]], + ) -> PreparedElectroporationRun: + if isinstance(prepared_run, PreparedElectroporationRun): + return prepared_run + return PreparedElectroporationRun.from_dict(prepared_run) + + def _cleanup_temporary_protocol(self, protocol_name: str) -> ElectroporationCleanup: + known_prepared_run = protocol_name in self.prepared_runs + self.prepared_runs.pop(protocol_name, None) + return ElectroporationCleanup( + deleted=True, + retry_used=False, + error=None, + details={ + "operation": "delete_protocol", + "protocol": protocol_name, + "known_prepared_run": known_prepared_run, + "simulated": True, + }, + ) + + def _now_utc_iso(self) -> str: + return datetime.now(timezone.utc).isoformat() diff --git a/pylabrobot/capabilities/electroporation/electroporation.py b/pylabrobot/capabilities/electroporation/electroporation.py new file mode 100644 index 00000000000..c6749fdac3b --- /dev/null +++ b/pylabrobot/capabilities/electroporation/electroporation.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Any, Dict, Mapping, Optional, Union + +from pylabrobot.capabilities.capability import BackendParams, Capability, need_capability_ready +from pylabrobot.capabilities.electroporation.backend import ElectroporationBackend +from pylabrobot.capabilities.electroporation.standard import ( + ElectroporationCancellationResult, + ElectroporationProtocol, + ElectroporationRunResult, + PreparedElectroporationRun, +) + + +class Electroporation(Capability): + """Electroporation capability. + + This frontend intentionally stays small and exposes the prepared-run workflow shared by + supported electroporators. + """ + + def __init__(self, backend: ElectroporationBackend): + super().__init__(backend=backend) + self.backend: ElectroporationBackend = backend + + @need_capability_ready + async def prepare_temporary_protocol( + self, + protocol: ElectroporationProtocol, + plate_columns: Optional[int] = None, + prefix: Optional[str] = None, + backend_params: Optional[BackendParams] = None, + ) -> PreparedElectroporationRun: + return await self.backend.prepare_temporary_protocol( + protocol=protocol, + plate_columns=plate_columns, + prefix=prefix, + backend_params=backend_params, + ) + + @need_capability_ready + async def start_prepared_run( + self, + prepared_run: Union[PreparedElectroporationRun, Mapping[str, Any]], + home_after: bool = True, + max_run_seconds: float = 420.0, + ) -> ElectroporationRunResult: + return await self.backend.start_prepared_run( + prepared_run=prepared_run, + home_after=home_after, + max_run_seconds=max_run_seconds, + ) + + @need_capability_ready + async def cancel_prepared_run( + self, + prepared_run: Union[PreparedElectroporationRun, Mapping[str, Any]], + home_after: bool = True, + ) -> ElectroporationCancellationResult: + return await self.backend.cancel_prepared_run( + prepared_run=prepared_run, + home_after=home_after, + ) + + @need_capability_ready + async def get_device_info(self) -> Dict[str, Any]: + return await self.backend.get_device_info() diff --git a/pylabrobot/capabilities/electroporation/electroporation_tests.py b/pylabrobot/capabilities/electroporation/electroporation_tests.py new file mode 100644 index 00000000000..89facf28914 --- /dev/null +++ b/pylabrobot/capabilities/electroporation/electroporation_tests.py @@ -0,0 +1,287 @@ +"""Tests for Electroporation.""" + +import unittest +from typing import Any, Dict, Mapping, Optional, Union + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.electroporation.backend import ElectroporationBackend +from pylabrobot.capabilities.electroporation.chatterbox import ElectroporationChatterboxBackend +from pylabrobot.capabilities.electroporation.electroporation import Electroporation +from pylabrobot.capabilities.electroporation.standard import ( + ElectroporationCancellationDetails, + ElectroporationCancellationResult, + ElectroporationCleanup, + ElectroporationExecutionDetails, + ElectroporationLogCapture, + ElectroporationPreparationDetails, + ElectroporationProtocol, + ElectroporationRunResult, + PreparedElectroporationRun, +) + + +class _Params(BackendParams): + pass + + +def _square_protocol() -> ElectroporationProtocol: + return ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + ) + + +def _prepared_run(protocol: Optional[ElectroporationProtocol] = None) -> PreparedElectroporationRun: + protocol = protocol or _square_protocol() + return PreparedElectroporationRun( + protocol_name="!PLR_123456789", + protocol=protocol, + plate_columns=None, + prefix="!PLR", + prepared_at_utc="2026-03-09T10:00:00+00:00", + baseline_log_paths=(), + prepare_result=ElectroporationPreparationDetails( + prepared_state="protocol_run_view", + protocol_setup={}, + device_prepare={}, + ), + ) + + +class _RecordingElectroporationBackend(ElectroporationBackend): + def __init__(self) -> None: + self.setup_params: Optional[BackendParams] = None + self.stop_calls = 0 + self.calls: list[dict[str, Any]] = [] + + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + self.setup_params = backend_params + + async def _on_stop(self): + self.stop_calls += 1 + + async def prepare_temporary_protocol( + self, + protocol: ElectroporationProtocol, + plate_columns: Optional[int] = None, + prefix: Optional[str] = None, + backend_params: Optional[BackendParams] = None, + ) -> PreparedElectroporationRun: + self.calls.append( + { + "method": "prepare", + "protocol": protocol, + "plate_columns": plate_columns, + "prefix": prefix, + "backend_params": backend_params, + } + ) + return PreparedElectroporationRun( + protocol_name="!PLR_123456789", + protocol=protocol, + plate_columns=plate_columns, + prefix=prefix or "!PLR", + prepared_at_utc="2026-03-09T10:00:00+00:00", + baseline_log_paths=(r"\BTXDATA\baseline.TXT",), + prepare_result=ElectroporationPreparationDetails( + prepared_state="protocol_run_view", + protocol_setup={}, + device_prepare={}, + ), + ) + + async def start_prepared_run( + self, + prepared_run: Union[PreparedElectroporationRun, Mapping[str, Any]], + home_after: bool = True, + max_run_seconds: float = 420.0, + ) -> ElectroporationRunResult: + prepared = ( + prepared_run + if isinstance(prepared_run, PreparedElectroporationRun) + else PreparedElectroporationRun.from_dict(prepared_run) + ) + self.calls.append( + { + "method": "start", + "prepared_run": prepared, + "home_after": home_after, + "max_run_seconds": max_run_seconds, + } + ) + return ElectroporationRunResult( + prepared_run=prepared, + started_at_utc="2026-03-09T10:01:00+00:00", + completed_at_utc="2026-03-09T10:02:00+00:00", + rsi_result=ElectroporationExecutionDetails( + verification_state="protocol_run_view", + completed_state="protocol_finish", + final_state="main_menu", + device_run={}, + ), + log_capture=ElectroporationLogCapture( + matched_log_path=None, + summary={}, + details={}, + ), + cleanup=ElectroporationCleanup( + deleted=True, + retry_used=False, + error=None, + details={}, + ), + ) + + async def cancel_prepared_run( + self, + prepared_run: Union[PreparedElectroporationRun, Mapping[str, Any]], + home_after: bool = True, + ) -> ElectroporationCancellationResult: + prepared = ( + prepared_run + if isinstance(prepared_run, PreparedElectroporationRun) + else PreparedElectroporationRun.from_dict(prepared_run) + ) + self.calls.append( + { + "method": "cancel", + "prepared_run": prepared, + "home_after": home_after, + } + ) + return ElectroporationCancellationResult( + prepared_run=prepared, + cancelled_at_utc="2026-03-09T10:01:00+00:00", + rsi_result=ElectroporationCancellationDetails( + final_state="main_menu", + device_cancel={}, + ), + cleanup=ElectroporationCleanup( + deleted=True, + retry_used=False, + error=None, + details={}, + ), + ) + + async def get_device_info(self) -> Dict[str, Any]: + self.calls.append({"method": "get_device_info"}) + return {"model": "test electroporator"} + + +class TestElectroporation(unittest.IsolatedAsyncioTestCase): + async def test_prepare_temporary_protocol_forwards_to_backend(self): + backend = _RecordingElectroporationBackend() + cap = Electroporation(backend=backend) + setup_params = _Params() + prepare_params = _Params() + protocol = _square_protocol() + + await cap._on_setup(backend_params=setup_params) + prepared = await cap.prepare_temporary_protocol( + protocol=protocol, + plate_columns=3, + prefix="!TMP", + backend_params=prepare_params, + ) + + self.assertIs(backend.setup_params, setup_params) + self.assertEqual(prepared.protocol_name, "!PLR_123456789") + self.assertEqual(prepared.protocol, protocol) + self.assertEqual(prepared.plate_columns, 3) + self.assertEqual(prepared.prefix, "!TMP") + self.assertEqual( + backend.calls[0], + { + "method": "prepare", + "protocol": protocol, + "plate_columns": 3, + "prefix": "!TMP", + "backend_params": prepare_params, + }, + ) + + async def test_start_and_cancel_prepared_run_forward_to_backend(self): + backend = _RecordingElectroporationBackend() + cap = Electroporation(backend=backend) + prepared = _prepared_run() + + await cap._on_setup() + started = await cap.start_prepared_run( + prepared.as_dict(), + home_after=False, + max_run_seconds=12.0, + ) + cancelled = await cap.cancel_prepared_run(prepared, home_after=False) + + self.assertEqual(started.prepared_run, prepared) + self.assertEqual(cancelled.prepared_run, prepared) + self.assertEqual(backend.calls[0]["method"], "start") + self.assertEqual(backend.calls[0]["prepared_run"], prepared) + self.assertEqual(backend.calls[0]["home_after"], False) + self.assertEqual(backend.calls[0]["max_run_seconds"], 12.0) + self.assertEqual(backend.calls[1]["method"], "cancel") + self.assertEqual(backend.calls[1]["prepared_run"], prepared) + self.assertEqual(backend.calls[1]["home_after"], False) + + async def test_methods_require_setup(self): + backend = _RecordingElectroporationBackend() + cap = Electroporation(backend=backend) + + with self.assertRaisesRegex(RuntimeError, "capability has not been set up"): + await cap.prepare_temporary_protocol(_square_protocol()) + + async def test_get_device_info_forwards_and_stop_resets_setup(self): + backend = _RecordingElectroporationBackend() + cap = Electroporation(backend=backend) + + await cap._on_setup() + info = await cap.get_device_info() + await cap._on_stop() + + self.assertEqual(info, {"model": "test electroporator"}) + self.assertFalse(cap.setup_finished) + self.assertEqual(backend.stop_calls, 1) + + async def test_chatterbox_prepares_and_starts_serialized_run(self): + backend = ElectroporationChatterboxBackend() + cap = Electroporation(backend=backend) + protocol = _square_protocol() + + await cap._on_setup() + prepared = await cap.prepare_temporary_protocol(protocol, plate_columns=3) + started = await cap.start_prepared_run(prepared.as_dict(), home_after=False) + + self.assertEqual(prepared.protocol_name, "!PLR_000001") + self.assertEqual(prepared.protocol, protocol) + self.assertEqual(prepared.plate_columns, 3) + self.assertEqual(started.prepared_run, prepared) + self.assertEqual(started.rsi_result.verification_state, "protocol_run_view") + self.assertEqual(started.rsi_result.completed_state, "protocol_finish") + self.assertEqual(started.rsi_result.final_state, "protocol_finish") + self.assertEqual(started.log_capture.summary["protocol"], prepared.protocol_name) + self.assertTrue(started.cleanup.deleted) + self.assertNotIn(prepared.protocol_name, backend.prepared_runs) + + async def test_chatterbox_cancels_prepared_run_and_reports_info(self): + backend = ElectroporationChatterboxBackend(temporary_protocol_prefix="!SIM") + cap = Electroporation(backend=backend) + + await cap._on_setup() + info = await cap.get_device_info() + prepared = await cap.prepare_temporary_protocol(_square_protocol()) + cancelled = await cap.cancel_prepared_run(prepared, home_after=True) + + self.assertEqual(info["backend"], "ElectroporationChatterboxBackend") + self.assertEqual(info["temporary_protocol_prefix"], "!SIM") + self.assertEqual(prepared.protocol_name, "!SIM_000001") + self.assertEqual(cancelled.prepared_run, prepared) + self.assertEqual(cancelled.rsi_result.final_state, "main_menu") + self.assertTrue(cancelled.cleanup.deleted) + self.assertNotIn(prepared.protocol_name, backend.prepared_runs) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/capabilities/electroporation/standard.py b/pylabrobot/capabilities/electroporation/standard.py new file mode 100644 index 00000000000..8f4aeb24663 --- /dev/null +++ b/pylabrobot/capabilities/electroporation/standard.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Mapping, Optional + + +@dataclass(frozen=True) +class ElectroporationProtocol: + """Portable protocol definition for electroporation runs. + + Exactly one waveform-specific parameter set must be present: + - `square`: `duration_us` + - `exponential`: `resistance_ohms` and `capacitance_uf` + """ + + protocol_type: str + pulse_amplitude_volts: int + gap_mm: float + pulse_count: int = 1 + pulse_interval_seconds: Optional[float] = None + duration_us: Optional[int] = None + resistance_ohms: Optional[int] = None + capacitance_uf: Optional[int] = None + + def as_parameters(self) -> Dict[str, Any]: + return { + "protocol_type": self.protocol_type, + "pulse_amplitude_volts": self.pulse_amplitude_volts, + "gap_mm": self.gap_mm, + "pulse_count": self.pulse_count, + "pulse_interval_seconds": self.pulse_interval_seconds, + "duration_us": self.duration_us, + "resistance_ohms": self.resistance_ohms, + "capacitance_uf": self.capacitance_uf, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "ElectroporationProtocol": + return cls( + protocol_type=str(data["protocol_type"]), + pulse_amplitude_volts=int(data["pulse_amplitude_volts"]), + gap_mm=float(data["gap_mm"]), + pulse_count=int(data.get("pulse_count", 1)), + pulse_interval_seconds=( + None + if data.get("pulse_interval_seconds") is None + else float(data["pulse_interval_seconds"]) + ), + duration_us=None if data.get("duration_us") is None else int(data["duration_us"]), + resistance_ohms=( + None if data.get("resistance_ohms") is None else int(data["resistance_ohms"]) + ), + capacitance_uf=None if data.get("capacitance_uf") is None else int(data["capacitance_uf"]), + ) + + +@dataclass(frozen=True) +class ElectroporationPreparationDetails: + """Generic preparation details for a prepared electroporation run.""" + + prepared_state: Optional[str] + protocol_setup: Dict[str, Any] + device_prepare: Dict[str, Any] + + def as_dict(self) -> Dict[str, Any]: + return { + "prepared_state": self.prepared_state, + "protocol_setup": self.protocol_setup, + "device_prepare": self.device_prepare, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "ElectroporationPreparationDetails": + return cls( + prepared_state=None if data["prepared_state"] is None else str(data["prepared_state"]), + protocol_setup=dict(data["protocol_setup"]), + device_prepare=dict(data["device_prepare"]), + ) + + +@dataclass(frozen=True) +class ElectroporationExecutionDetails: + """Generic device-run details for a started electroporation run.""" + + verification_state: Optional[str] + completed_state: Optional[str] + final_state: Optional[str] + device_run: Dict[str, Any] + + def as_dict(self) -> Dict[str, Any]: + return { + "verification_state": self.verification_state, + "completed_state": self.completed_state, + "final_state": self.final_state, + "device_run": self.device_run, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "ElectroporationExecutionDetails": + return cls( + verification_state=( + None if data["verification_state"] is None else str(data["verification_state"]) + ), + completed_state=None if data["completed_state"] is None else str(data["completed_state"]), + final_state=None if data["final_state"] is None else str(data["final_state"]), + device_run=dict(data["device_run"]), + ) + + +@dataclass(frozen=True) +class ElectroporationCancellationDetails: + """Generic device-cancel details for a prepared electroporation run.""" + + final_state: Optional[str] + device_cancel: Dict[str, Any] + + def as_dict(self) -> Dict[str, Any]: + return { + "final_state": self.final_state, + "device_cancel": self.device_cancel, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "ElectroporationCancellationDetails": + return cls( + final_state=None if data["final_state"] is None else str(data["final_state"]), + device_cancel=dict(data["device_cancel"]), + ) + + +@dataclass(frozen=True) +class ElectroporationLogCapture: + """Generic log-capture result for an electroporation run.""" + + matched_log_path: Optional[str] + summary: Dict[str, Any] + details: Dict[str, Any] + + def as_dict(self) -> Dict[str, Any]: + return { + "matched_log_path": self.matched_log_path, + "summary": self.summary, + "details": self.details, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "ElectroporationLogCapture": + return cls( + matched_log_path=None if data["matched_log_path"] is None else str(data["matched_log_path"]), + summary=dict(data["summary"]), + details=dict(data["details"]), + ) + + +@dataclass(frozen=True) +class ElectroporationCleanup: + """Generic cleanup result after a prepared or completed electroporation run.""" + + deleted: Optional[bool] + retry_used: bool + error: Optional[str] + details: Dict[str, Any] + + def as_dict(self) -> Dict[str, Any]: + return { + "deleted": self.deleted, + "retry_used": self.retry_used, + "error": self.error, + "details": self.details, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "ElectroporationCleanup": + return cls( + deleted=None if data["deleted"] is None else bool(data["deleted"]), + retry_used=bool(data["retry_used"]), + error=None if data["error"] is None else str(data["error"]), + details=dict(data["details"]), + ) + + +@dataclass(frozen=True) +class PreparedElectroporationRun: + """Prepared temporary run left armed on the device run screen. + + Serialize with `as_dict()` and restore with `from_dict()` in a later process. + """ + + protocol_name: str + protocol: ElectroporationProtocol + plate_columns: Optional[int] + prefix: str + prepared_at_utc: str + baseline_log_paths: tuple[str, ...] + prepare_result: ElectroporationPreparationDetails + + def as_dict(self) -> Dict[str, Any]: + return { + "protocol_name": self.protocol_name, + "protocol": self.protocol.as_parameters(), + "plate_columns": self.plate_columns, + "prefix": self.prefix, + "prepared_at_utc": self.prepared_at_utc, + "baseline_log_paths": list(self.baseline_log_paths), + "prepare_result": self.prepare_result.as_dict(), + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "PreparedElectroporationRun": + return cls( + protocol_name=str(data["protocol_name"]), + protocol=ElectroporationProtocol.from_dict(data["protocol"]), + plate_columns=None if data["plate_columns"] is None else int(data["plate_columns"]), + prefix=str(data["prefix"]), + prepared_at_utc=str(data["prepared_at_utc"]), + baseline_log_paths=tuple(str(path) for path in data["baseline_log_paths"]), + prepare_result=ElectroporationPreparationDetails.from_dict(data["prepare_result"]), + ) + + +@dataclass(frozen=True) +class ElectroporationRunResult: + """Result of starting a previously prepared electroporation run.""" + + prepared_run: PreparedElectroporationRun + started_at_utc: str + completed_at_utc: str + rsi_result: ElectroporationExecutionDetails + log_capture: ElectroporationLogCapture + cleanup: ElectroporationCleanup + + def as_dict(self) -> Dict[str, Any]: + return { + "prepared_run": self.prepared_run.as_dict(), + "started_at_utc": self.started_at_utc, + "completed_at_utc": self.completed_at_utc, + "rsi_result": self.rsi_result.as_dict(), + "log_capture": self.log_capture.as_dict(), + "cleanup": self.cleanup.as_dict(), + } + + +@dataclass(frozen=True) +class ElectroporationCancellationResult: + """Result of cancelling a prepared temporary electroporation run.""" + + prepared_run: PreparedElectroporationRun + cancelled_at_utc: str + rsi_result: ElectroporationCancellationDetails + cleanup: ElectroporationCleanup + + def as_dict(self) -> Dict[str, Any]: + return { + "prepared_run": self.prepared_run.as_dict(), + "cancelled_at_utc": self.cancelled_at_utc, + "rsi_result": self.rsi_result.as_dict(), + "cleanup": self.cleanup.as_dict(), + } diff --git a/pyproject.toml b/pyproject.toml index c873d4096bd..c064d17991f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,13 +16,14 @@ serial = ["pyserial"] usb = ["pyusb", "libusb-package"] ftdi = ["pylibftdi", "pyusb"] hid = ["hid"] +btx = ["pyserial", "numpy>=1.26", "Pillow"] modbus = ["pymodbus>=3.0.0,<3.7.0"] opentrons = ["opentrons-http-api-client==0.2.0"] sila = ["zeroconf>=0.131.0", "grpcio"] cytation-microscopy = ["numpy>=1.26", "opencv-python", "PyGObject"] pico = ["PyLabRobot[sila]", "opencv-python", "numpy"] xarm = ["xarm-python-sdk"] -all = ["PyLabRobot[serial,usb,ftdi,hid,modbus,websockets,visualizer,opentrons,sila,pico,xarm]"] +all = ["PyLabRobot[serial,usb,ftdi,hid,btx,modbus,websockets,visualizer,opentrons,sila,pico,xarm]"] test = [ "pytest", "pytest-timeout",