diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index c69872a566b..d7eb973a7b5 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -22,6 +22,10 @@ runs: || sudo apt-get install -y libgirepository1.0-dev fi 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 33e206a3310..3be85b5d71a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,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/_typos.toml b/_typos.toml index 199350fc525..788abb52d2e 100644 --- a/_typos.toml +++ b/_typos.toml @@ -33,6 +33,12 @@ LOK = "LOK" ouput = "ouput" hegiht = "hegiht" +# BTX Gemini X2 firmware/OCR vocabulary. `scap` is the RSI framebuffer +# command; the misspelled protocol variants are OCR corrections. +scap = "scap" +protocals = "protocals" +protocal = "protocal" + # Celigo vendor XML schema identifiers. These spellings appear in instrument # configuration files and must remain exact for configuration loading. Accleration = "Accleration" diff --git a/docs/_exts/plr_devices/data.py b/docs/_exts/plr_devices/data.py index 86d0afa60ae..5a2113d56eb 100644 --- a/docs/_exts/plr_devices/data.py +++ b/docs/_exts/plr_devices/data.py @@ -22,6 +22,7 @@ class DeviceRegistryError(ValueError): "centrifuge loader", "decapper", "delidder", + "electroporator", "fan", "flow cytometer", "heater shaker", @@ -51,6 +52,7 @@ class DeviceRegistryError(ValueError): "decapping", "delidding", "dispensing", + "electroporation", "flow cytometry", "fluorescence", "fluorescence polarization", diff --git a/docs/_static/devices.json b/docs/_static/devices.json index 97dfdb5a8ed..15caed83b90 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -278,6 +278,21 @@ "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://www.preciseflexrobots.com/lab-automation-applicable-products" }, + { + "id": "btx-gemini-x2", + "vendor": "BTX", + "name": "Gemini X2", + "kind": "electroporator", + "capabilities": [ + "electroporation" + ], + "status": "mostly", + "api": "pylabrobot.thermo_fisher.btx.gemini.X2.BTXGeminiX2", + "api_version": "v1", + "code_slug": "thermo_fisher/btx/gemini/X2", + "doc_slug": "thermo_fisher/btx/gemini/X2/hello-world", + "oem": "https://support.btxonline.com/hc/en-us/articles/6215664757907-Gemini-Twin-Wave-Electroporators-Manual-and-Quick-Start-guide" + }, { "id": "byonoy-absorbance-96", "vendor": "Byonoy", diff --git a/docs/api/pylabrobot.thermo_fisher.rst b/docs/api/pylabrobot.thermo_fisher.rst index 27c3d5383c3..fb8ef00bd59 100644 --- a/docs/api/pylabrobot.thermo_fisher.rst +++ b/docs/api/pylabrobot.thermo_fisher.rst @@ -46,3 +46,40 @@ ALPS Heat Sealers ThermoScientificALPS5000 ALPS5000Status +BTX Gemini X2 +------------- + +.. currentmodule:: pylabrobot.thermo_fisher.btx.gemini.X2.gemini_x2 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + BTXGeminiX2 + +.. currentmodule:: pylabrobot.thermo_fisher.btx.gemini.X2.standard + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + ElectroporationProtocol + ElectroporationPreparationDetails + ElectroporationExecutionDetails + ElectroporationCancellationDetails + ElectroporationLogCapture + ElectroporationCleanup + PreparedElectroporationRun + ElectroporationRunResult + ElectroporationCancellationResult + +.. currentmodule:: pylabrobot.thermo_fisher.btx.gemini.X2.ht200 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + BTXHT200 diff --git a/docs/user_guide/thermo_fisher/btx/gemini/X2/hello-world.ipynb b/docs/user_guide/thermo_fisher/btx/gemini/X2/hello-world.ipynb new file mode 100644 index 00000000000..744ca026464 --- /dev/null +++ b/docs/user_guide/thermo_fisher/btx/gemini/X2/hello-world.ipynb @@ -0,0 +1,224 @@ +{ + "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", + "See the [installation instructions](installation.md) before connecting the instrument.\n", + "\n", + "Protocol and run-result models are provided by the Gemini X2 package." + ] + }, + { + "cell_type": "markdown", + "id": "device-card", + "metadata": {}, + "source": [ + "```{device-card} btx-gemini-x2\n", + "```" + ] + }, + { + "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.thermo_fisher.btx.gemini.X2 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 Gemini X2 exposes device identity, the temporary protocol prefix, and plate-handler support." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "info-code", + "metadata": {}, + "outputs": [], + "source": [ + "info = await gemini.request_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.thermo_fisher.btx.gemini.X2 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, first record its configured pulse count and column adjustment. `plate_columns` also 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": [ + "gemini.plate_handler.configure_manual_state(pulse_count=1, column_adjust=0)\n", + "\n", + "prepared = await gemini.prepare_temporary_protocol(\n", + " protocol=protocol,\n", + " plate_columns=3,\n", + " plate_handler_reset_state=\"reset_confirmed\",\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.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.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/thermo_fisher/btx/gemini/X2/installation.md b/docs/user_guide/thermo_fisher/btx/gemini/X2/installation.md new file mode 100644 index 00000000000..1495f93bb0a --- /dev/null +++ b/docs/user_guide/thermo_fisher/btx/gemini/X2/installation.md @@ -0,0 +1,12 @@ +# BTX Gemini X2 installation + +BTX Gemini X2 support uses serial communication and touchscreen screenshot OCR. First, install the +Python dependencies: + +```bash +pip install "pylabrobot[btx]" +``` + +Touchscreen control 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`. diff --git a/docs/user_guide/thermo_fisher/btx/index.md b/docs/user_guide/thermo_fisher/btx/index.md new file mode 100644 index 00000000000..b1d8feb72e0 --- /dev/null +++ b/docs/user_guide/thermo_fisher/btx/index.md @@ -0,0 +1,8 @@ +# BTX + +```{toctree} +:maxdepth: 1 + +gemini/X2/installation +gemini/X2/hello-world +``` diff --git a/docs/user_guide/thermo_fisher/index.md b/docs/user_guide/thermo_fisher/index.md index 4c887655bbb..20716cfcb0d 100644 --- a/docs/user_guide/thermo_fisher/index.md +++ b/docs/user_guide/thermo_fisher/index.md @@ -4,4 +4,5 @@ :maxdepth: 1 alps/index +btx/index ``` diff --git a/pylabrobot/io/serial.py b/pylabrobot/io/serial.py index 85dd33dc7df..d7ffb81fe2d 100644 --- a/pylabrobot/io/serial.py +++ b/pylabrobot/io/serial.py @@ -4,11 +4,14 @@ from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from io import IOBase -from typing import Iterator, Optional, cast +from typing import Collection, Iterator, Optional, cast from pylabrobot.events import emit_event +from pylabrobot.io.capture import CaptureReader, Command, capturer, get_capture_or_validation_active from pylabrobot.io.errors import ValidationError +from pylabrobot.io.validation_utils import LOG_LEVEL_IO, align_sequences +_SERIAL_IMPORT_ERROR: Optional[ImportError] = None try: import serial import serial.tools.list_ports @@ -18,12 +21,32 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e -from pylabrobot.io.capture import CaptureReader, Command, capturer, get_capture_or_validation_active -from pylabrobot.io.validation_utils import LOG_LEVEL_IO, align_sequences - logger = logging.getLogger(__name__) +def find_serial_ports(vid_pid_pairs: Collection[tuple[int, int]]) -> list[str]: + """Return serial ports matching any of the supplied USB VID/PID pairs. + + Args: + vid_pid_pairs: USB vendor/product identifier pairs to match. + + Raises: + RuntimeError: If pyserial is not installed. + """ + if not HAS_SERIAL: + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) + + identifiers = set(vid_pid_pairs) + return [ + str(port.device) + for port in serial.tools.list_ports.comports() + if (port.vid, port.pid) in identifiers + ] + + @dataclass class SerialCommand(Command): data: str diff --git a/pylabrobot/io/serial_tests.py b/pylabrobot/io/serial_tests.py new file mode 100644 index 00000000000..5f5f05527e6 --- /dev/null +++ b/pylabrobot/io/serial_tests.py @@ -0,0 +1,32 @@ +import types +import unittest +from unittest.mock import patch + +from pylabrobot.io.serial import find_serial_ports + + +class TestSerialDiscovery(unittest.TestCase): + def test_find_serial_ports_matches_multiple_vid_pid_pairs(self) -> None: + ports = [ + types.SimpleNamespace(device="/dev/tty-one", vid=0x1234, pid=0x0001), + types.SimpleNamespace(device="/dev/tty-two", vid=0x1234, pid=0x0002), + types.SimpleNamespace(device="/dev/tty-other", vid=0x9999, pid=0x0001), + ] + serial_module = types.SimpleNamespace( + tools=types.SimpleNamespace( + list_ports=types.SimpleNamespace(comports=lambda: ports), + ) + ) + + with ( + patch("pylabrobot.io.serial.HAS_SERIAL", True), + patch("pylabrobot.io.serial.serial", serial_module, create=True), + ): + discovered = find_serial_ports({(0x1234, 0x0001), (0x1234, 0x0002)}) + + self.assertEqual(discovered, ["/dev/tty-one", "/dev/tty-two"]) + + def test_find_serial_ports_reports_missing_pyserial(self) -> None: + with patch("pylabrobot.io.serial.HAS_SERIAL", False): + with self.assertRaisesRegex(RuntimeError, "pyserial is not installed"): + find_serial_ports({(0x1234, 0x0001)}) diff --git a/pylabrobot/thermo_fisher/btx/__init__.py b/pylabrobot/thermo_fisher/btx/__init__.py new file mode 100644 index 00000000000..5c2191e1c34 --- /dev/null +++ b/pylabrobot/thermo_fisher/btx/__init__.py @@ -0,0 +1,4 @@ +from .gemini.X2 import ( + BTXHT200, + BTXGeminiX2, +) diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/__init__.py b/pylabrobot/thermo_fisher/btx/gemini/X2/__init__.py new file mode 100644 index 00000000000..837ecc5b511 --- /dev/null +++ b/pylabrobot/thermo_fisher/btx/gemini/X2/__init__.py @@ -0,0 +1,13 @@ +from .gemini_x2 import BTXGeminiX2 +from .ht200 import BTXHT200 +from .standard import ( + ElectroporationCancellationDetails, + ElectroporationCancellationResult, + ElectroporationCleanup, + ElectroporationExecutionDetails, + ElectroporationLogCapture, + ElectroporationPreparationDetails, + ElectroporationProtocol, + ElectroporationRunResult, + PreparedElectroporationRun, +) diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/file_transfer_control.py b/pylabrobot/thermo_fisher/btx/gemini/X2/file_transfer_control.py new file mode 100644 index 00000000000..029ad130451 --- /dev/null +++ b/pylabrobot/thermo_fisher/btx/gemini/X2/file_transfer_control.py @@ -0,0 +1,1065 @@ +from __future__ import annotations + +import asyncio +import logging +import re +from datetime import datetime, timezone +from math import isfinite +from typing import Any, Dict, Mapping, Optional, Protocol, TypedDict + +from pylabrobot.io.binary import Reader, Writer +from pylabrobot.io.serial import Serial, find_serial_ports + +from .standard import ElectroporationProtocol + +logger = logging.getLogger(__name__) + + +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 + + +class _ProgramEntry(TypedDict): + name: str + size: int + + +class ProtocolDeletionPendingError(RuntimeError): + """Raised when a protocol remains visible after the Gemini accepted its deletion.""" + + +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._port = port + 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)} + ) + self._is_setup = False + self._command_lock = asyncio.Lock() + + @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 self._is_setup: + return + logger.info("Setting up Gemini X2 file-transfer control on port %s", self._port or "auto") + if self._serial is None: + if self._port is None: + self._port = self._resolve_port() + self._serial = Serial( + human_readable_device_name="BTX Gemini X2 protocol manager", + port=self._port, + baudrate=self._baudrate, + timeout=self._timeout, + write_timeout=self._write_timeout, + ) + + serial_dev = self._require_serial() + try: + await serial_dev.setup() + except Exception: + try: + await serial_dev.stop() + except Exception: + logger.debug("Failed to close Gemini serial after setup failure", exc_info=True) + raise + self._is_setup = True + resolved_port = getattr(serial_dev, "port", None) + if isinstance(resolved_port, str): + self._port = resolved_port + logger.info("Gemini X2 file-transfer control ready on port %s", self._port) + + async def stop(self) -> None: + """Close the Gemini USB-serial port.""" + if not self._is_setup: + return + logger.info("Stopping Gemini X2 file-transfer control on port %s", self._port) + try: + await self._require_serial().stop() + finally: + self._is_setup = False + logger.info("Gemini X2 file-transfer control stopped") + + 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 request_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( + "request_protocol", + name, + payload_hex=payload_hex, + payload_bytes=len(payload), + decoded=decoded, + response=response, + ) + + async def verify_protocol( + self, + protocol_name: str, + expected: ElectroporationProtocol, + ) -> Dict[str, Any]: + """Read back a stored method and require it to match the prepared protocol exactly.""" + result = await self.request_protocol(protocol_name) + decoded = result["decoded"] + if not isinstance(decoded, Mapping): + raise RuntimeError(f"Gemini protocol {protocol_name!r} had no decoded payload.") + + normalized = self._normalize_protocol_parameters(expected) + expected_payload_hex = self._build_method_payload(protocol_name, expected).hex().upper() + expected_fields: Dict[str, Any] = { + "version": 1, + "name": protocol_name, + "protocol_type": normalized["protocol_type"], + "pulse_amplitude_volts": normalized["pulse_amplitude_volts"], + "pulse_count": normalized["pulse_count"], + "pulse_interval_ms": normalized["pulse_interval_ms"], + "electrode_gap_mm": normalized["electrode_gap_mm"], + "pulse_duration_us": 0, + "resistance_ohms": 0, + "capacitance_uf": 0, + } + if normalized["protocol_type"] == "square": + expected_fields["pulse_duration_us"] = normalized["pulse_duration_us"] + else: + expected_fields["resistance_ohms"] = normalized["resistance_ohms"] + expected_fields["capacitance_uf"] = normalized["capacitance_uf"] + + differences = [] + for field, expected_value in expected_fields.items(): + actual_value = decoded.get(field) + matches = ( + abs(float(actual_value) - expected_value) <= 1e-5 + if field == "electrode_gap_mm" and isinstance(actual_value, (int, float)) + else actual_value == expected_value + ) + if not matches: + differences.append(f"{field}: expected {expected_value!r}, got {actual_value!r}") + actual_payload_hex = result.get("payload_hex") + if isinstance(actual_payload_hex, str) and actual_payload_hex.upper() != expected_payload_hex: + differences.append("raw method payload differs") + if differences: + raise RuntimeError( + f"Stored Gemini protocol {protocol_name!r} no longer matches the prepared run: " + + "; ".join(differences) + ) + return result + + 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) + logger.info("Adding Gemini X2 protocol %s (overwrite=%s)", name, overwrite) + payload = self._build_method_payload(name, protocol) + payload_hex = payload.hex().upper() + 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) + + 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.') + + logger.info("Added Gemini X2 protocol %s", name) + 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) + logger.info("Deleting Gemini X2 protocol %s", 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.') + logger.info("Gemini X2 protocol %s was already absent", name) + 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 ProtocolDeletionPendingError( + f'Protocol "{name}" still exists after repeated delete attempts.' + ) + + logger.info("Deleted Gemini X2 protocol %s", name) + 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 request_version(self) -> str: + """Return the Gemini software version string.""" + return await self._read_single_value_command("version") + + async def request_serial_number(self) -> str: + """Return the Gemini serial number.""" + return await self._read_single_value_command("sn") + + async def request_device_time(self) -> str: + """Return the current date/time reported by the Gemini.""" + return await self._read_single_value_command("time") + + async def request_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 workflow.""" + 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 _send_text_command(self, command: str) -> str: + """Send one PM shell command and return the prompt-terminated response text.""" + if "\r" in command or "\n" in command: + raise ValueError("BTX commands cannot contain carriage returns or newlines.") + if not self._is_setup: + raise RuntimeError("Gemini X2 file-transfer control is not set up.") + async with self._command_lock: + 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: + btx_ports = find_serial_ports(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: + raise RuntimeError( + f"Multiple BTX Gemini devices found: {btx_ports}. Please specify the port explicitly." + ) + + logger.info("Autodiscovered Gemini X2 on port %s", btx_ports[0]) + return btx_ports[0] + + 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, ":"}] + if len(lines) == 0: + raise RuntimeError(f"BTX command {command!r} returned no value.") + return lines[0] + + async def _read_until_prompt(self, read_size: int = 512, max_reads: int = 24) -> bytes: + chunks: list[bytes] = [] + for _ in range(max_reads): + chunk = await self._read_raw(num_bytes=read_size) + if len(chunk) == 0: + await asyncio.sleep(0.05) + continue + chunks.append(chunk) + response = b"".join(chunks) + if response.rstrip(b"\r\n").endswith(b":"): + return response + await asyncio.sleep(0.03) + response = b"".join(chunks) + raise TimeoutError( + "Timed out waiting for the Gemini command prompt; " + f"received {len(response)} bytes without a terminating ':'." + ) + + 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: + if any(ord(character) < 32 or ord(character) == 127 for character in sd_path): + raise ValueError("SD paths cannot contain control characters.") + path = sd_path.strip().replace("/", "\\") + if not path.startswith("\\"): + path = "\\" + path + path = re.sub(r"\\+", r"\\", path) + components = [component for component in path.split("\\") if component] + for component in components: + if component in {".", ".."}: + raise ValueError("SD paths cannot contain '.' or '..' components.") + if not re.fullmatch(r"[^\x00-\x1f\x7f\"<>|:*?]+", component): + raise ValueError(f"Unsafe Gemini SD path component: {component!r}.") + return "\\" + "\\".join(components) if components else "\\" + + def _join_sd_path(self, *parts: str) -> str: + return self._normalize_sd_path("\\".join(parts)) + + 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") + if pulse_count is None: + pulse_count = 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 driver. " + "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/thermo_fisher/btx/gemini/X2/file_transfer_control_tests.py b/pylabrobot/thermo_fisher/btx/gemini/X2/file_transfer_control_tests.py new file mode 100644 index 00000000000..a65e81a7276 --- /dev/null +++ b/pylabrobot/thermo_fisher/btx/gemini/X2/file_transfer_control_tests.py @@ -0,0 +1,580 @@ +import unittest +from collections import deque +from typing import Deque, List, Sequence, Tuple +from unittest.mock import AsyncMock, patch + +from pylabrobot.thermo_fisher.btx.gemini.X2.file_transfer_control import ( + ProtocolDeletionPendingError, + _FileTransferControl, +) + +from .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 = AsyncMock() + self.stop = AsyncMock() + self.writes: List[bytes] = [] + self.read_chunks: Deque[bytes] = deque() + + 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() + + +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 = AsyncMock() + self.stop = AsyncMock() + _ConstructedSerial.instances.append(self) + + async def write(self, data: bytes) -> None: + del data + + async def read(self, num_bytes: int = 1) -> bytes: + del num_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() + + fake.setup.assert_awaited_once_with() + fake.stop.assert_awaited_once_with() + + async def test_setup_and_stop_are_idempotent(self): + fake = _FakeSerial() + control = _FileTransferControl(serial_io=fake) + + await control.setup() + await control.setup() + await control.stop() + await control.stop() + + fake.setup.assert_awaited_once_with() + fake.stop.assert_awaited_once_with() + + async def test_setup_failure_attempts_serial_cleanup(self): + fake = _FakeSerial() + fake.setup.side_effect = RuntimeError("open failed") + control = _FileTransferControl(serial_io=fake) + + with self.assertRaisesRegex(RuntimeError, "open failed"): + await control.setup() + + fake.stop.assert_awaited_once_with() + + async def test_setup_autodiscovers_btx_port_then_uses_shared_serial(self): + _ConstructedSerial.instances.clear() + + with ( + patch( + "pylabrobot.thermo_fisher.btx.gemini.X2.file_transfer_control.find_serial_ports", + return_value=["/dev/cu.btx"], + ) as find_ports, + patch( + "pylabrobot.thermo_fisher.btx.gemini.X2.file_transfer_control.Serial", + _ConstructedSerial, + ), + ): + control = _FileTransferControl() + await control.setup() + await control.stop() + + find_ports.assert_called_once_with(_FileTransferControl.SUPPORTED_USB_IDS) + 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) + serial_io.setup.assert_awaited_once_with() + serial_io.stop.assert_awaited_once_with() + 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) + await control.setup() + + 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) + await control.setup() + + 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) + await control.setup() + + 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) + await control.setup() + + 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_overwrite_payload_is_validated_before_existing_protocol_is_deleted(self): + fake = _FakeSerial() + control = _FileTransferControl(serial_io=fake) + await control.setup() + + with self.assertRaisesRegex(ValueError, "Missing pulse amplitude"): + await control.add_protocol("TEST", {"protocol_type": "square"}, overwrite=True) + + self.assertEqual(fake.writes, []) + + def test_protocol_model_rejects_zero_pulses_and_mixed_waveform_fields(self): + with self.assertRaisesRegex(ValueError, "pulse_count must be greater than zero"): + ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + pulse_count=0, + duration_us=1000, + ) + with self.assertRaisesRegex(ValueError, "cannot define resistance"): + ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + resistance_ohms=200, + ) + + async def test_request_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) + await control.setup() + + result = await control.request_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) + await control.setup() + + result = await control.delete_protocol("TEST") + + self.assertTrue(result["deleted"]) + self.assertFalse(result["exists_after"]) + + async def test_delete_protocol_raises_typed_pending_error(self): + fake = _FakeSerial() + control = _FileTransferControl(serial_io=fake) + await control.setup() + + with ( + patch.object(control, "list_protocols", AsyncMock(return_value=["TEST"])), + patch.object(control, "_send_text_command", AsyncMock(return_value=":")), + ): + with self.assertRaises(ProtocolDeletionPendingError): + await control.delete_protocol("TEST") + + async def test_prompt_reader_rejects_empty_and_partial_responses(self): + fake = _FakeSerial() + control = _FileTransferControl(serial_io=fake) + await control.setup() + + with self.assertRaisesRegex(TimeoutError, "without a terminating ':'"): + await control._read_until_prompt(max_reads=1) + + fake.read_chunks.append(b"partial response") + with self.assertRaisesRegex(TimeoutError, "without a terminating ':'"): + await control._read_until_prompt(max_reads=1) + + async def test_single_value_command_rejects_a_prompt_without_a_value(self): + fake = _FakeSerial() + fake.read_chunks.append(b":") + control = _FileTransferControl(serial_io=fake) + await control.setup() + + with self.assertRaisesRegex(RuntimeError, "returned no value"): + await control.request_version() + + def test_sd_paths_reject_control_characters_and_traversal(self): + control = _FileTransferControl(serial_io=_FakeSerial()) + + for path in ("\\BTXDATA\nversion", "\\BTXDATA\\..\\secret", "\\BTXDATA\\bad:name"): + with self.subTest(path=path): + with self.assertRaises(ValueError): + control._normalize_sd_path(path) + + async def test_verify_protocol_rejects_any_changed_payload_field(self): + control = _FileTransferControl(serial_io=_FakeSerial()) + expected = ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + ) + request_protocol = AsyncMock( + return_value={ + "decoded": { + "name": "TEST", + "protocol_type": "square", + "pulse_amplitude_volts": 251, + "pulse_count": 1, + "pulse_interval_ms": 0, + "electrode_gap_mm": 1.0, + "pulse_duration_us": 1000, + } + } + ) + + with patch.object(control, "request_protocol", request_protocol): + with self.assertRaisesRegex(RuntimeError, "pulse_amplitude_volts"): + await control.verify_protocol("TEST", expected) + + 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) + await control.setup() + + 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) + await control.setup() + + 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_request_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) + await control.setup() + + version = await control.request_version() + serial_number = await control.request_serial_number() + device_time = await control.request_device_time() + stats = await control.request_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/thermo_fisher/btx/gemini/X2/gemini_x2.py b/pylabrobot/thermo_fisher/btx/gemini/X2/gemini_x2.py new file mode 100644 index 00000000000..bedf8817cc6 --- /dev/null +++ b/pylabrobot/thermo_fisher/btx/gemini/X2/gemini_x2.py @@ -0,0 +1,689 @@ +from __future__ import annotations + +import asyncio +import logging +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import ( + Any, + Awaitable, + Callable, + Dict, + Literal, + Mapping, + Optional, + Protocol, + TypeVar, + Union, +) + +from .file_transfer_control import ProtocolDeletionPendingError, _FileTransferControl +from .ht200 import BTXHT200 +from .standard import ( + ElectroporationCancellationDetails, + ElectroporationCancellationResult, + ElectroporationCleanup, + ElectroporationExecutionDetails, + ElectroporationLogCapture, + ElectroporationPreparationDetails, + ElectroporationProtocol, + ElectroporationRunResult, + PreparedElectroporationRun, +) +from .the_ghost_touch import ( + CancelledPreparedUserProtocolResult, + PreparedUserProtocolResult, + StartedPreparedUserProtocolResult, + _TheGhostTouch, +) + +logger = logging.getLogger(__name__) + +PlateHandlerResetState = Literal["unknown", "reset_confirmed", "continue_current_position"] + + +class _GhostTouchSession(Protocol): + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def ensure_home(self) -> Any: + pass + + async def prepare_user_protocol( + self, + protocol_name: str, + plate_columns: Optional[int] = None, + ) -> PreparedUserProtocolResult: + pass + + async def start_prepared_user_protocol( + self, + protocol_name: str, + home_after: bool = True, + max_run_seconds: float = 420.0, + ) -> StartedPreparedUserProtocolResult: + pass + + async def cancel_prepared_user_protocol(self) -> CancelledPreparedUserProtocolResult: + pass + + +GhostTouchResult = TypeVar("GhostTouchResult") + + +@dataclass(frozen=True) +class TemporaryProtocolCleanupResult: + delete_result: Optional[Dict[str, 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, + } + + def to_cleanup(self) -> ElectroporationCleanup: + deleted = None if self.delete_result is None else self.delete_result.get("deleted") + return ElectroporationCleanup( + deleted=deleted if isinstance(deleted, bool) else None, + retry_used=self.delete_retry_used, + error=self.delete_error, + details=self.as_dict(), + ) + + +@dataclass(frozen=True) +class MatchedRunLogResult: + before_count: int + after_count: int + new_log_paths: tuple[str, ...] + matched_log_path: Optional[str] + matched_log: Optional[Dict[str, 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 BTXGeminiX2: + """BTX Gemini X2 driver. + + The driver owns both mutually exclusive serial modes used by the device: Protocol Manager + file transfer and the RSI touchscreen workflow. All device operations are serialized so the + two modes cannot be used concurrently. + """ + + UI_PROTOCOL_NAME_BYTES = _FileTransferControl.UI_PROTOCOL_NAME_BYTES + DEFAULT_TEMPORARY_PROTOCOL_PREFIX = "!PLR" + PLATE_HANDLER_RESET_STATE_UNKNOWN: PlateHandlerResetState = "unknown" + PLATE_HANDLER_RESET_STATE_RESET_CONFIRMED: PlateHandlerResetState = "reset_confirmed" + PLATE_HANDLER_RESET_STATE_CONTINUE_CURRENT_POSITION: PlateHandlerResetState = ( + "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, + } + LOG_POLL_TIMEOUT_SECONDS = 10.0 + LOG_POLL_INTERVAL_SECONDS = 0.5 + + def __init__( + self, + port: Optional[str] = None, + *, + plate_handler: Optional[BTXHT200] = None, + temporary_protocol_prefix: str = DEFAULT_TEMPORARY_PROTOCOL_PREFIX, + ) -> None: + self._file_transfer_control = _FileTransferControl(port=port) + self.plate_handler = plate_handler if plate_handler is not None else BTXHT200() + self._temporary_protocol_prefix = temporary_protocol_prefix + self._operation_lock = asyncio.Lock() + self._is_setup = False + + @property + def port(self) -> Optional[str]: + """The resolved serial port, if setup has discovered one.""" + return self._file_transfer_control.port + + async def setup(self) -> None: + async with self._operation_lock: + if self._is_setup: + return + logger.info("Setting up BTX Gemini X2") + try: + await self._file_transfer_control.setup() + await self._ensure_temporary_protocol_prefix_order_safe(self._temporary_protocol_prefix) + except BaseException: + await self._stop_file_transfer_after_failure() + raise + self._is_setup = True + logger.info("BTX Gemini X2 ready on port %s", self.port) + + async def stop(self) -> None: + async with self._operation_lock: + if not self._is_setup: + return + logger.info("Stopping BTX Gemini X2") + try: + await self._file_transfer_control.stop() + finally: + self._is_setup = False + logger.info("BTX Gemini X2 stopped") + + async def _stop_file_transfer_after_failure(self) -> None: + try: + await self._file_transfer_control.stop() + except Exception: + logger.exception("Failed to close Gemini X2 after setup failure") + + def _require_setup(self) -> None: + if not self._is_setup: + raise RuntimeError("BTX Gemini X2 is not set up. Call setup() first.") + + async def list_protocols(self) -> list[str]: + async with self._operation_lock: + self._require_setup() + return await self._file_transfer_control.list_protocols() + + async def request_protocol(self, protocol_name: str) -> Dict[str, Any]: + async with self._operation_lock: + self._require_setup() + return await self._file_transfer_control.request_protocol(protocol_name) + + async def add_protocol( + self, + protocol_name: str, + protocol: ElectroporationProtocol, + overwrite: bool = False, + ) -> Dict[str, Any]: + async with self._operation_lock: + self._require_setup() + 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]: + async with self._operation_lock: + self._require_setup() + 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]: + async with self._operation_lock: + self._require_setup() + return await self._file_transfer_control.list_log_files(root=root) + + async def fetch_sd_file(self, sd_path: str) -> str: + async with self._operation_lock: + self._require_setup() + return await self._file_transfer_control.fetch_sd_file(sd_path) + + async def request_version(self) -> str: + async with self._operation_lock: + self._require_setup() + return await self._file_transfer_control.request_version() + + async def request_serial_number(self) -> str: + async with self._operation_lock: + self._require_setup() + return await self._file_transfer_control.request_serial_number() + + async def request_device_time(self) -> str: + async with self._operation_lock: + self._require_setup() + return await self._file_transfer_control.request_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], Awaitable[GhostTouchResult]], + ) -> GhostTouchResult: + """Temporarily hand the device port to RSI control while holding the device lock.""" + self._require_setup() + port = self.port + if port is None: + raise RuntimeError("Gemini X2 serial port is not resolved. Call setup() first.") + + logger.info("Switching Gemini X2 from file-transfer control to touchscreen control") + try: + await self._file_transfer_control.stop() + except BaseException: + self._is_setup = False + raise + ghost_touch: _GhostTouchSession = _TheGhostTouch(port=port) + primary_error: BaseException | None = None + stop_error: Exception | None = None + restore_error: Exception | None = None + try: + await ghost_touch.setup() + result = await action(ghost_touch) + except BaseException as exc: + primary_error = exc + finally: + try: + await ghost_touch.stop() + except Exception as exc: + stop_error = exc + logger.exception("Failed to stop Gemini X2 touchscreen control") + try: + logger.info("Restoring Gemini X2 file-transfer control") + await self._file_transfer_control.setup() + except Exception as exc: + restore_error = exc + self._is_setup = False + logger.exception("Failed to restore Gemini X2 file-transfer control") + + if primary_error is not None: + raise primary_error.with_traceback(primary_error.__traceback__) + if restore_error is not None: + raise restore_error + if stop_error is not None: + raise stop_error + return result + + async def prepare_temporary_protocol( + self, + protocol: ElectroporationProtocol, + plate_columns: Optional[int] = None, + prefix: Optional[str] = None, + plate_handler_reset_state: PlateHandlerResetState = "unknown", + ) -> PreparedElectroporationRun: + """Create a temporary protocol and leave the Gemini armed on ``Run Protocol``.""" + async with self._operation_lock: + self._require_setup() + if plate_columns is not None and ( + isinstance(plate_columns, bool) or not 0 <= plate_columns <= 12 + ): + raise ValueError("plate_columns must be in the range 0..12.") + 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=plate_handler_reset_state, + ) + assumed_pulse_count, assumed_column_adjust = self._resolve_plate_handler_manual_state( + plate_columns=plate_columns + ) + await self._ensure_temporary_protocol_prefix_available(resolved_prefix) + + baseline_log_paths = tuple(await self._file_transfer_control.list_log_files()) + device_serial_number = await self._file_transfer_control.request_serial_number() + protocol_name = self._make_temporary_protocol_name(resolved_prefix) + logger.info( + "Preparing Gemini X2 temporary protocol %s (plate_columns=%s)", + protocol_name, + plate_columns, + ) + add_result = await self._file_transfer_control.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 BaseException: + await self._cleanup_temporary_protocol(protocol_name, missing_ok=True) + raise + + return PreparedElectroporationRun( + protocol_name=protocol_name, + device_serial_number=device_serial_number, + 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=rsi_result.prepared_verification.state, + protocol_setup=add_result, + device_prepare={ + "plate_handler_reset_state": resolved_reset_state, + "assumed_plate_handler_pulse_count": assumed_pulse_count, + "assumed_plate_handler_column_adjust": assumed_column_adjust, + **rsi_result.as_dict(), + }, + ), + ) + + 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.""" + async with self._operation_lock: + self._require_setup() + if max_run_seconds <= 0: + raise ValueError("max_run_seconds must be greater than zero.") + prepared = self._coerce_prepared_run(prepared_run) + await self._verify_prepared_run_identity(prepared, verify_protocol=True) + + logger.info("Starting prepared Gemini X2 run for protocol %s", prepared.protocol_name) + 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, + ) + ) + completed_at_utc = rsi_result.completed_at_utc + + 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) + + summary: Dict[str, Any] = {} + if log_capture.matched_log is not None: + parsed_summary = log_capture.matched_log.get("summary") + if isinstance(parsed_summary, Mapping): + summary = dict(parsed_summary) + return ElectroporationRunResult( + prepared_run=prepared, + started_at_utc=started_at_utc, + completed_at_utc=completed_at_utc, + rsi_result=ElectroporationExecutionDetails( + verification_state=rsi_result.verification.state, + completed_state=rsi_result.completed.state, + final_state=( + rsi_result.completed.state if rsi_result.home is None else rsi_result.home.state + ), + device_run=rsi_result.as_dict(), + ), + log_capture=ElectroporationLogCapture( + matched_log_path=log_capture.matched_log_path, + summary=summary, + details=log_capture.as_dict(), + ), + cleanup=cleanup.to_cleanup(), + ) + + async def cancel_prepared_run( + self, + prepared_run: Union[PreparedElectroporationRun, Mapping[str, Any]], + ) -> ElectroporationCancellationResult: + """Return the Gemini home and delete the prepared temporary protocol.""" + async with self._operation_lock: + self._require_setup() + prepared = self._coerce_prepared_run(prepared_run) + await self._verify_prepared_run_identity(prepared, verify_protocol=False) + + logger.info("Cancelling prepared Gemini X2 run for protocol %s", prepared.protocol_name) + rsi_result = await self._run_with_ghost_touch( + lambda ghost_touch: ghost_touch.cancel_prepared_user_protocol() + ) + 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=rsi_result.final_state.state, + device_cancel=rsi_result.as_dict(), + ), + cleanup=cleanup.to_cleanup(), + ) + + async def request_device_info(self) -> Dict[str, Any]: + """Return Gemini identity plus the supported electroporation workflow surface.""" + async with self._operation_lock: + self._require_setup() + version = await self._file_transfer_control.request_version() + serial_number = await self._file_transfer_control.request_serial_number() + device_time = await self._file_transfer_control.request_device_time() + protocols = await self._file_transfer_control.list_protocols() + return { + "device": self.__class__.__name__, + "model": "Gemini X2", + "port": self.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": self.plate_handler.get_device_info(), + "temporary_protocol_prefix": self._temporary_protocol_prefix, + } + + async def _verify_prepared_run_identity( + self, + prepared: PreparedElectroporationRun, + *, + verify_protocol: bool, + ) -> None: + current_serial_number = await self._file_transfer_control.request_serial_number() + if current_serial_number != prepared.device_serial_number: + raise RuntimeError( + "Prepared Gemini X2 run belongs to serial number " + f"{prepared.device_serial_number!r}, but the connected device is " + f"{current_serial_number!r}." + ) + if verify_protocol: + await self._file_transfer_control.verify_protocol(prepared.protocol_name, prepared.protocol) + + def _resolve_plate_handler_reset_state( + self, + *, + plate_columns: Optional[int], + plate_handler_reset_state: PlateHandlerResetState, + ) -> PlateHandlerResetState: + 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._file_transfer_control.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._file_transfer_control.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]: + anchor_key = self._temporary_protocol_sort_anchor(prefix).casefold() + return sorted( + (name for name in protocols if name.casefold() < anchor_key), + key=str.casefold, + ) + + def _temporary_protocol_prefix_collisions( + self, + protocols: list[str], + prefix: str, + ) -> list[str]: + anchor_key = self._temporary_protocol_sort_anchor(prefix).casefold() + return sorted( + (name for name in protocols if name.casefold().startswith(anchor_key)), + 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 _force_home_via_ghost_touch(self) -> None: + await self._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._file_transfer_control.delete_protocol( + protocol_name, + missing_ok=missing_ok, + ) + except ProtocolDeletionPendingError: + delete_retry_used = True + logger.warning( + "Gemini X2 protocol %s remained after deletion; returning the touchscreen home and retrying", + protocol_name, + ) + try: + await self._force_home_via_ghost_touch() + delete_result = await self._file_transfer_control.delete_protocol( + protocol_name, + missing_ok=missing_ok, + ) + except Exception as retry_exc: # pragma: no cover - hardware-specific recovery + delete_error = str(retry_exc) + logger.exception("Failed to delete Gemini X2 temporary protocol %s", protocol_name) + except Exception as exc: # pragma: no cover - hardware-specific recovery + delete_error = str(exc) + logger.exception("Failed to delete Gemini X2 temporary protocol %s", protocol_name) + + 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: + loop = asyncio.get_running_loop() + deadline = loop.time() + self.LOG_POLL_TIMEOUT_SECONDS + after_logs = set(before_logs) + new_logs: list[str] = [] + while True: + after_logs = set(await self._file_transfer_control.list_log_files()) + new_logs = sorted(after_logs - before_logs) + for log_path in new_logs: + text = await self._file_transfer_control.fetch_sd_file(log_path) + parsed = self.parse_run_log(text) + summary = parsed.get("summary") + if isinstance(summary, Mapping) and summary.get("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, + ) + if loop.time() >= deadline: + logger.warning( + "No new Gemini X2 run log matched protocol %s within %.1f seconds", + protocol_name, + self.LOG_POLL_TIMEOUT_SECONDS, + ) + return MatchedRunLogResult( + before_count=len(before_logs), + after_count=len(after_logs), + new_log_paths=tuple(new_logs), + matched_log_path=None, + matched_log=None, + ) + await asyncio.sleep(self.LOG_POLL_INTERVAL_SECONDS) + + def _make_temporary_protocol_name(self, prefix: str) -> str: + reserved_anchor = self._temporary_protocol_sort_anchor(prefix) + remaining_bytes = self.UI_PROTOCOL_NAME_BYTES - len(reserved_anchor.encode("ascii")) + if remaining_bytes < 6: + raise ValueError( + "Generated temp protocol name would exceed the " + f"{self.UI_PROTOCOL_NAME_BYTES}-byte Gemini UI limit. Shorten prefix={prefix!r}." + ) + return f"{reserved_anchor}{uuid.uuid4().hex[:remaining_bytes].upper()}" + + def _now_utc_iso(self) -> str: + return datetime.now(timezone.utc).isoformat() diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/gemini_x2_tests.py b/pylabrobot/thermo_fisher/btx/gemini/X2/gemini_x2_tests.py new file mode 100644 index 00000000000..ec245789fb9 --- /dev/null +++ b/pylabrobot/thermo_fisher/btx/gemini/X2/gemini_x2_tests.py @@ -0,0 +1,632 @@ +import asyncio +import unittest +from typing import Any, Dict, List, Optional, cast +from unittest.mock import AsyncMock, patch + +from pylabrobot.thermo_fisher.btx.gemini.X2.file_transfer_control import ( + ProtocolDeletionPendingError, + _FileTransferControl, +) +from pylabrobot.thermo_fisher.btx.gemini.X2.gemini_x2 import BTXGeminiX2 +from pylabrobot.thermo_fisher.btx.gemini.X2.ht200 import BTXHT200 +from pylabrobot.thermo_fisher.btx.gemini.X2.standard import ( + ElectroporationPreparationDetails, + ElectroporationProtocol, + PreparedElectroporationRun, +) +from pylabrobot.thermo_fisher.btx.gemini.X2.the_ghost_touch import ( + CancelledPreparedUserProtocolResult, + PreparedUserProtocolResult, + ScreenSnapshotResult, + StartedPreparedUserProtocolResult, +) + + +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"" + + +class _FakeFileTransferControl: + def __init__(self) -> None: + self.port = "/dev/fake-btx" + self.setup = AsyncMock() + self.stop = AsyncMock() + 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.verify_calls: List[tuple[str, ElectroporationProtocol]] = [] + self.delete_failures_before_success = 0 + self.verify_error: Optional[Exception] = None + 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 list_protocols(self) -> list[str]: + return list(self.protocols) + + async def request_protocol(self, protocol_name: str) -> Dict[str, Any]: + return {"operation": "request_protocol", "protocol": protocol_name} + + async def verify_protocol( + self, + protocol_name: str, + protocol: ElectroporationProtocol, + ) -> Dict[str, Any]: + self.verify_calls.append((protocol_name, protocol)) + if self.verify_error is not None: + raise self.verify_error + return {"operation": "verify_protocol", "protocol": protocol_name} + + 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 ProtocolDeletionPendingError( + 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 request_version(self) -> str: + return self.version + + async def request_serial_number(self) -> str: + return self.serial_number + + async def request_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", port: str) -> None: + self.factory = factory + self.port = port + + async def setup(self) -> None: + await self.factory.setup() + + async def stop(self) -> None: + await self.factory.stop() + + async def ensure_home(self) -> ScreenSnapshotResult: + self.factory.ensure_home_calls += 1 + return ScreenSnapshotResult(state="main_menu", image_path="home") + + async def prepare_user_protocol( + self, + protocol_name: str, + plate_columns: Optional[int] = None, + ) -> PreparedUserProtocolResult: + if self.factory.prepare_error is not None: + raise self.factory.prepare_error + self.factory.prepare_calls.append( + { + "protocol_name": protocol_name, + "plate_columns": plate_columns, + "port": self.port, + } + ) + run_view = ScreenSnapshotResult(state="protocol_run_view", image_path="run-view") + return PreparedUserProtocolResult( + protocol_name=protocol_name, + plate_columns=plate_columns, + run_view=run_view, + after_set_plate_columns=None, + prepared_verification=run_view, + ) + + async def start_prepared_user_protocol( + self, + protocol_name: str, + home_after: bool = True, + max_run_seconds: float = 420.0, + ) -> StartedPreparedUserProtocolResult: + if self.factory.start_waiter is not None: + await self.factory.start_waiter.wait() + if self.factory.start_error is not None: + raise self.factory.start_error + self.factory.start_calls.append( + { + "protocol_name": protocol_name, + "home_after": home_after, + "max_run_seconds": max_run_seconds, + "port": self.port, + } + ) + verification = ScreenSnapshotResult(state="protocol_run_view", image_path="verify") + completed = ScreenSnapshotResult(state="protocol_finish", image_path="done") + home = ScreenSnapshotResult(state="main_menu", image_path="home") if home_after else None + return StartedPreparedUserProtocolResult( + protocol_name=protocol_name, + verification=verification, + after_start=verification, + completed=completed, + completed_at_utc="2026-03-09T10:00:01+00:00", + home=home, + ) + + async def cancel_prepared_user_protocol(self) -> CancelledPreparedUserProtocolResult: + if self.factory.cancel_error is not None: + raise self.factory.cancel_error + self.factory.cancel_calls += 1 + return CancelledPreparedUserProtocolResult( + cancelled=True, + home_after=True, + final_state=ScreenSnapshotResult(state="main_menu", image_path="home"), + ) + + +class _FakeGhostTouchFactory: + def __init__(self) -> None: + self.created: List[str] = [] + self.prepare_calls: List[Dict[str, Any]] = [] + self.start_calls: List[Dict[str, Any]] = [] + self.cancel_calls = 0 + self.setup = AsyncMock() + self.stop = AsyncMock() + self.ensure_home_calls = 0 + self.prepare_error: Optional[Exception] = None + self.start_error: Optional[Exception] = None + self.cancel_error: Optional[Exception] = None + self.start_waiter: Optional[asyncio.Event] = None + + def __call__(self, port: str) -> _FakeGhostTouchSession: + self.created.append(port) + return _FakeGhostTouchSession(self, port) + + +def _protocol() -> ElectroporationProtocol: + return ElectroporationProtocol( + protocol_type="square", + pulse_amplitude_volts=250, + gap_mm=1.0, + duration_us=1000, + ) + + +def _prepared_run( + protocol_name: str = "!PLR_123456789", + serial_number: str = "1135421", +) -> PreparedElectroporationRun: + return PreparedElectroporationRun( + protocol_name=protocol_name, + device_serial_number=serial_number, + 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={}, + ), + ) + + +def _make_gemini( + file_transfer_control: _FakeFileTransferControl, + *, + plate_handler: Optional[BTXHT200] = None, + temporary_protocol_prefix: str = BTXGeminiX2.DEFAULT_TEMPORARY_PROTOCOL_PREFIX, +) -> BTXGeminiX2: + gemini = BTXGeminiX2( + plate_handler=plate_handler, + temporary_protocol_prefix=temporary_protocol_prefix, + ) + gemini._file_transfer_control = cast(_FileTransferControl, file_transfer_control) + return gemini + + +class TestBTXGeminiX2(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.ghost_factory = _FakeGhostTouchFactory() + patcher = patch( + "pylabrobot.thermo_fisher.btx.gemini.X2.gemini_x2._TheGhostTouch", + self.ghost_factory, + ) + patcher.start() + self.addCleanup(patcher.stop) + + 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"]] + gemini = _make_gemini( + file_control, + plate_handler=BTXHT200(assumed_pulse_count=2, assumed_column_adjust=0), + ) + protocol = ElectroporationProtocol( + protocol_type="exponential", + pulse_amplitude_volts=2300, + gap_mm=2.0, + resistance_ohms=200, + capacitance_uf=25, + ) + + await gemini.setup() + prepared = await gemini.prepare_temporary_protocol( + protocol, + plate_columns=3, + plate_handler_reset_state=gemini.PLATE_HANDLER_RESET_STATE_RESET_CONFIRMED, + ) + + self.assertTrue(prepared.protocol_name.startswith("!PLR_")) + self.assertEqual(len(prepared.protocol_name.encode("ascii")), 15) + self.assertEqual(prepared.device_serial_number, file_control.serial_number) + 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(self.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"], + gemini.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 + ) + self.ghost_factory.setup.assert_awaited_once_with() + self.ghost_factory.stop.assert_awaited_once_with() + + async def test_prepare_failure_cleans_up_and_restores_file_transfer(self): + file_control = _FakeFileTransferControl() + self.ghost_factory.prepare_error = RuntimeError("prepare failed") + gemini = _make_gemini(file_control) + await gemini.setup() + + with self.assertRaisesRegex(RuntimeError, "prepare failed"): + await gemini.prepare_temporary_protocol(_protocol()) + + self.assertEqual(len(file_control.delete_calls), 1) + self.assertTrue(file_control.delete_calls[0]["missing_ok"]) + self.assertEqual(file_control.setup.await_count, 2) + self.ghost_factory.stop.assert_awaited_once_with() + + async def test_start_verifies_identity_and_protocol_before_go(self): + file_control = _FakeFileTransferControl() + prepared = _prepared_run() + file_control.protocols.append(prepared.protocol_name) + log_path = r"\BTXDATA\2026-03\260309\100100.TXT" + file_control.log_snapshots = [[], [log_path]] + file_control.log_contents[log_path] = "\n".join( + [ + f"Protocol Name: {prepared.protocol_name}", + "Protocol Result: Complete", + "Status: 0x00000000.00000000 - No error.", + ] + ) + gemini = _make_gemini(file_control) + gemini.LOG_POLL_INTERVAL_SECONDS = 0 + await gemini.setup() + + result = await gemini.start_prepared_run(prepared.as_dict(), max_run_seconds=100.0) + + self.assertEqual(file_control.verify_calls, [(prepared.protocol_name, prepared.protocol)]) + self.assertEqual(self.ghost_factory.start_calls[0]["protocol_name"], prepared.protocol_name) + self.assertEqual(result.log_capture.matched_log_path, log_path) + self.assertEqual(result.completed_at_utc, "2026-03-09T10:00:01+00:00") + self.assertTrue(result.cleanup.deleted) + + async def test_start_rejects_a_different_connected_device_before_touch_control(self): + file_control = _FakeFileTransferControl() + gemini = _make_gemini(file_control) + await gemini.setup() + + with self.assertRaisesRegex(RuntimeError, "belongs to serial number 'different'"): + await gemini.start_prepared_run(_prepared_run(serial_number="different")) + + self.assertEqual(file_control.verify_calls, []) + self.assertEqual(self.ghost_factory.created, []) + + async def test_start_rejects_changed_stored_protocol_before_touch_control(self): + file_control = _FakeFileTransferControl() + file_control.verify_error = RuntimeError("stored protocol changed") + gemini = _make_gemini(file_control) + await gemini.setup() + + with self.assertRaisesRegex(RuntimeError, "stored protocol changed"): + await gemini.start_prepared_run(_prepared_run()) + + self.assertEqual(self.ghost_factory.created, []) + + async def test_start_rejects_nonpositive_run_timeout_before_go(self): + file_control = _FakeFileTransferControl() + gemini = _make_gemini(file_control) + await gemini.setup() + + with self.assertRaisesRegex(ValueError, "max_run_seconds"): + await gemini.start_prepared_run(_prepared_run(), max_run_seconds=0) + + self.assertEqual(self.ghost_factory.created, []) + + async def test_missing_delayed_log_is_a_structured_success_result(self): + file_control = _FakeFileTransferControl() + prepared = _prepared_run() + file_control.protocols.append(prepared.protocol_name) + gemini = _make_gemini(file_control) + gemini.LOG_POLL_TIMEOUT_SECONDS = 0 + await gemini.setup() + + result = await gemini.start_prepared_run(prepared) + + self.assertIsNone(result.log_capture.matched_log_path) + self.assertEqual(result.log_capture.summary, {}) + self.assertTrue(result.cleanup.deleted) + self.assertEqual(result.rsi_result.completed_state, "protocol_finish") + + async def test_start_verification_failure_leaves_protocol_for_explicit_cancel(self): + file_control = _FakeFileTransferControl() + prepared = _prepared_run() + file_control.protocols.append(prepared.protocol_name) + self.ghost_factory.start_error = RuntimeError("verification failed") + gemini = _make_gemini(file_control) + await gemini.setup() + + with self.assertRaisesRegex(RuntimeError, "verification failed"): + await gemini.start_prepared_run(prepared) + + self.assertEqual(file_control.delete_calls, []) + self.assertIn(prepared.protocol_name, file_control.protocols) + + async def test_cancellation_stops_touch_control_and_restores_file_transfer(self): + file_control = _FakeFileTransferControl() + self.ghost_factory.start_waiter = asyncio.Event() + gemini = _make_gemini(file_control) + await gemini.setup() + + task = asyncio.create_task(gemini.start_prepared_run(_prepared_run())) + while self.ghost_factory.setup.await_count == 0: + await asyncio.sleep(0) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + + self.ghost_factory.stop.assert_awaited_once_with() + self.assertEqual(file_control.setup.await_count, 2) + + async def test_primary_run_error_is_not_masked_by_touch_stop_error(self): + file_control = _FakeFileTransferControl() + self.ghost_factory.start_error = RuntimeError("primary run failure") + self.ghost_factory.stop.side_effect = RuntimeError("secondary stop failure") + gemini = _make_gemini(file_control) + await gemini.setup() + + with self.assertRaisesRegex(RuntimeError, "primary run failure"): + await gemini.start_prepared_run(_prepared_run()) + + self.assertEqual(file_control.setup.await_count, 2) + + async def test_restore_failure_marks_device_stopped_without_masking_primary_error(self): + file_control = _FakeFileTransferControl() + file_control.setup.side_effect = [None, RuntimeError("restore failed")] + self.ghost_factory.start_error = RuntimeError("primary run failure") + gemini = _make_gemini(file_control) + await gemini.setup() + + with self.assertRaisesRegex(RuntimeError, "primary run failure"): + await gemini.start_prepared_run(_prepared_run()) + + with self.assertRaisesRegex(RuntimeError, "is not set up"): + await gemini.list_protocols() + + async def test_cancel_always_homes_and_deletes(self): + file_control = _FakeFileTransferControl() + prepared = _prepared_run() + file_control.protocols.append(prepared.protocol_name) + gemini = _make_gemini(file_control) + await gemini.setup() + + result = await gemini.cancel_prepared_run(prepared.as_dict()) + + self.assertTrue(result.cleanup.deleted) + self.assertEqual(self.ghost_factory.cancel_calls, 1) + self.assertEqual(result.rsi_result.final_state, "main_menu") + self.assertNotIn(prepared.protocol_name, file_control.protocols) + + async def test_cancel_retries_typed_pending_delete_after_forcing_home(self): + file_control = _FakeFileTransferControl() + file_control.delete_failures_before_success = 1 + prepared = _prepared_run() + file_control.protocols.append(prepared.protocol_name) + gemini = _make_gemini(file_control) + await gemini.setup() + + result = await gemini.cancel_prepared_run(prepared) + + self.assertTrue(result.cleanup.retry_used) + self.assertEqual(self.ghost_factory.ensure_home_calls, 1) + self.assertEqual(len(file_control.delete_calls), 2) + + async def test_setup_validation_failure_closes_file_transfer(self): + file_control = _FakeFileTransferControl() + file_control.protocols = ["!AAA", "CD"] + gemini = _make_gemini(file_control) + + with self.assertRaisesRegex(RuntimeError, r"Temporary protocol prefix '!PLR' is not safe"): + await gemini.setup() + + file_control.setup.assert_awaited_once_with() + file_control.stop.assert_awaited_once_with() + + async def test_setup_and_stop_are_idempotent(self): + file_control = _FakeFileTransferControl() + gemini = _make_gemini(file_control) + + await gemini.setup() + await gemini.setup() + await gemini.stop() + await gemini.stop() + + file_control.setup.assert_awaited_once_with() + file_control.stop.assert_awaited_once_with() + + async def test_device_lock_serializes_public_operations(self): + file_control = _FakeFileTransferControl() + entered = asyncio.Event() + release = asyncio.Event() + + async def blocked_version() -> str: + entered.set() + await release.wait() + return file_control.version + + serial_number = AsyncMock(return_value=file_control.serial_number) + with ( + patch.object(file_control, "request_version", blocked_version), + patch.object(file_control, "request_serial_number", serial_number), + ): + gemini = _make_gemini(file_control) + await gemini.setup() + + version_task = asyncio.create_task(gemini.request_version()) + await entered.wait() + serial_task = asyncio.create_task(gemini.request_serial_number()) + await asyncio.sleep(0) + serial_number.assert_not_awaited() + release.set() + + self.assertEqual(await version_task, file_control.version) + self.assertEqual(await serial_task, file_control.serial_number) + + async def test_prepare_requires_explicit_plate_handler_reset_state(self): + file_control = _FakeFileTransferControl() + gemini = _make_gemini( + file_control, + plate_handler=BTXHT200(assumed_pulse_count=2, assumed_column_adjust=0), + ) + await gemini.setup() + + with self.assertRaisesRegex(ValueError, "requires an explicit plate_handler_reset_state"): + await gemini.prepare_temporary_protocol(_protocol(), plate_columns=3) + + async def test_prepare_requires_assumed_plate_handler_state(self): + file_control = _FakeFileTransferControl() + gemini = _make_gemini(file_control, plate_handler=BTXHT200()) + await gemini.setup() + + with self.assertRaisesRegex(ValueError, "Missing: assumed_pulse_count, assumed_column_adjust"): + await gemini.prepare_temporary_protocol( + _protocol(), + plate_columns=3, + plate_handler_reset_state=gemini.PLATE_HANDLER_RESET_STATE_RESET_CONFIRMED, + ) + + async def test_prepare_rejects_reset_state_without_columns(self): + file_control = _FakeFileTransferControl() + gemini = _make_gemini(file_control) + await gemini.setup() + + with self.assertRaisesRegex(ValueError, "only valid when plate_columns is set"): + await gemini.prepare_temporary_protocol( + _protocol(), + plate_handler_reset_state=gemini.PLATE_HANDLER_RESET_STATE_RESET_CONFIRMED, + ) + + async def test_prepare_validates_plate_columns_before_adding_protocol(self): + file_control = _FakeFileTransferControl() + gemini = _make_gemini(file_control) + await gemini.setup() + + with self.assertRaisesRegex(ValueError, "plate_columns must be in the range"): + await gemini.prepare_temporary_protocol( + _protocol(), + plate_columns=13, + plate_handler_reset_state=gemini.PLATE_HANDLER_RESET_STATE_RESET_CONFIRMED, + ) + + self.assertEqual(file_control.add_calls, []) + + async def test_prepare_rejects_existing_reserved_prefix(self): + file_control = _FakeFileTransferControl() + file_control.protocols = ["!PLR_OLD", "CD"] + gemini = _make_gemini(file_control) + await gemini.setup() + + with self.assertRaisesRegex(RuntimeError, r"not available.*!PLR_OLD"): + await gemini.prepare_temporary_protocol(_protocol()) + + async def test_request_device_info(self): + file_control = _FakeFileTransferControl() + gemini = _make_gemini( + file_control, + plate_handler=BTXHT200(assumed_pulse_count=2, assumed_column_adjust=1), + ) + await gemini.setup() + + info = await gemini.request_device_info() + + self.assertEqual(info["device"], "BTXGeminiX2") + self.assertEqual(info["serial_number"], "1135421") + self.assertEqual(info["protocol_count"], 2) + self.assertTrue(info["supports_serialized_prepared_runs"]) + self.assertEqual(info["plate_handler"]["model"], "HT-200") + self.assertNotIn("touch_control", info) + + async def test_file_transfer_methods_delegate_to_file_control(self): + file_control = _FakeFileTransferControl() + gemini = _make_gemini(file_control) + await gemini.setup() + + self.assertEqual(await gemini.list_protocols(), ["CD", "JJ"]) + self.assertEqual( + await gemini.request_protocol("CD"), + {"operation": "request_protocol", "protocol": "CD"}, + ) + + def test_temporary_names_are_unique_and_respect_ui_limit(self): + gemini = _make_gemini(_FakeFileTransferControl()) + + first = gemini._make_temporary_protocol_name("!PLR") + second = gemini._make_temporary_protocol_name("!PLR") + + self.assertNotEqual(first, second) + self.assertEqual(len(first.encode("ascii")), gemini.UI_PROTOCOL_NAME_BYTES) + with self.assertRaisesRegex(ValueError, "exceed the 15-byte"): + gemini._make_temporary_protocol_name("!PLR_TOO_LONG") diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/ht200.py b/pylabrobot/thermo_fisher/btx/gemini/X2/ht200.py new file mode 100644 index 00000000000..62b5305ecc6 --- /dev/null +++ b/pylabrobot/thermo_fisher/btx/gemini/X2/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/thermo_fisher/btx/gemini/X2/standard.py b/pylabrobot/thermo_fisher/btx/gemini/X2/standard.py new file mode 100644 index 00000000000..4927395e410 --- /dev/null +++ b/pylabrobot/thermo_fisher/btx/gemini/X2/standard.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import isfinite +from typing import Any, Dict, Literal, Mapping, Optional, cast + + +@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: Literal["square", "exponential"] + 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 __post_init__(self) -> None: + if self.protocol_type not in {"square", "exponential"}: + raise ValueError("protocol_type must be 'square' or 'exponential'.") + if self.pulse_count <= 0: + raise ValueError("pulse_count must be greater than zero.") + if self.pulse_amplitude_volts <= 0: + raise ValueError("pulse_amplitude_volts must be greater than zero.") + if not isfinite(self.gap_mm) or self.gap_mm <= 0: + raise ValueError("gap_mm must be a finite value greater than zero.") + if self.pulse_interval_seconds is not None and ( + not isfinite(self.pulse_interval_seconds) or self.pulse_interval_seconds < 0 + ): + raise ValueError("pulse_interval_seconds must be finite and non-negative.") + + if self.protocol_type == "square": + if self.duration_us is None: + raise ValueError("Square protocols require duration_us.") + if self.resistance_ohms is not None or self.capacitance_uf is not None: + raise ValueError("Square protocols cannot define resistance_ohms or capacitance_uf.") + elif self.duration_us is not None: + raise ValueError("Exponential protocols cannot define duration_us.") + elif self.resistance_ohms is None or self.capacitance_uf is None: + raise ValueError("Exponential protocols require resistance_ohms and capacitance_uf.") + + 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=cast(Literal["square", "exponential"], 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 + device_serial_number: 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, + "device_serial_number": self.device_serial_number, + "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"]), + device_serial_number=str(data["device_serial_number"]), + 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/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/00_main_menu.png b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/00_main_menu.png new file mode 100644 index 00000000000..d090cda6864 Binary files /dev/null and b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/00_main_menu.png differ diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/01_user_protocols_top.png b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/01_user_protocols_top.png new file mode 100644 index 00000000000..194e265a28c Binary files /dev/null and b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/01_user_protocols_top.png differ diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/02_protocol_summary.png b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/02_protocol_summary.png new file mode 100644 index 00000000000..1001888cb95 Binary files /dev/null and b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/02_protocol_summary.png differ diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/03_run_protocol_prerun.png b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/03_run_protocol_prerun.png new file mode 100644 index 00000000000..7362a51f821 Binary files /dev/null and b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/03_run_protocol_prerun.png differ diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/04_set_plate_columns_open.png b/pylabrobot/thermo_fisher/btx/gemini/X2/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/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/04_set_plate_columns_open.png differ diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/05_set_plate_columns_after_first_confirm.png b/pylabrobot/thermo_fisher/btx/gemini/X2/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/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/05_set_plate_columns_after_first_confirm.png differ diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/06_set_plate_columns_confirmed_run_view.png b/pylabrobot/thermo_fisher/btx/gemini/X2/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/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/06_set_plate_columns_confirmed_run_view.png differ diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/07_go_prerun.png b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/07_go_prerun.png new file mode 100644 index 00000000000..01553f0f7ca Binary files /dev/null and b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/07_go_prerun.png differ diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/08_go_delivering_pulse.png b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/08_go_delivering_pulse.png new file mode 100644 index 00000000000..977b034b940 Binary files /dev/null and b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/08_go_delivering_pulse.png differ diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/09_go_pulses_delivered.png b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/09_go_pulses_delivered.png new file mode 100644 index 00000000000..0d583fb6b84 Binary files /dev/null and b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/09_go_pulses_delivered.png differ diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/10_returned_home_after_go.png b/pylabrobot/thermo_fisher/btx/gemini/X2/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/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/10_returned_home_after_go.png differ diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/contact_sheet.png b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/contact_sheet.png new file mode 100644 index 00000000000..e58ff52f70a Binary files /dev/null and b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/contact_sheet.png differ diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/metadata.json b/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/metadata.json new file mode 100644 index 00000000000..96355131d06 --- /dev/null +++ b/pylabrobot/thermo_fisher/btx/gemini/X2/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/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/user_protocols_double_up_active.png b/pylabrobot/thermo_fisher/btx/gemini/X2/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/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/user_protocols_double_up_active.png differ diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/user_protocols_double_up_inactive.png b/pylabrobot/thermo_fisher/btx/gemini/X2/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/thermo_fisher/btx/gemini/X2/test_data/gemini_x2/screens/user_protocols_double_up_inactive.png differ diff --git a/pylabrobot/thermo_fisher/btx/gemini/X2/the_ghost_touch.py b/pylabrobot/thermo_fisher/btx/gemini/X2/the_ghost_touch.py new file mode 100644 index 00000000000..3f0d737610b --- /dev/null +++ b/pylabrobot/thermo_fisher/btx/gemini/X2/the_ghost_touch.py @@ -0,0 +1,1110 @@ +from __future__ import annotations + +import asyncio +import hashlib +import logging +import os +import re +import shutil +import subprocess +import tempfile +import time +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional, Protocol, cast + +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) + +from pylabrobot.io.serial import Serial + +logger = logging.getLogger(__name__) + +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 + completed_at_utc: str + 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(), + "completed_at_utc": self.completed_at_utc, + } + 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: + if down_ms < 0: + raise ValueError("down_ms must be non-negative.") + self.down_ms = down_ms + if artifact_dir is None: + artifact_dir = str(Path(tempfile.gettempdir()) / "pylabrobot-btx-gemini-x2") + self.artifact_dir = artifact_dir + self._transport = _RSITransport(port=port, baud=baud, timeout=timeout, retries=retries) + self._detector = _GeminiScreenDetector(min_conf=min_conf) + + @property + def port(self) -> str: + return self._transport.port + + @property + def min_conf(self) -> float: + return self._detector.min_conf + + async def setup(self) -> None: + """Set up the RSI serial session.""" + logger.info("Setting up Gemini X2 touchscreen control on port %s", self.port) + self._require_dependencies() + await self._transport.setup() + logger.info("Gemini X2 touchscreen control ready on port %s", self.port) + + async def stop(self) -> None: + """Stop the RSI serial session.""" + logger.info("Stopping Gemini X2 touchscreen control on port %s", self.port) + await self._transport.stop() + logger.info("Gemini X2 touchscreen control stopped") + + def _require_dependencies(self) -> None: + if not _HAS_NUMPY: + raise RuntimeError( + "numpy is required for Gemini X2 touchscreen handling. Install with: pip install pylabrobot[btx]. " + f"Import error: {_NUMPY_IMPORT_ERROR}" + ) + if not _HAS_PIL: + raise RuntimeError( + "Pillow is required for Gemini X2 touchscreen handling. Install with: pip install pylabrobot[btx]. " + f"Import error: {_PIL_IMPORT_ERROR}" + ) + if shutil.which("tesseract") is None: + raise RuntimeError( + "Gemini X2 touchscreen control 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." + ) + + async 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.""" + logger.info("Arming Gemini X2 protocol %s (plate_columns=%s)", protocol_name, plate_columns) + run_view = await self.goto_user_protocol_run_view(protocol_name) + after_set_plate_columns: ScreenSnapshotResult | None = None + if plate_columns is not None: + after_columns = await self.set_plate_columns(plate_columns) + after_set_plate_columns = self._snapshot_result(after_columns) + + verified = await 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), + ) + + async 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.""" + if max_run_seconds <= 0: + raise ValueError("max_run_seconds must be greater than zero.") + logger.info("Starting Gemini X2 electroporation protocol %s", protocol_name) + verified = await self.verify_prepared_user_protocol(protocol_name) + start = await self.start_run() + done = await self.wait_run_done(max_seconds=max_run_seconds) + completed_at_utc = datetime.now(timezone.utc).isoformat() + home = None if not home_after else await 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), + completed_at_utc=completed_at_utc, + home=None if home is None else self._snapshot_result(home), + ) + + async def cancel_prepared_user_protocol(self) -> CancelledPreparedUserProtocolResult: + """Leave the prepared UI state without starting electroporation.""" + logger.info("Cancelling prepared Gemini X2 touchscreen run") + home = await self.ensure_home() + return CancelledPreparedUserProtocolResult( + cancelled=True, + home_after=True, + final_state=self._snapshot_result(home), + ) + + async def ensure_home(self) -> Snapshot: + """Return the Gemini UI to ``Main Menu`` using the fixed Home control.""" + current = await 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 = await 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 = await 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.") + + async 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 = await 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.") + + async def goto_user_protocol_run_view(self, protocol_name: str) -> Snapshot: + """Open the first sorted user protocol and reach its ``Run Protocol`` screen.""" + current = await self.snapshot("goto-user-run-start") + if current.detection.state == STATE_PROTOCOL_RUN_VIEW: + if await 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 = await self.ensure_home() + if current.detection.state != STATE_MAIN_MENU: + raise RuntimeError(f"Expected Main Menu, got {current.detection.state}.") + + try: + current = await self._open_user_protocols(attempt) + current = await self._select_first_user_protocol(attempt) + current = await self._confirm_user_protocol_summary(current, protocol_name, attempt) + await self._verify_run_view_protocol(current, protocol_name) + except RuntimeError as exc: + last_error = str(exc) + current = await self.ensure_home() + await asyncio.sleep(1.0) + continue + return current + + raise RuntimeError(f"Failed to reach Run Protocol for '{protocol_name}': {last_error}") + + async def set_plate_columns(self, columns: int) -> Snapshot: + """Open ``Set Plate Columns`` and confirm the requested HT-200 column count.""" + if isinstance(columns, bool) or not 0 <= columns <= 12: + raise ValueError("plate_columns must be in the range 0..12.") + + current = await 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 = await 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.") + + await self._enter_set_columns_value(columns) + closed = await 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 = await 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.") + + async 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 = await 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}." + await asyncio.sleep(0.35) + continue + + protocol_match = await self._run_view_matches_protocol(snap.image_path, protocol_name) + if protocol_match is False: + header = (await asyncio.to_thread(self._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." + await asyncio.sleep(0.35) + continue + + if not self._detector.looks_prerun(snap.detection): + last_reason = "Run screen is not in the pre-run state." + await asyncio.sleep(0.35) + continue + + return snap + + raise RuntimeError(f"Prepared run verification failed for '{protocol_name}': {last_reason}") + + async def start_run(self) -> Snapshot: + """Press ``GO`` from the prepared run screen and wait for visible run start feedback.""" + before = await self.snapshot("run-start-before-go") + if not self._detector.looks_prerun(before.detection): + raise RuntimeError( + f"Expected a verified pre-run Run Protocol view before GO, got {before.detection.state}." + ) + + await self.tap(GO_COORD[0], GO_COORD[1], down_ms=90) + after = await self._wait_for_run_transition( + timeout=8.0, + interval=0.45, + prefix="run-start-after-go", + ) + if after is None: + raise RuntimeError("No visible response after GO.") + if self._detector.is_run_done(after.detection): + return after + + if self._detector.has_confirm_dialog(after.detection): + await self.tap(GO_COORD[0], GO_COORD[1], down_ms=90) + after_confirm = await self._wait_for_run_transition( + timeout=8.0, + interval=0.45, + prefix="run-start-after-confirm", + ) + if ( + after_confirm is not None + and not self._detector.has_confirm_dialog(after_confirm.detection) + and not self._detector.looks_prerun(after_confirm.detection) + ): + return after_confirm + raise RuntimeError("The Gemini did not leave its confirmation/pre-run screen after GO.") + + if self._detector.looks_prerun(after.detection): + raise RuntimeError( + "The Gemini remained on the pre-run screen after GO; refusing to tap again." + ) + return after + + async def _wait_for_run_transition( + self, + *, + timeout: float, + interval: float, + prefix: str, + ) -> Snapshot | None: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + idx = 0 + while loop.time() < deadline: + snap = await self.snapshot(f"{prefix}-{idx:02d}") + detection = snap.detection + if ( + self._detector.is_run_done(detection) + or self._detector.has_confirm_dialog(detection) + or ( + detection.state == STATE_PROTOCOL_RUN_VIEW + and not self._detector.looks_prerun(detection) + and detection.confidence >= self.min_conf + ) + ): + return snap + idx += 1 + await asyncio.sleep(interval) + return None + + async def wait_run_done(self, max_seconds: float) -> Snapshot: + """Poll the RSI screen until the run has finished.""" + if max_seconds <= 0: + raise ValueError("max_seconds must be greater than zero.") + loop = asyncio.get_running_loop() + deadline = loop.time() + max_seconds + idx = 0 + while loop.time() < deadline: + # Use one frame attempt per poll. Retried requests can accumulate during pulse delivery. + snap = await self._snapshot_run_poll(f"run-wait-{idx:02d}") + if self._detector.is_run_done(snap.detection): + return snap + idx += 1 + await asyncio.sleep(0.7) + raise TimeoutError(f"Timed out waiting for run completion after {max_seconds} seconds.") + + async def read_frame(self) -> FrameCapture: + """Read one full RGB frame from the RSI ``scap`` stream.""" + return await self._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')}-{uuid.uuid4().hex[:8]}.png", + ) + Image.fromarray(frame.rgba, mode="RGBA").save(path) + return path + + async def snapshot(self, prefix: str) -> Snapshot: + """Capture a frame, save it, OCR it, and classify the current screen state.""" + return await self._snapshot_from_frame(prefix=prefix, frame=await self.read_frame()) + + async def _snapshot_run_poll(self, prefix: str) -> Snapshot: + """Capture one run-state frame without retrying the framebuffer request.""" + frame = await self._transport.read_frame(retry=False) + return await self._snapshot_from_frame(prefix=prefix, frame=frame) + + async def _snapshot_from_frame(self, prefix: str, frame: FrameCapture) -> Snapshot: + """Save and classify a captured framebuffer.""" + image_path = await asyncio.to_thread(self._save_frame, frame, prefix) + detection = await asyncio.to_thread(self._detector.classify_image, image_path) + return Snapshot(frame=frame, image_path=image_path, detection=detection) + + async 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 + await self._transport.tap(x, y, hold_ms=hold) + + async 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.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + idx = 0 + if initial_delay > 0: + await asyncio.sleep(initial_delay) + while loop.time() < deadline: + snap = await self.snapshot(f"{prefix}-{idx:02d}") + if snap.detection.state in states and ( + snap.detection.state == STATE_UNKNOWN or snap.detection.confidence >= self.min_conf + ): + return snap + idx += 1 + await asyncio.sleep(interval) + return None + + async 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.""" + await self.tap(x, y, down_ms=down_ms) + return await self.wait_for_states( + expected_states, + timeout=timeout, + interval=interval, + prefix=prefix, + initial_delay=initial_delay, + ) + + async def _summary_matches_protocol(self, image_path: str, protocol_name: str) -> bool | None: + return await asyncio.to_thread( + self._detector.summary_matches_protocol, image_path, protocol_name + ) + + async def _run_view_matches_protocol(self, image_path: str, protocol_name: str) -> bool | None: + return await asyncio.to_thread( + self._detector.run_view_matches_protocol, image_path, protocol_name + ) + + async 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 await asyncio.to_thread(self._detector.user_protocols_at_top, current): + return current + + for attempt in range(8): + next_snapshot = await 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 await asyncio.to_thread(self._detector.user_protocols_at_top, current): + return current + + raise RuntimeError("Failed to reach the top of User Protocols.") + + async def _open_user_protocols(self, attempt: int) -> Snapshot: + current = await 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 await self._scroll_user_protocols_to_top(current) + + async def _select_first_user_protocol(self, attempt: int) -> Snapshot: + await self.tap( + USER_PROTOCOLS_FIRST_ROW_COORD[0], + USER_PROTOCOLS_FIRST_ROW_COORD[1], + down_ms=max(self.down_ms, 80), + ) + await asyncio.sleep(1.0) + current = await self.snapshot(f"goto-user-first-row-selected-{attempt}") + detector = self._detector + if current.detection.state == STATE_USER_PROTOCOLS: + await self.tap( + DETAIL_CONFIRM_COORD[0], + DETAIL_CONFIRM_COORD[1], + down_ms=max(self.down_ms, 80), + ) + await asyncio.sleep(1.0) + current = await 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) + ): + await asyncio.sleep(0.45) + current = await 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) + ): + await asyncio.sleep(0.45) + current = await self.snapshot(f"goto-user-summary-{attempt}-01") + return current + + async def _confirm_user_protocol_summary( + self, + current: Snapshot, + protocol_name: str, + attempt: int, + ) -> Snapshot: + detector = self._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 = await 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 = await 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 + + async def _verify_run_view_protocol(self, current: Snapshot, protocol_name: str) -> None: + protocol_match = await self._run_view_matches_protocol(current.image_path, protocol_name) + if protocol_match is False: + header = (await asyncio.to_thread(self._detector.run_header_text, current.image_path)).strip() + raise RuntimeError( + f"Run header does not match target protocol '{protocol_name}'. header='{header}'" + ) + + async 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] + await self.tap(x, y, down_ms=max(self.down_ms, 70)) + await asyncio.sleep(pause_s) + + async def _enter_set_columns_value(self, columns: int) -> None: + for _ in range(4): + await self._tap_set_columns_key("delete") + for digit in str(columns): + await self._tap_set_columns_key(digit) + await asyncio.sleep(0.04) + + def _snapshot_result(self, snap: Snapshot) -> ScreenSnapshotResult: + return ScreenSnapshotResult( + state=snap.detection.state, + image_path=snap.image_path, + ) + + +class _AsyncSerialLike(Protocol): + @property + def port(self) -> str: + pass + + 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: + if timeout <= 0: + raise ValueError("timeout must be greater than zero.") + if retries <= 0: + raise ValueError("retries must be greater than zero.") + self.timeout = timeout + self.retries = retries + self._serial = ( + serial_io + if serial_io is not None + else Serial( + human_readable_device_name="BTX Gemini X2 touchscreen control", + port=port, + baudrate=baud, + timeout=0.05, + ) + ) + self._is_setup = False + + @property + def port(self) -> str: + return self._serial.port + + async def setup(self) -> None: + if self._is_setup: + return + try: + await self._serial.setup() + except Exception: + try: + await self._serial.stop() + except Exception: + logger.debug("Failed to close Gemini RSI serial after setup failure", exc_info=True) + raise + self._is_setup = True + + async def stop(self) -> None: + if not self._is_setup: + return + try: + await self._serial.stop() + finally: + self._is_setup = False + + def ensure_open(self) -> _AsyncSerialLike: + if not self._is_setup: + raise RuntimeError("Gemini X2 touchscreen serial session is not open.") + return self._serial + + async def reset_input_buffer(self) -> None: + await self.ensure_open().reset_input_buffer() + + async def write_line(self, line: str) -> None: + await self.ensure_open().write(line.encode("ascii") + b"\r") + + async def _read_frame_once(self) -> FrameCapture: + self.ensure_open() + await self.reset_input_buffer() + await self.write_line("echo off") + await asyncio.sleep(0.03) + await self.reset_input_buffer() + await self.write_line("scap") + + buf = bytearray() + loop = asyncio.get_running_loop() + deadline = loop.time() + self.timeout + while loop.time() < deadline: + chunk = await self.ensure_open().read(self.READ_CHUNK_BYTES) + if chunk: + buf.extend(chunk) + else: + await asyncio.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") + + async def read_frame(self, *, retry: bool = True) -> FrameCapture: + attempts = self.retries if retry else 1 + for attempt in range(attempts): + try: + return await self._read_frame_once() + except Exception: # pragma: no cover - live hardware path + if attempt == attempts - 1: + raise + await self.reset_input_buffer() + await asyncio.sleep(0.06) + raise RuntimeError("Unreachable RSI retry state.") # pragma: no cover + + async def tap(self, x: int, y: int, hold_ms: int) -> None: + await self.write_line(f"@key {x} {y}") + await asyncio.sleep(hold_ms / 1000.0) + await self.write_line("@key") + + +class _GeminiScreenDetector: + """OCR and state classification for Gemini RSI screenshots.""" + + def __init__(self, min_conf: float, ocr_timeout: float = 10.0) -> None: + if not 0 <= min_conf <= 1: + raise ValueError("min_conf must be in the range 0..1.") + if ocr_timeout <= 0: + raise ValueError("ocr_timeout must be greater than zero.") + self.min_conf = min_conf + self.ocr_timeout = ocr_timeout + + 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, + timeout=self.ocr_timeout, + ) + except subprocess.TimeoutExpired: + logger.warning("Tesseract timed out after %.1f seconds for %s", self.ocr_timeout, image_path) + return "" + except subprocess.CalledProcessError as exc: + logger.warning("Tesseract failed for %s with exit code %s", image_path, exc.returncode) + return "" + except OSError as exc: + logger.warning("Could not run Tesseract for %s: %s", image_path, exc) + 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") + # Tesseract commonly reads the leading `!` in PLR's temporary protocol names as `I`. + lowered = re.sub(r"(? bool: + marker_norm = self.normalize_text(marker) + if not marker_norm: + return False + normalized = self.normalize_text(text_norm) + marker_pattern = r"\s*".join(re.escape(part) for part in marker_norm.split()) + return re.search(rf"(? bool | None: + header_norm = self.normalize_text(header_text) + target_norm = self.normalize_text(protocol_name) + if not header_norm or not target_norm: + return None + target_parts = target_norm.split() + target_pattern = r"\s*".join(re.escape(part) for part in target_parts) + return ( + re.search( + rf"(? 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 + or (detection.state == STATE_PROTOCOL_RUN_VIEW and detection.matched == ["run protocol"]) + ): + 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: + return self.protocol_name_matches(self.summary_header_text(image_path), protocol_name) + + def run_view_matches_protocol(self, image_path: str, protocol_name: str) -> bool | None: + return self.protocol_name_matches(self.run_header_text(image_path), protocol_name) + + 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 ( + self.contains_marker(detection.text_norm, "are you sure") + or self.contains_marker(detection.text_norm, "confirm") + or ( + self.contains_marker(detection.text_norm, "yes") + and self.contains_marker(detection.text_norm, "no") + ) + ) + + def looks_prerun(self, detection: Detection) -> bool: + if detection.state != STATE_PROTOCOL_RUN_VIEW: + return False + return ( + 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/thermo_fisher/btx/gemini/X2/the_ghost_touch_tests.py b/pylabrobot/thermo_fisher/btx/gemini/X2/the_ghost_touch_tests.py new file mode 100644 index 00000000000..a2e1a36b0f7 --- /dev/null +++ b/pylabrobot/thermo_fisher/btx/gemini/X2/the_ghost_touch_tests.py @@ -0,0 +1,523 @@ +import json +import shutil +import subprocess +import unittest +from pathlib import Path +from typing import Optional, cast +from unittest.mock import AsyncMock, patch + +import pytest + +pytest.importorskip("numpy") +pytest.importorskip("PIL") +pytest.importorskip("serial") + +from pylabrobot.thermo_fisher.btx.gemini.X2.the_ghost_touch import ( + FRAME_BYTES, + FRAME_H, + FRAME_W, + STATE_MAIN_MENU, + STATE_PROTOCOL_DETAILS, + STATE_PROTOCOL_FINISH, + STATE_PROTOCOL_RUN_VIEW, + STATE_UNKNOWN, + STATE_USER_PROTOCOLS, + Detection, + FrameCapture, + Snapshot, + _decode_rsi_framebuffer, + _GeminiScreenDetector, + _RSITransport, + _TheGhostTouch, +) + +SCREEN_FIXTURES = Path(__file__).parent / "test_data/gemini_x2/screens" + + +class _FakeAsyncSerial: + def __init__(self, reads: Optional[list[bytes]] = None, port: str = "/dev/test"): + self.port = port + self.reads: list[bytes] = list(reads or []) + self.writes: list[bytes] = [] + self.setup = AsyncMock() + self.stop = AsyncMock() + self.reset_calls = 0 + + 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: + super().__init__(port="/dev/test", artifact_dir="/tmp", retries=1) + 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) + ) + + async def snapshot(self, prefix: str) -> Snapshot: + del prefix + if not self._snapshots: + raise AssertionError("No queued snapshots left") + return self._snapshots.pop(0) + + async def _snapshot_run_poll(self, prefix: str) -> Snapshot: + return await self.snapshot(prefix) + + async def tap(self, x: int, y: int, down_ms=None) -> None: + self.taps.append((x, y, down_ms)) + + async 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 await self.snapshot("tap-and-wait") + + async def _scroll_user_protocols_to_top(self, current: Snapshot) -> Snapshot: + return current + + async def _summary_matches_protocol(self, image_path: str, protocol_name: str): + del image_path, protocol_name + return True + + async def _run_view_matches_protocol(self, image_path: str, protocol_name: str): + del image_path, protocol_name + return True + + +class TestTheGhostTouch(unittest.IsolatedAsyncioTestCase): + 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.thermo_fisher.btx.gemini.X2.the_ghost_touch.shutil.which", + return_value=None, + ): + with self.assertRaisesRegex(RuntimeError, "external `tesseract` command"): + touch._require_dependencies() + + def test_constructor_rejects_invalid_retry_and_confidence_settings(self): + with self.assertRaisesRegex(ValueError, "retries"): + _TheGhostTouch(port="/dev/test", retries=0) + with self.assertRaisesRegex(ValueError, "min_conf"): + _TheGhostTouch(port="/dev/test", min_conf=1.1) + + def test_ocr_timeout_and_process_failures_return_empty_text(self): + detector = _GeminiScreenDetector(min_conf=0.70, ocr_timeout=0.01) + + with patch( + "pylabrobot.thermo_fisher.btx.gemini.X2.the_ghost_touch.subprocess.check_output", + side_effect=subprocess.TimeoutExpired("tesseract", 0.01), + ): + with self.assertLogs( + "pylabrobot.thermo_fisher.btx.gemini.X2.the_ghost_touch", level="WARNING" + ): + self.assertEqual(detector.ocr_text("missing.png", psm=6), "") + + def test_marker_and_protocol_matching_require_token_boundaries(self): + detector = _GeminiScreenDetector(min_conf=0.70) + + self.assertFalse(detector.contains_marker("number of columns", "no")) + self.assertTrue(detector.contains_marker("answer yes or no", "no")) + self.assertTrue(detector.contains_marker("the mainmenu screen", "main menu")) + self.assertTrue(detector.protocol_name_matches("Run !PLR_123", "!PLR_123")) + self.assertTrue(detector.protocol_name_matches("Run IPLR_123", "!PLR_123")) + self.assertFalse(detector.protocol_name_matches("Run !PLR_1234", "!PLR_123")) + + def test_confirm_dialog_does_not_match_unrelated_no_substrings(self): + detector = _GeminiScreenDetector(min_conf=0.70) + detection = detector.detect_state("Run Protocol number of columns GO Set Meas") + + self.assertFalse(detector.has_confirm_dialog(detection)) + + def test_fixture_metadata_markers_classify_without_tesseract(self): + detector = _GeminiScreenDetector(min_conf=0.70) + metadata = json.loads((SCREEN_FIXTURES / "metadata.json").read_text()) + + for screen in metadata["screens"]: + text = " ".join(screen["matched"]) or "electroporation method summary" + with self.subTest(image=screen["image"]): + self.assertEqual(detector.detect_state(text).state, screen["state"]) + + 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) + + async 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, + ) + + await transport.setup() + try: + frame = await transport.read_frame() + finally: + await transport.stop() + + fake.setup.assert_awaited_once_with() + fake.stop.assert_awaited_once_with() + 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]) + + async def test_rsi_transport_can_disable_frame_retries(self): + transport = _RSITransport( + port="/dev/test", + baud=115200, + timeout=0.2, + retries=5, + serial_io=_FakeAsyncSerial(), + ) + + with patch.object( + transport, + "_read_frame_once", + AsyncMock(side_effect=TimeoutError("frame unavailable")), + ) as read_once: + with self.assertRaisesRegex(TimeoutError, "frame unavailable"): + await transport.read_frame(retry=False) + + read_once.assert_awaited_once_with() + + async def test_rsi_transport_cleans_up_a_partial_setup_failure(self): + fake = _FakeAsyncSerial() + fake.setup.side_effect = RuntimeError("open failed") + transport = _RSITransport( + port="/dev/test", + baud=115200, + timeout=0.2, + retries=1, + serial_io=fake, + ) + + with self.assertRaisesRegex(RuntimeError, "open failed"): + await transport.setup() + + fake.stop.assert_awaited_once_with() + + async def test_wait_for_states_can_explicitly_accept_unknown(self): + touch = _TestGhostTouch() + touch.queue_snapshot(STATE_UNKNOWN, image_path="unknown") + + result = await touch.wait_for_states({STATE_UNKNOWN}, timeout=0.1, interval=0, prefix="unknown") + + self.assertIsNotNone(result) + assert result is not None + self.assertEqual(result.image_path, "unknown") + + async def test_start_run_refuses_a_blind_second_go_tap(self): + touch = _TestGhostTouch() + touch.queue_snapshot( + STATE_PROTOCOL_RUN_VIEW, + text="Run Protocol GO Set Meas", + text_norm="run protocol go set meas", + ) + still_prerun = Snapshot( + frame=cast(FrameCapture, None), + image_path="still-prerun", + detection=Detection( + state=STATE_PROTOCOL_RUN_VIEW, + confidence=1.0, + matched=[], + text="Run Protocol GO Set Meas", + text_norm="run protocol go set meas", + ), + ) + + with patch.object(touch, "_wait_for_run_transition", AsyncMock(return_value=still_prerun)): + with self.assertRaisesRegex(RuntimeError, "refusing to tap again"): + await touch.start_run() + + self.assertEqual(len(touch.taps), 1) + + 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) + + async 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 = await 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) + + async 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" + ) + + with patch.object( + touch, + "_snapshot_run_poll", + wraps=touch._snapshot_run_poll, + ) as run_poll: + result = await 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) + run_poll.assert_awaited_once_with("run-wait-00") + + async 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 = await 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)) + + async 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 = await 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)) + + async 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 = await touch.cancel_prepared_user_protocol() + + self.assertTrue(result.cancelled) + self.assertEqual(result.final_state.image_path, "home") diff --git a/pylabrobot/thermo_fisher/btx/gemini/__init__.py b/pylabrobot/thermo_fisher/btx/gemini/__init__.py new file mode 100644 index 00000000000..2881e4058cd --- /dev/null +++ b/pylabrobot/thermo_fisher/btx/gemini/__init__.py @@ -0,0 +1,4 @@ +from .X2 import ( + BTXHT200, + BTXGeminiX2, +) diff --git a/pyproject.toml b/pyproject.toml index 54a40e54b60..de14549cd74 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.1"] 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,opentrons,sila,pico,xarm]"] +all = ["PyLabRobot[serial,usb,ftdi,hid,btx,modbus,websockets,visualizer,opentrons,sila,pico,xarm]"] test = [ "pytest", ]