diff --git a/docs/_static/devices.json b/docs/_static/devices.json index aa40beaa198..72fc7f00f3b 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -216,6 +216,23 @@ "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://www.azenta.com/products/automated-plate-seal-remover-formerly-xpeel" }, + { + "id": "azure-biosystems-cielo-6", + "vendor": "Azure BioSystems", + "name": "Cielo 6", + "kind": "qPCR machine", + "capabilities": [ + "qPCR", + "thermocycling", + "fluorescence" + ], + "status": "mostly", + "api": "pylabrobot.azure_biosystems.Cielo6", + "api_version": "v1", + "code_slug": "azure_biosystems", + "doc_slug": "azure_biosystems/cielo6/hello-world", + "notes": "USB identity, status, workspace operations, program retrieval, qPCR execution, live progress, and verified result transfer were tested on hardware. Pause, resume, and program deletion are implemented from the vendor USB API but still need hardware verification." + }, { "id": "beckman-cytoflex-s", "vendor": "Beckman Coulter", diff --git a/docs/api/pylabrobot.azure_biosystems.rst b/docs/api/pylabrobot.azure_biosystems.rst new file mode 100644 index 00000000000..073b0803702 --- /dev/null +++ b/docs/api/pylabrobot.azure_biosystems.rst @@ -0,0 +1,32 @@ +.. currentmodule:: pylabrobot.azure_biosystems + +pylabrobot.azure_biosystems package +=================================== + +.. currentmodule:: pylabrobot.azure_biosystems.cielo6 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Cielo6 + Cielo6AmplificationResult + Cielo6CollectionPoint + Cielo6Error + Cielo6ExperimentInfo + Cielo6FirmwareStateError + Cielo6Identity + Cielo6MeltingData + Cielo6MeltingCurveResult + Cielo6MeltRecord + Cielo6ResultFile + Cielo6RunTimeoutError + Cielo6RunState + Cielo6RunningData + Cielo6StoredProgram + Cielo6StoredProgramStep + Cielo6Status + Cielo6ThermalProtocol + Cielo6ThermalStep + Cielo6WorkState diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index 9a842454106..22b81a68598 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -21,6 +21,7 @@ Manufacturers :maxdepth: 1 pylabrobot.agilent + pylabrobot.azure_biosystems pylabrobot.azenta pylabrobot.big_bear pylabrobot.brooks diff --git a/docs/user_guide/azure_biosystems/cielo6/hello-world.ipynb b/docs/user_guide/azure_biosystems/cielo6/hello-world.ipynb new file mode 100644 index 00000000000..2b4945355c8 --- /dev/null +++ b/docs/user_guide/azure_biosystems/cielo6/hello-world.ipynb @@ -0,0 +1,404 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cielo-introduction", + "metadata": {}, + "source": [ + "# Azure BioSystems Cielo 6\n", + "\n", + "The Azure BioSystems Cielo 6 is a six-channel real-time PCR instrument. PyLabRobot communicates directly with its firmware through a USB serial connection.\n", + "\n", + "```{device-card} azure-biosystems-cielo-6\n", + "```\n", + "\n", + "The backend supports instrument identity and state, live run progress, workspace and stored-program discovery, thermal protocol execution, and verified `.AZE` result download. Results include raw and processed amplification measurements and, when present, melting-curve measurements." + ] + }, + { + "cell_type": "markdown", + "id": "before-starting", + "metadata": {}, + "source": [ + "## Before starting\n", + "\n", + "Connect the Cielo 6 to the computer by USB and identify its serial port. The instrument uses a generic FTDI USB identifier, so you must select the port explicitly. Typical port names are `COM3` on Windows, `/dev/ttyUSB0` on Linux, and `/dev/cu.usbserial-XXXXXXXX` on macOS.\n", + "\n", + "The setup, status, discovery, and result-download sections are read-only. The **Run a protocol** section starts a physical run and heats the block. Interrupting Python or setting a PLR timeout does not stop a physical run; use `stop_run()` when you intend to stop the instrument. `pause_run()` and `resume_run()` change an active run. `delete_program()` permanently deletes a stored program." + ] + }, + { + "cell_type": "markdown", + "id": "setup-heading", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Create the device, open the serial connection, and verify the identity returned by the firmware." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "setup-device", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.azure_biosystems import Cielo6\n", + "\n", + "cielo = Cielo6(port=\"/dev/cu.usbserial-XXXXXXXX\") # replace with your port\n", + "await cielo.setup()\n", + "cielo.identity" + ] + }, + { + "cell_type": "markdown", + "id": "status-heading", + "metadata": {}, + "source": [ + "## Read instrument state\n", + "\n", + "Request the current firmware state and temperatures. This request does not change the instrument." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "read-status", + "metadata": {}, + "outputs": [], + "source": [ + "status = await cielo.request_status()\n", + "{\n", + " \"state\": status.work_state.name,\n", + " \"block_temperatures\": status.block_temperatures,\n", + " \"hot_lid_temperature\": status.hot_lid_temperature,\n", + " \"progress\": status.progress,\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "storage-heading", + "metadata": {}, + "source": [ + "## Discover stored programs and results\n", + "\n", + "List each workspace and its stored programs. A stored program supplies the instrument-specific optical channels, exposure settings, and lid settings needed to compile a PLR thermal protocol." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "list-workspaces", + "metadata": {}, + "outputs": [], + "source": [ + "workspaces = await cielo.request_workspace_summary()\n", + "workspaces" + ] + }, + { + "cell_type": "markdown", + "id": "retrieve-template", + "metadata": {}, + "source": [ + "Choose a stored qPCR program that uses the optical channels and lid settings required by your assay. Retrieving it validates every transport frame and the program CRC32. Replace the names below with values from `workspaces`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "request-template", + "metadata": {}, + "outputs": [], + "source": [ + "workspace = \"Public\"\n", + "template_name = \"Existing qPCR template\"\n", + "template = await cielo.request_program(workspace, template_name)\n", + "{\n", + " \"channels\": template.channels,\n", + " \"sample_volume\": template.sample_volume,\n", + " \"step_count\": template.step_count,\n", + " \"cycle_count\": template.cycle_count,\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "stored-results", + "metadata": {}, + "source": [ + "Stored result discovery and download are also read-only. `request_experiment_data()` verifies the firmware-provided MD5 before it returns the `.AZE` bytes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "list-experiments", + "metadata": {}, + "outputs": [], + "source": [ + "experiments = await cielo.request_experiment_summary()\n", + "experiments[-5:]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "parse-stored-result", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.azure_biosystems import Cielo6ResultFile\n", + "\n", + "experiment = experiments[-1]\n", + "aze_data = await cielo.request_experiment_data(experiment)\n", + "stored_result = Cielo6ResultFile.from_bytes(aze_data)\n", + "{\n", + " \"workspace\": stored_result.workspace,\n", + " \"program\": stored_result.program,\n", + " \"raw_amplification_points\": len(stored_result.collection_points),\n", + " \"processed_amplification_points\": len(stored_result.processed_collection_points),\n", + " \"melting_points\": len(stored_result.melt_records),\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "protocol-heading", + "metadata": {}, + "source": [ + "## Define a qPCR protocol\n", + "\n", + "A `Cielo6ThermalProtocol` contains constant-temperature steps and an optional repeated group. `repeat_from_step` is a zero-based index, and `cycles` includes the first execution of the repeated group. Set `collect_fluorescence=True` on each step that must produce an amplification measurement.\n", + "\n", + "This example performs initial denaturation followed by 40 two-step qPCR cycles. Adjust the temperatures, times, cycle count, and sample volume for your assay." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "define-protocol", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.azure_biosystems import Cielo6ThermalProtocol, Cielo6ThermalStep\n", + "\n", + "protocol = Cielo6ThermalProtocol(\n", + " steps=(\n", + " Cielo6ThermalStep(temperature=95, hold_time=180),\n", + " Cielo6ThermalStep(temperature=95, hold_time=15),\n", + " Cielo6ThermalStep(\n", + " temperature=60,\n", + " hold_time=30,\n", + " collect_fluorescence=True,\n", + " ),\n", + " ),\n", + " repeat_from_step=1,\n", + " cycles=40,\n", + " sample_volume=20,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "preview-protocol", + "metadata": {}, + "source": [ + "Compile the protocol locally to inspect the exact stored-program model before starting the instrument. Compilation preserves documented device settings from `template` and replaces its thermal steps. It does not communicate with the Cielo." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "compile-protocol", + "metadata": {}, + "outputs": [], + "source": [ + "program_name = \"PLR-qPCR\"\n", + "compiled = protocol.compile(template, workspace=workspace, name=program_name)\n", + "{\n", + " \"step_count\": compiled.step_count,\n", + " \"thermal_step_count\": compiled.thermal_step_count,\n", + " \"cycle_count\": compiled.cycle_count,\n", + " \"channels\": compiled.channels,\n", + " \"sample_volume\": compiled.sample_volume,\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "run-heading", + "metadata": {}, + "source": [ + "## Run the protocol and monitor progress\n", + "\n", + "The next cell uploads the compiled program, starts a physical run, heats the block, waits for completion, and downloads the verified result. It creates `workspace` if it does not exist. The program transfer is run-scoped and does not add a stored program to the instrument.\n", + "\n", + "Run this cell only after the tubes, consumables, thermal settings, optical channels, and sample volume are correct." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "run-and-monitor", + "metadata": {}, + "outputs": [], + "source": [ + "import asyncio\n", + "\n", + "run_task = asyncio.create_task(\n", + " cielo.run_protocol(\n", + " protocol,\n", + " template=template,\n", + " workspace=workspace,\n", + " program_name=program_name,\n", + " poll_interval=2.0,\n", + " )\n", + ")\n", + "\n", + "while not run_task.done():\n", + " state = await cielo.request_run_state()\n", + " print(\n", + " {\n", + " \"state\": state.status.work_state.name,\n", + " \"progress\": state.progress,\n", + " \"step\": state.current_step_index,\n", + " \"cycle\": state.current_cycle_index,\n", + " \"block_temperatures\": state.status.block_temperatures,\n", + " \"target_temperatures\": state.target_temperatures,\n", + " \"estimated_completion_at\": state.estimated_completion_at,\n", + " \"amplification_frames\": len(state.amplification_data),\n", + " \"melting_frames\": len(state.melting_data),\n", + " }\n", + " )\n", + " await asyncio.sleep(2.0)\n", + "\n", + "result = await run_task" + ] + }, + { + "cell_type": "markdown", + "id": "stop-run", + "metadata": {}, + "source": [ + "To stop an active physical run, call `stop_run()`. Stopping the Python task, interrupting the notebook kernel, or reaching a PLR timeout does not stop the instrument." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "stop-run-code", + "metadata": {}, + "outputs": [], + "source": [ + "# Run this only when you intend to stop the active run.\n", + "# await cielo.stop_run()" + ] + }, + { + "cell_type": "markdown", + "id": "results-heading", + "metadata": {}, + "source": [ + "## Inspect qPCR results\n", + "\n", + "The completed run returns a `Cielo6ResultFile`. Conversion methods return measurements in PLR plate-data layout: `data[row][column]`, with rows A--H and columns 1--12. Optical channel indices are zero-based." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "convert-results", + "metadata": {}, + "outputs": [], + "source": [ + "raw_amplification = result.to_amplification_results()\n", + "processed_amplification = result.to_processed_amplification_results()\n", + "melting_curves = result.to_melting_curve_results()\n", + "\n", + "{\n", + " \"raw_amplification_measurements\": len(raw_amplification),\n", + " \"processed_amplification_measurements\": len(processed_amplification),\n", + " \"melting_curve_measurements\": len(melting_curves),\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "inspect-well", + "metadata": {}, + "source": [ + "For example, collect the processed channel-1 amplification values for well A1. A result can legitimately contain no processed amplification or melting measurements." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "inspect-a1", + "metadata": {}, + "outputs": [], + "source": [ + "a1_channel_1 = [\n", + " {\"cycle\": measurement.cycle, \"value\": measurement.data[0][0]}\n", + " for measurement in processed_amplification\n", + " if measurement.channel_index == 0\n", + "]\n", + "a1_channel_1" + ] + }, + { + "cell_type": "markdown", + "id": "csv-export", + "metadata": {}, + "source": [ + "The parsed result can also reproduce the Cielo amplification and melting CSV structures. These methods return text and do not write files." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "create-csv", + "metadata": {}, + "outputs": [], + "source": [ + "amplification_csv = result.to_amplification_csv()\n", + "melting_csv = result.to_melting_csv()" + ] + }, + { + "cell_type": "markdown", + "id": "teardown-heading", + "metadata": {}, + "source": [ + "## Teardown\n", + "\n", + "Close the PLR serial connection when the workflow is complete. `stop()` disconnects PLR; it does not stop a physical run." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "teardown", + "metadata": {}, + "outputs": [], + "source": [ + "await cielo.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/azure_biosystems/index.md b/docs/user_guide/azure_biosystems/index.md new file mode 100644 index 00000000000..26a62111e8c --- /dev/null +++ b/docs/user_guide/azure_biosystems/index.md @@ -0,0 +1,7 @@ +# Azure BioSystems + +```{toctree} +:maxdepth: 1 + +cielo6/hello-world +``` diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index 4b3546dde2b..f740d63c427 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -29,6 +29,7 @@ generic/index :hidden: agilent/index +azure_biosystems/index azenta/index big_bear/index brooks/index diff --git a/pylabrobot/azure_biosystems/__init__.py b/pylabrobot/azure_biosystems/__init__.py new file mode 100644 index 00000000000..62c1b77de4c --- /dev/null +++ b/pylabrobot/azure_biosystems/__init__.py @@ -0,0 +1,22 @@ +from .cielo6 import ( + Cielo6, + Cielo6AmplificationResult, + Cielo6CollectionPoint, + Cielo6Error, + Cielo6ExperimentInfo, + Cielo6FirmwareStateError, + Cielo6Identity, + Cielo6MeltingCurveResult, + Cielo6MeltingData, + Cielo6MeltRecord, + Cielo6ResultFile, + Cielo6RunningData, + Cielo6RunState, + Cielo6RunTimeoutError, + Cielo6Status, + Cielo6StoredProgram, + Cielo6StoredProgramStep, + Cielo6ThermalProtocol, + Cielo6ThermalStep, + Cielo6WorkState, +) diff --git a/pylabrobot/azure_biosystems/cielo6.py b/pylabrobot/azure_biosystems/cielo6.py new file mode 100644 index 00000000000..32482e1850d --- /dev/null +++ b/pylabrobot/azure_biosystems/cielo6.py @@ -0,0 +1,2329 @@ +import asyncio +import enum +import hashlib +import json +import logging +import struct +import time +import zlib +from contextlib import asynccontextmanager +from dataclasses import dataclass, field, replace +from datetime import datetime, timedelta, timezone +from typing import AsyncIterator, List, Optional + +from pylabrobot.io.serial import Serial + +logger = logging.getLogger(__name__) + +VERSION_CHECK_COMMAND = 0x0B01 +STATUS_QUERY_COMMAND = 0x0B02 +PAUSE_COMMAND = 0x0B04 +RESUME_COMMAND = 0x0B05 +PROGRAM_GET_COMMAND = 0x0B09 +WORKSPACE_SUMMARY_GET_COMMAND = 0x0B0A +WORKSPACE_CREATE_COMMAND = 0x0B0B +WORKSPACE_DELETE_COMMAND = 0x0B0C +PROGRAM_DELETE_COMMAND = 0x0B0D +EXPERIMENT_DATA_SUMMARY_GET_COMMAND = 0x0B10 +EXPERIMENT_DATA_FILE_INFO_GET_COMMAND = 0x0B11 +EXPERIMENT_DATA_FILE_GET_COMMAND = 0x0B12 +RUNNING_EXPERIMENT_INFOS_GET_COMMAND = 0x0B16 +RUNNING_EXPERIMENT_DATA_UPLOAD_COMMAND = 0x0B15 +SESSION_LOCK_COMMAND = 0x0B17 +INITIALIZE_COMMAND = 0x0B00 +PROGRAM_UPLOAD_COMMAND = 0x0B08 +RESULT_PATH_SET_COMMAND = 0x0B14 +RUN_COMMAND = 0x0B03 +STOP_COMMAND = 0x0B06 +DISCONNECT_COMMAND = 0x0B19 +EXEC_SUCCESSFUL = 0x5A01 +DISCOVERY_DEVICE_ID = "99999999" +FRAME_HEADER_SIZE = 13 +FRAME_OVERHEAD = 17 +FRAME_TAIL = b"\x55\xaa" +MAX_PAYLOAD_SIZE = 255 +IDENTITY_RESPONSE_SIZE = 57 +WORK_STATUS_IDLE = 256 +WORK_STATUS_RUNNING = 512 +WORK_STATUS_PAUSED = 768 +RUNNING_DATA_TYPE_NORMAL = 1 +RUNNING_DATA_TYPE_MELTING = 2 +RUNNING_DATA_VALUE_COUNT = 16 +EXPERIMENT_DATA_SIZE = 2308 +EXPERIMENT_CHANNEL_COUNT = 6 +EXPERIMENT_WELL_COUNT = 96 +MELTING_DATA_SIZE = 388 +_COMPLETION_SETTLE_TIMEOUT = 5.0 +_COMPLETION_SETTLE_POLL_INTERVAL = 0.25 + +_STATUS_STRUCT = struct.Struct("<16s2I2IHHHhHHIIIhhHH16h16h") +_PROGRAM_HEADER_STRUCT = struct.Struct("<4s6s6s9H6BHHf6HI") +_PROGRAM_STEP_STRUCT = struct.Struct(" None: + """Store the last authoritative status that PLR received.""" + self.latest_status = latest_status + super().__init__( + "Timed out while waiting for the Cielo run. The run may still be active " + f"(work_status={latest_status.work_status})." + ) + + +class Cielo6FirmwareStateError(Cielo6Error): + """Report an error state returned by the Cielo firmware.""" + + def __init__(self, operation: str, status: "Cielo6Status") -> None: + """Store the operation and authoritative status readback.""" + self.operation = operation + self.status = status + super().__init__( + f"Cielo firmware reported {status.work_state.name} while {operation} " + f"(work_status={status.work_status})." + ) + + +class Cielo6WorkState(enum.IntEnum): + """Firmware-reported Cielo operating state.""" + + UNKNOWN = -1 + NONE = 0 + IDLE = WORK_STATUS_IDLE + RUNNING = WORK_STATUS_RUNNING + PAUSED = WORK_STATUS_PAUSED + ERROR_1 = 2561 + ERROR_2 = 2562 + ERROR_3 = 2563 + ERROR_4 = 2564 + PAUSE_ERROR = 2817 + RESUME_ERROR = 2818 + RUN_ERROR = 2819 + STOP_ERROR = 2820 + DOWNLOAD_ERROR = 2821 + + @classmethod + def from_firmware(cls, value: int) -> "Cielo6WorkState": + """Map a firmware value. Return ``UNKNOWN`` for an unsupported value.""" + try: + return cls(value) + except ValueError: + return cls.UNKNOWN + + @property + def is_error(self) -> bool: + """Return true for a firmware-defined error state.""" + return self in { + self.ERROR_1, + self.ERROR_2, + self.ERROR_3, + self.ERROR_4, + self.PAUSE_ERROR, + self.RESUME_ERROR, + self.RUN_ERROR, + self.STOP_ERROR, + self.DOWNLOAD_ERROR, + } + + +class _Cielo6RunPhase(enum.Enum): + """Local progress through the run-dispatch boundary.""" + + NONE = enum.auto() + PREPARING = enum.auto() + DISPATCHED = enum.auto() + + +@dataclass(frozen=True) +class Cielo6ExperimentInfo: + """Identity and timestamps for one experiment stored by the Cielo firmware.""" + + workspace: str + protocol: str + name: str + started_at_raw: str = "" + ended_at_raw: str = "" + + +def _encode_device_id(device_id: str) -> bytes: + """Encode and validate an eight-character device identifier.""" + try: + encoded = device_id.encode("ascii") + except UnicodeEncodeError as error: + raise ValueError("device_id must contain exactly 8 ASCII characters") from error + if len(encoded) != 8: + raise ValueError("device_id must contain exactly 8 ASCII characters") + return encoded + + +def _crc16(data: bytes) -> int: + """Return the 16-bit value from ``Hats.Tools.CRC.CRC16``. + + The firmware API serializes this value in little-endian order. The wire bytes + use the standard Modbus CRC byte order. + """ + crc = 0xFFFF + for byte in data: + crc ^= byte + for _ in range(8): + crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 + return ((crc & 0xFF) << 8) | (crc >> 8) + + +@dataclass(frozen=True) +class Cielo6Identity: + """Identity returned by the Cielo firmware's broadcast version query.""" + + device_id: str + name: str + transport: str + + @classmethod + def from_bytes(cls, data: bytes) -> "Cielo6Identity": + """Decode a firmware identity response.""" + if len(data) != IDENTITY_RESPONSE_SIZE: + raise Cielo6Error( + f"Invalid Cielo identity response: expected {IDENTITY_RESPONSE_SIZE} bytes, got {len(data)}" + ) + if data[:20] != b"Azure QPCR SeriesID:" or data[28:33] != b"Name:": + raise Cielo6Error("Invalid Cielo identity response header") + if data[53:] != b"&USB": + raise Cielo6Error("Invalid Cielo identity response transport") + + try: + device_id = data[20:28].decode("ascii") + name = data[33:53].decode("ascii").rstrip() + except UnicodeDecodeError as error: + raise Cielo6Error("Cielo identity response is not ASCII") from error + _encode_device_id(device_id) + return cls(device_id=device_id, name=name, transport="USB") + + +@dataclass(frozen=True) +class CieloFrame: + """One Cielo firmware API frame.""" + + device_id: str + command: int + payload: bytes = b"" + + def __post_init__(self) -> None: + """Validate the frame fields before serialization.""" + _encode_device_id(self.device_id) + if not 0 <= self.command <= 0x7FFFFFFF: + raise ValueError("command must fit in a non-negative signed 32-bit integer") + if len(self.payload) > MAX_PAYLOAD_SIZE: + raise ValueError("payload cannot exceed 255 bytes") + + def to_bytes(self) -> bytes: + """Serialize this frame with its CRC and tail.""" + body = ( + _encode_device_id(self.device_id) + + self.command.to_bytes(4, byteorder="little", signed=True) + + bytes([len(self.payload)]) + + self.payload + ) + return body + _crc16(body).to_bytes(2, byteorder="little") + FRAME_TAIL + + @classmethod + def from_bytes(cls, data: bytes) -> "CieloFrame": + """Decode and validate one complete firmware frame.""" + if len(data) < FRAME_OVERHEAD: + raise Cielo6Error(f"Incomplete Cielo frame: expected at least 17 bytes, got {len(data)}") + + payload_size = data[12] + expected_size = FRAME_OVERHEAD + payload_size + if len(data) != expected_size: + raise Cielo6Error( + f"Invalid Cielo frame length: header declares {expected_size} bytes, got {len(data)}" + ) + if data[-2:] != FRAME_TAIL: + raise Cielo6Error("Invalid Cielo frame tail") + + body = data[:-4] + received_crc = int.from_bytes(data[-4:-2], byteorder="little") + expected_crc = _crc16(body) + if received_crc != expected_crc: + raise Cielo6Error( + f"Invalid Cielo frame CRC: expected 0x{expected_crc:04x}, got 0x{received_crc:04x}" + ) + + try: + device_id = data[:8].decode("ascii") + except UnicodeDecodeError as error: + raise Cielo6Error("Cielo frame device ID is not ASCII") from error + command = int.from_bytes(data[8:12], byteorder="little", signed=True) + return cls(device_id=device_id, command=command, payload=data[13:-4]) + + +@dataclass(frozen=True) +class Cielo6Status: + """Contain a decoded payload from firmware command ``0x0B02``. + + The temperature properties apply the firmware scale of 0.01 degrees Celsius. + The corresponding raw fields preserve each signed firmware integer. + """ + + file_name: str + run_id: tuple[int, int] + sample_id: tuple[int, int] + control_mode: int + work_status: int + hot_lid_mode: int + hot_lid_temperature_raw: int + current_step: int + current_cycle: int + current_time_remaining: int + program_time_total: int + program_time_remaining: int + environment_temperature_raw: int + radiator_temperature_raw: int + volume: int + is_finished: int + block_temperatures_raw: tuple[int, ...] + sample_temperatures: tuple[float, ...] + + @property + def work_state(self) -> Cielo6WorkState: + """Return the typed firmware work state.""" + return Cielo6WorkState.from_firmware(self.work_status) + + @property + def is_running(self) -> bool: + """Return true if the firmware reports an active run.""" + return self.work_state is Cielo6WorkState.RUNNING + + @property + def is_paused(self) -> bool: + """Return true if the firmware reports a paused run.""" + return self.work_state is Cielo6WorkState.PAUSED + + @property + def finished(self) -> bool: + """Return true if the firmware reports run completion.""" + return self.is_finished == 1 + + @property + def hot_lid_heating(self) -> Optional[bool]: + """Return heater activity. This value is not the mechanical lid state.""" + if self.hot_lid_mode == 0: + return False + if self.hot_lid_mode == 1: + return True + return None + + @property + def hot_lid_temperature(self) -> float: + """Return the hot-lid temperature in degrees Celsius.""" + return self.hot_lid_temperature_raw / 100 + + @property + def environment_temperature(self) -> float: + """Return the internal environment temperature in degrees Celsius.""" + return self.environment_temperature_raw / 100 + + @property + def radiator_temperature(self) -> float: + """Return the radiator temperature in degrees Celsius.""" + return self.radiator_temperature_raw / 100 + + @property + def block_temperatures(self) -> tuple[float, ...]: + """Return the block temperatures in degrees Celsius.""" + return tuple(value / 100 for value in self.block_temperatures_raw) + + @property + def progress(self) -> Optional[float]: + """Return firmware-timed progress in the inclusive range from 0 to 1.""" + if self.program_time_total <= 0: + return None + elapsed = self.program_time_total - self.program_time_remaining + return min(1.0, max(0.0, elapsed / self.program_time_total)) + + @classmethod + def from_payload(cls, payload: bytes) -> "Cielo6Status": + """Decode one status-command payload.""" + if len(payload) != _STATUS_STRUCT.size: + raise Cielo6Error( + f"Invalid Cielo status payload: expected {_STATUS_STRUCT.size} bytes, got {len(payload)}" + ) + + values = _STATUS_STRUCT.unpack(payload) + try: + file_name = values[0].split(b"\0", 1)[0].decode("ascii") + except UnicodeDecodeError as error: + raise Cielo6Error("Cielo status file name is not ASCII") from error + + return cls( + file_name=file_name, + run_id=(values[1], values[2]), + sample_id=(values[3], values[4]), + control_mode=values[5], + work_status=values[6], + hot_lid_mode=values[7], + hot_lid_temperature_raw=values[8], + current_step=values[9], + current_cycle=values[10], + current_time_remaining=values[11], + program_time_total=values[12], + program_time_remaining=values[13], + environment_temperature_raw=values[14], + radiator_temperature_raw=values[15], + volume=values[16], + is_finished=values[17], + block_temperatures_raw=tuple(values[18:34]), + sample_temperatures=tuple(value / 100 for value in values[34:50]), + ) + + +@dataclass(frozen=True) +class Cielo6RunningData: + """Contain one fluorescence upload from firmware command ``0x0B15``. + + The firmware sends one frame for each measurement group. The frame contains + an index, step, position, channel, cycle, and 16 float values. The structure + starts at payload byte 5. + """ + + index: int + step_number: int + position: int + channel: int + cycle: int + values: tuple[float, ...] + + @classmethod + def from_payload(cls, payload: bytes) -> "Cielo6RunningData": + """Decode one live amplification payload.""" + if len(payload) != 5 + 8 + RUNNING_DATA_VALUE_COUNT * 4: + raise Cielo6Error( + f"Invalid Cielo running-data payload: expected " + f"{5 + 8 + RUNNING_DATA_VALUE_COUNT * 4} bytes, got {len(payload)}" + ) + if payload[4] != RUNNING_DATA_TYPE_NORMAL: + raise Cielo6Error( + f"Invalid Cielo running-data type: expected {RUNNING_DATA_TYPE_NORMAL}, got {payload[4]}" + ) + index = int.from_bytes(payload[:4], byteorder="little", signed=True) + step_number, position, channel, cycle = struct.unpack_from(" "Cielo6MeltingData": + """Decode one live melting-curve payload.""" + if len(payload) != 5 + 8 + RUNNING_DATA_VALUE_COUNT * 4: + raise Cielo6Error( + f"Invalid Cielo melting-data payload: expected " + f"{5 + 8 + RUNNING_DATA_VALUE_COUNT * 4} bytes, got {len(payload)}" + ) + if payload[4] != RUNNING_DATA_TYPE_MELTING: + raise Cielo6Error( + f"Invalid Cielo melting-data type: expected {RUNNING_DATA_TYPE_MELTING}, got {payload[4]}" + ) + index = int.from_bytes(payload[:4], byteorder="little", signed=True) + position = int.from_bytes(payload[5:7], byteorder="little") + temperature = int.from_bytes(payload[7:11], byteorder="little", signed=True) + cycle = int.from_bytes(payload[11:13], byteorder="little") + values = struct.unpack_from(f"<{RUNNING_DATA_VALUE_COUNT}f", payload, 13) + return cls( + index=index, + position=position, + temperature=temperature, + cycle=cycle, + values=values, + ) + + +@dataclass(frozen=True) +class Cielo6MeltRecord: + """One temperature point of a melting-curve read from a result file. + + ``values[position]`` holds the fluorescence for the well at that position in + the same column-major order used by :class:`Cielo6CollectionPoint`. + ``channel_index`` is the zero-based optical channel index. + """ + + temperature_raw: int + values: tuple[float, ...] + channel_index: int + + def __post_init__(self) -> None: + """Validate the channel index and the number of well values.""" + if not 0 <= self.channel_index < EXPERIMENT_CHANNEL_COUNT: + raise ValueError( + f"Cielo melt record channel_index must be between 0 and " + f"{EXPERIMENT_CHANNEL_COUNT - 1}, got {self.channel_index}" + ) + if len(self.values) != EXPERIMENT_WELL_COUNT: + raise ValueError( + f"Cielo melt record must contain {EXPERIMENT_WELL_COUNT} values, got {len(self.values)}" + ) + + @property + def temperature(self) -> float: + """Return the melting temperature in degrees Celsius.""" + return self.temperature_raw / 100 + + +@dataclass(frozen=True) +class Cielo6StoredProgramStep: + """One decoded 64-byte firmware program step.""" + + name: int + function: int + hold_time: int + forever: int + ramp_rate: int + delta_temperature: int + delta_time: int + to_step: int + goto_times: int + pause_before: int + pause_after: int + loop_nesting_times: int + collection_mode: int + nc: tuple[int, int] + temperatures_raw: tuple[int, ...] + + def __post_init__(self) -> None: + """Validate all firmware field widths.""" + self.to_bytes() + + def to_bytes(self) -> bytes: + """Serialize one 64-byte firmware program step.""" + if len(self.nc) != 2: + raise ValueError("Cielo program step nc must contain exactly 2 values") + if len(self.temperatures_raw) != 16: + raise ValueError("Cielo program step temperatures_raw must contain exactly 16 values") + try: + return _PROGRAM_STEP_STRUCT.pack( + self.name, + self.function, + self.hold_time, + self.forever, + self.ramp_rate, + self.delta_temperature, + self.delta_time, + self.to_step, + self.goto_times, + self.pause_before, + self.pause_after, + self.loop_nesting_times, + self.collection_mode, + *self.nc, + *self.temperatures_raw, + ) + except struct.error as error: + raise ValueError( + f"Cielo program step value is outside its firmware field width: {error}" + ) from error + + @classmethod + def from_bytes(cls, data: bytes) -> "Cielo6StoredProgramStep": + """Decode one 64-byte firmware program step.""" + if len(data) != _PROGRAM_STEP_STRUCT.size: + raise Cielo6Error( + f"Invalid Cielo program step: expected {_PROGRAM_STEP_STRUCT.size} bytes, got {len(data)}" + ) + values = _PROGRAM_STEP_STRUCT.unpack(data) + return cls( + name=values[0], + function=values[1], + hold_time=values[2], + forever=values[3], + ramp_rate=values[4], + delta_temperature=values[5], + delta_time=values[6], + to_step=values[7], + goto_times=values[8], + pause_before=values[9], + pause_after=values[10], + loop_nesting_times=values[11], + collection_mode=values[12], + nc=(values[13], values[14]), + temperatures_raw=tuple(values[15:31]), + ) + + +def _decode_fixed_ascii(data: bytes, field_name: str) -> str: + """Decode one fixed-width ASCII program field.""" + try: + return data.split(b"\0", 1)[0].decode("ascii") + except UnicodeDecodeError as error: + raise Cielo6Error(f"Cielo program {field_name} is not ASCII") from error + + +def _encode_fixed_ascii(value: str, size: int, field_name: str) -> bytes: + """Encode one fixed-width ASCII program field.""" + try: + encoded = value.encode("ascii") + except UnicodeEncodeError as error: + raise ValueError(f"Cielo program {field_name} must contain only ASCII characters") from error + if len(encoded) > size: + raise ValueError(f"Cielo program {field_name} cannot exceed {size} ASCII characters") + return encoded.ljust(size, b"\0") + + +def _encode_storage_name(value: str, field_name: str) -> bytes: + """Encode and validate one firmware storage name.""" + try: + encoded = value.encode("ascii") + except UnicodeEncodeError as error: + raise ValueError(f"{field_name} must contain only ASCII characters") from error + if not value or "^" in value: + raise ValueError(f"{field_name} must be non-empty and cannot contain '^'") + if len(encoded) > 30: + raise ValueError(f"{field_name} cannot exceed 30 ASCII characters") + return encoded + + +@dataclass(frozen=True) +class Cielo6StoredProgram: + """Decoded 2,048-byte program returned by firmware command ``0x0B09``.""" + + identifier: bytes + channels: tuple[int, ...] + positions: tuple[int, ...] + hot_lid_mode: int + hot_lid_temperature_raw: int + hot_lid_close_temperature_raw: int + experiment_mode: int + sample_volume: int + test_zone: int + heater_line: int + saved_year: int + saved_month: int + saved_day: int + saved_hour: int + saved_minute: int + activation_step_number: int + melting_curve_mode: int + melting_curve_start_temperature_raw: int + melting_curve_end_temperature_raw: int + melting_curve_step_resolution: float + exposure_times: tuple[int, ...] + reserved: int + steps: tuple[Cielo6StoredProgramStep, ...] + name: str + workspace: str + _unused_step_bytes: bytes = field(default=b"", repr=False) + + def __post_init__(self) -> None: + """Validate the complete stored-program representation.""" + self.to_bytes() + + @property + def step_count(self) -> int: + """Return the number of active program steps.""" + return len(self.steps) + + @property + def thermal_step_count(self) -> int: + """Return executable temperature steps, excluding firmware loop markers.""" + return sum(step.function != 4 for step in self.steps) + + @property + def cycle_count(self) -> Optional[int]: + """Return the cycle count when the program has at most one repeat group.""" + repeat_steps = tuple(step for step in self.steps if step.function == 4) + if not repeat_steps: + return 1 + if len(repeat_steps) == 1: + return repeat_steps[0].goto_times + 1 + return None + + def step_target_temperatures(self, step_index: int) -> tuple[float, ...]: + """Return target temperatures for a zero-based firmware program step.""" + if not 0 <= step_index < len(self.steps): + raise IndexError(f"Cielo program step index {step_index} is out of range") + return tuple(value / 100 for value in self.steps[step_index].temperatures_raw) + + @property + def crc32(self) -> int: + """Return the CRC32 of the program's current serialized content.""" + return int.from_bytes(self.to_bytes()[-4:], byteorder="little") + + def to_bytes(self) -> bytes: + """Serialize the program and calculate the firmware CRC32.""" + if len(self.identifier) != 4: + raise ValueError("Cielo program identifier must contain exactly 4 bytes") + if len(self.channels) != 6 or len(self.positions) != 6 or len(self.exposure_times) != 6: + raise ValueError( + "Cielo program channels, positions, and exposure_times must each have 6 values" + ) + if self.step_count > PROGRAM_STEP_COUNT: + raise ValueError(f"Cielo programs cannot exceed {PROGRAM_STEP_COUNT} steps") + + try: + header = _PROGRAM_HEADER_STRUCT.pack( + self.identifier, + bytes(self.channels), + bytes(self.positions), + self.step_count, + self.hot_lid_mode, + self.hot_lid_temperature_raw, + self.hot_lid_close_temperature_raw, + self.experiment_mode, + self.sample_volume, + self.test_zone, + self.heater_line, + self.saved_year, + self.saved_month, + self.saved_day, + self.saved_hour, + self.saved_minute, + self.activation_step_number, + self.melting_curve_mode, + self.melting_curve_start_temperature_raw, + self.melting_curve_end_temperature_raw, + self.melting_curve_step_resolution, + *self.exposure_times, + self.reserved, + ) + except (struct.error, ValueError) as error: + raise ValueError( + f"Cielo program value is outside its firmware field width: {error}" + ) from error + + unused_step_size = _PROGRAM_STEP_STRUCT.size * (PROGRAM_STEP_COUNT - self.step_count) + unused_step_bytes = self._unused_step_bytes or bytes(unused_step_size) + if len(unused_step_bytes) != unused_step_size: + raise ValueError( + f"Cielo program inactive step data must contain exactly {unused_step_size} bytes" + ) + body = ( + header + + b"".join(step.to_bytes() for step in self.steps) + + unused_step_bytes + + _encode_fixed_ascii(self.name, 30, "name") + + _encode_fixed_ascii(self.workspace, 30, "workspace") + ) + assert len(body) == PROGRAM_SIZE - 4 + return body + zlib.crc32(body).to_bytes(4, byteorder="little") + + @classmethod + def from_bytes(cls, data: bytes) -> "Cielo6StoredProgram": + """Decode and verify one complete stored program.""" + if len(data) != PROGRAM_SIZE: + raise Cielo6Error(f"Invalid Cielo program: expected {PROGRAM_SIZE} bytes, got {len(data)}") + expected_crc = zlib.crc32(data[:-4]) + received_crc = int.from_bytes(data[-4:], byteorder="little") + if received_crc != expected_crc: + raise Cielo6Error( + f"Invalid Cielo program CRC32: expected 0x{expected_crc:08x}, got 0x{received_crc:08x}" + ) + + header = _PROGRAM_HEADER_STRUCT.unpack_from(data) + step_count = header[3] + if step_count > PROGRAM_STEP_COUNT: + raise Cielo6Error( + f"Invalid Cielo program step count: expected at most {PROGRAM_STEP_COUNT}, got {step_count}" + ) + step_offset = _PROGRAM_HEADER_STRUCT.size + all_steps = tuple( + Cielo6StoredProgramStep.from_bytes( + data[ + step_offset + index * _PROGRAM_STEP_STRUCT.size : step_offset + + (index + 1) * _PROGRAM_STEP_STRUCT.size + ] + ) + for index in range(PROGRAM_STEP_COUNT) + ) + name_offset = step_offset + PROGRAM_STEP_COUNT * _PROGRAM_STEP_STRUCT.size + workspace_offset = name_offset + 30 + + return cls( + identifier=header[0], + channels=tuple(header[1]), + positions=tuple(header[2]), + hot_lid_mode=header[4], + hot_lid_temperature_raw=header[5], + hot_lid_close_temperature_raw=header[6], + experiment_mode=header[7], + sample_volume=header[8], + test_zone=header[9], + heater_line=header[10], + saved_year=header[11], + saved_month=header[12], + saved_day=header[13], + saved_hour=header[14], + saved_minute=header[15], + activation_step_number=header[16], + melting_curve_mode=header[17], + melting_curve_start_temperature_raw=header[18], + melting_curve_end_temperature_raw=header[19], + melting_curve_step_resolution=header[20], + exposure_times=tuple(header[21:27]), + reserved=header[27], + steps=all_steps[:step_count], + name=_decode_fixed_ascii(data[name_offset:workspace_offset], "name"), + workspace=_decode_fixed_ascii(data[workspace_offset : workspace_offset + 30], "workspace"), + _unused_step_bytes=data[step_offset + step_count * _PROGRAM_STEP_STRUCT.size : name_offset], + ) + + +@dataclass(frozen=True) +class Cielo6RunState: + """Contain one run-progress snapshot from the Cielo firmware. + + The status and experiment identity are firmware readback. PLR uses zero-based + step and cycle indices. Program data supplies totals and target temperatures. + """ + + status: Cielo6Status + experiment: Optional[Cielo6ExperimentInfo] + observed_at: datetime + current_step_index: Optional[int] + current_cycle_index: Optional[int] + total_step_count: Optional[int] + total_cycle_count: Optional[int] + target_temperatures: Optional[tuple[float, ...]] + amplification_data: tuple[Cielo6RunningData, ...] + melting_data: tuple[Cielo6MeltingData, ...] + + @property + def progress(self) -> Optional[float]: + """Return the normalized progress reported by the firmware.""" + return self.status.progress + + @property + def estimated_completion_at(self) -> Optional[datetime]: + """Return the estimated completion time when the run is active.""" + if ( + not (self.status.is_running or self.status.is_paused) + or self.status.program_time_remaining <= 0 + or self.status.finished + ): + return None + return self.observed_at + timedelta(seconds=self.status.program_time_remaining) + + +@dataclass(frozen=True) +class Cielo6ThermalStep: + """One constant-temperature step in a user-facing Cielo thermal protocol.""" + + temperature: float + hold_time: int + collect_fluorescence: bool = False + + def __post_init__(self) -> None: + """Validate the temperature and hold time.""" + if not 4 <= self.temperature <= 100: + raise ValueError("Cielo step temperature must be between 4 and 100 degrees Celsius") + if self.hold_time < 1: + raise ValueError("Cielo step hold_time must be at least 1 second") + + +@dataclass(frozen=True) +class Cielo6ThermalProtocol: + """Define a linear Cielo protocol with an optional repeated step group. + + ``repeat_from_step`` is a zero-based index in ``steps``. ``cycles`` includes + the first execution of the group. Compilation uses a program from the + instrument as a template. PLR does not create undocumented field values. + """ + + steps: tuple[Cielo6ThermalStep, ...] + repeat_from_step: Optional[int] = None + cycles: int = 1 + sample_volume: int = 20 + + def __post_init__(self) -> None: + """Validate the user-facing thermal protocol.""" + if not self.steps: + raise ValueError("Cielo thermal protocol must contain at least one step") + if self.cycles < 1: + raise ValueError("Cielo thermal protocol cycles must be at least 1") + if not 1 <= self.sample_volume <= 100: + raise ValueError("Cielo sample_volume must be between 1 and 100 microliters") + if self.repeat_from_step is None: + if self.cycles != 1: + raise ValueError("repeat_from_step is required when cycles is greater than 1") + elif not 0 <= self.repeat_from_step < len(self.steps): + raise ValueError("repeat_from_step must identify an existing thermal step") + compiled_step_count = len(self.steps) + (self.cycles > 1) + if compiled_step_count > PROGRAM_STEP_COUNT: + raise ValueError(f"Compiled Cielo protocol cannot exceed {PROGRAM_STEP_COUNT} steps") + + def compile( + self, template: Cielo6StoredProgram, *, workspace: str, name: str + ) -> Cielo6StoredProgram: + """Compile this protocol using hardware-read device settings from ``template``.""" + _encode_storage_name(workspace, "workspace") + _encode_storage_name(name, "program") + steps = [self._compile_step(step) for step in self.steps] + if self.cycles > 1: + assert self.repeat_from_step is not None + steps.append( + Cielo6StoredProgramStep( + name=0x5AF3, + function=4, + hold_time=0, + forever=0, + ramp_rate=0, + delta_temperature=0, + delta_time=0, + to_step=self.repeat_from_step + 1, + goto_times=self.cycles - 1, + pause_before=0, + pause_after=0, + loop_nesting_times=0, + collection_mode=0, + nc=(0, 0), + temperatures_raw=(0,) * 16, + ) + ) + return replace( + template, + sample_volume=self.sample_volume, + activation_step_number=0, + melting_curve_mode=0, + steps=tuple(steps), + name=name, + workspace=workspace, + _unused_step_bytes=b"", + ) + + @staticmethod + def _compile_step(step: Cielo6ThermalStep) -> Cielo6StoredProgramStep: + """Compile one user-facing thermal step.""" + temperature_raw = round(step.temperature * 100) + return Cielo6StoredProgramStep( + name=0x5AF1, + function=1, + hold_time=step.hold_time, + forever=0, + ramp_rate=0, + delta_temperature=0, + delta_time=0, + to_step=0, + goto_times=0, + pause_before=0, + pause_after=0, + loop_nesting_times=0, + collection_mode=int(step.collect_fluorescence), + nc=(0, 0), + temperatures_raw=(temperature_raw,) * 3 + (0,) * 13, + ) + + +def _well_position(row: int, column: int) -> int: + """Return the 96-well array position for a well in column-major order. + + The instrument stores fluorescence in the order A1..H1, A2..H2, ..., A12..H12, + matching the vendor's exported CSV column headers. ``row`` is zero-based + (0 = A) and ``column`` is one-based (1..12). + """ + if not 0 <= row <= 7: + raise ValueError(f"Cielo well row must be between 0 and 7, got {row}") + if not 1 <= column <= 12: + raise ValueError(f"Cielo well column must be between 1 and 12, got {column}") + return (column - 1) * 8 + row + + +def _row_major_plate_data(values: tuple[float, ...]) -> List[List[Optional[float]]]: + """Convert one Cielo column-major well array to PLR row-major plate data.""" + if len(values) != EXPERIMENT_WELL_COUNT: + raise ValueError( + f"Cielo plate data must contain {EXPERIMENT_WELL_COUNT} values, got {len(values)}" + ) + return [[values[_well_position(row, column)] for column in range(1, 13)] for row in range(8)] + + +def _format_float32(value: float) -> str: + """Format a value as the shortest decimal that preserves its float32 value.""" + single = struct.unpack(" None: + """Validate all channel and well dimensions.""" + if len(self.channels) != EXPERIMENT_CHANNEL_COUNT: + raise ValueError( + f"Cielo collection point must have {EXPERIMENT_CHANNEL_COUNT} channels, " + f"got {len(self.channels)}" + ) + for channel in self.channels: + if len(channel) != EXPERIMENT_WELL_COUNT: + raise ValueError( + f"Cielo collection point channel must contain {EXPERIMENT_WELL_COUNT} " + f"values, got {len(channel)}" + ) + + def well_value(self, row: int, column: int, channel: int) -> float: + """Return the fluorescence for one well and channel (0-based channel).""" + if not 0 <= channel < EXPERIMENT_CHANNEL_COUNT: + raise ValueError(f"Cielo channel must be between 0 and 5, got {channel}") + return self.channels[channel][_well_position(row, column)] + + +@dataclass +class Cielo6AmplificationResult: + """Result of one Cielo amplification measurement. + + Attributes: + data: Plate data indexed by ``[row][column]``. The value is ``None`` for an + unmeasured well. + cycle: Cycle number reported in the Cielo result file. + step: Protocol step number reported in the Cielo result file. + channel_index: Zero-based optical channel index. + """ + + data: List[List[Optional[float]]] + cycle: int + step: int + channel_index: int + + +@dataclass +class Cielo6MeltingCurveResult: + """Result of one Cielo melting-curve measurement. + + Attributes: + data: Plate data indexed by ``[row][column]``. The value is ``None`` for an + unmeasured well. + temperature: Sample temperature in degrees Celsius. + channel_index: Zero-based optical channel index. + """ + + data: List[List[Optional[float]]] + temperature: float + channel_index: int + + +@dataclass(frozen=True) +class Cielo6ResultFile: + """Decoded ``.AZE`` experiment data downloaded from the Cielo firmware. + + The file is a length-prefixed binary envelope: magic, the stored program, + a JSON metadata block, an optional melting section, and separate raw and + instrument-processed amplification sections. The binary melting section + contains channel 1. The metadata can contain melting data for channels 2-6. + The JSON keys retain the vendor's trailing-colon spelling. + """ + + device_id: str + device_name: str + software_versions: tuple[str, str, str] + workspace: str + program: str + run_started_at: str + run_ended_at: str + gain: int + exposure_times: tuple[int, ...] + dyes: tuple[tuple[str, ...], ...] + dye_crosstalk_coefficients: dict[str, tuple[float, ...]] + temperature_curves: dict[str, tuple[int, ...]] + stored_program: Cielo6StoredProgram + collection_points: tuple[Cielo6CollectionPoint, ...] + melt_records: tuple[Cielo6MeltRecord, ...] = () + processed_collection_points: tuple[Cielo6CollectionPoint, ...] = () + + def to_amplification_results(self) -> List[Cielo6AmplificationResult]: + """Return the raw amplification measurements in PLR plate-data format.""" + return self._to_amplification_results(self.collection_points) + + def to_processed_amplification_results(self) -> List[Cielo6AmplificationResult]: + """Return the instrument-processed amplification measurements.""" + return self._to_amplification_results(self.processed_collection_points) + + def to_melting_curve_results(self) -> List[Cielo6MeltingCurveResult]: + """Return the melting-curve measurements in PLR plate-data format.""" + return [ + Cielo6MeltingCurveResult( + data=_row_major_plate_data(record.values), + temperature=record.temperature, + channel_index=record.channel_index, + ) + for record in self.melt_records + ] + + def _to_amplification_results( + self, points: tuple[Cielo6CollectionPoint, ...] + ) -> List[Cielo6AmplificationResult]: + """Convert Cielo amplification records to PLR plate-data format.""" + return [ + Cielo6AmplificationResult( + data=_row_major_plate_data(point.channels[channel_index]), + cycle=point.cycle, + step=point.step, + channel_index=channel_index, + ) + for point in points + for channel_index in range(EXPERIMENT_CHANNEL_COUNT) + if self.stored_program.channels[channel_index] > 0 + ] + + @classmethod + def from_bytes(cls, data: bytes) -> "Cielo6ResultFile": + """Decode one complete ``.AZE`` experiment file.""" + + def section(offset: int, size: int, name: str) -> bytes: + """Read a bounded section from the experiment file.""" + end = offset + size + if size < 0 or end > len(data): + raise Cielo6Error( + f"Incomplete Cielo experiment file {name}: expected {size} bytes at " + f"offset {offset}, file has {len(data)} bytes" + ) + return data[offset:end] + + def length_at(offset: int, name: str, *, signed: bool = False) -> int: + """Read one big-endian section length.""" + return int.from_bytes(section(offset, 4, f"{name} length"), byteorder="big", signed=signed) + + magic_length = length_at(0, "magic") + magic = section(4, magic_length, "magic") + if magic.rstrip(b"\0") != b"Azure Data": + raise Cielo6Error("Invalid Cielo experiment file magic") + + offset = 4 + magic_length + program_length = length_at(offset, "program") + offset += 4 + stored_program = Cielo6StoredProgram.from_bytes(section(offset, program_length, "program")) + offset += program_length + + datainfo_length = length_at(offset, "metadata") + offset += 4 + try: + datainfo = json.loads(section(offset, datainfo_length, "metadata").decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise Cielo6Error("Invalid Cielo experiment JSON metadata") from error + if not isinstance(datainfo, dict): + raise Cielo6Error("Invalid Cielo experiment JSON metadata: expected an object") + offset += datainfo_length + + melting_length = length_at(offset, "melting data", signed=True) + offset += 4 + melt_records: tuple[Cielo6MeltRecord, ...] = () + if melting_length < -1: + raise Cielo6Error(f"Invalid Cielo melting data length: {melting_length}") + if melting_length > 0: + if melting_length % MELTING_DATA_SIZE != 0: + raise Cielo6Error( + f"Invalid Cielo melting data length: {melting_length} is not a multiple of " + f"{MELTING_DATA_SIZE}" + ) + section(offset, melting_length, "melting data") + melt_records = tuple( + Cielo6MeltRecord( + temperature_raw=struct.unpack_from(" 0 and experiment_length % EXPERIMENT_DATA_SIZE != 0: + raise Cielo6Error( + f"Invalid Cielo experiment data length: {experiment_length} is not a multiple of " + f"{EXPERIMENT_DATA_SIZE}" + ) + if experiment_length > 0: + section(offset, experiment_length, "amplification data") + + def parse_collection_points(start: int, size: int) -> tuple[Cielo6CollectionPoint, ...]: + """Decode consecutive amplification records.""" + return tuple( + Cielo6CollectionPoint( + step=struct.unpack_from(" 0: + collection_points = parse_collection_points(offset, experiment_length) + offset += experiment_length + + processed_length = length_at(offset, "processed amplification data", signed=True) + offset += 4 + processed_collection_points: tuple[Cielo6CollectionPoint, ...] = () + if processed_length < -1: + raise Cielo6Error(f"Invalid Cielo processed amplification data length: {processed_length}") + if processed_length > 0: + if processed_length % EXPERIMENT_DATA_SIZE != 0: + raise Cielo6Error( + f"Invalid Cielo processed amplification data length: {processed_length} is not a " + f"multiple of {EXPERIMENT_DATA_SIZE}" + ) + section(offset, processed_length, "processed amplification data") + processed_collection_points = parse_collection_points(offset, processed_length) + offset += processed_length + if offset != len(data): + raise Cielo6Error(f"Invalid Cielo experiment file: {len(data) - offset} trailing byte(s)") + + def text(key: str, default: str = "") -> str: + """Read one scalar metadata value as text.""" + value = datainfo.get(key) + if value is None: + return default + if not isinstance(value, (str, int, float, bool)): + raise Cielo6Error(f"Invalid Cielo experiment {key!r} value: expected a scalar") + return str(value) + + def integers(key: str) -> tuple[int, ...]: + """Read one optional metadata array as integers.""" + value = datainfo.get(key) + if value is None: + return () + if not isinstance(value, list): + raise Cielo6Error(f"Invalid Cielo experiment {key!r} value: expected an array") + result = [] + for item in value: + try: + result.append(int(item)) + except (TypeError, ValueError): + raise Cielo6Error(f"Invalid Cielo experiment {key!r} value: {item!r}") from None + return tuple(result) + + def floats(key: str) -> tuple[float, ...]: + """Read one optional metadata array as floats.""" + value = datainfo.get(key) + if value is None: + return () + if not isinstance(value, list): + raise Cielo6Error(f"Invalid Cielo experiment {key!r} value: expected an array") + result = [] + for item in value: + try: + result.append(float(item)) + except (TypeError, ValueError): + raise Cielo6Error(f"Invalid Cielo experiment {key!r} value: {item!r}") from None + return tuple(result) + + def strings(key: str) -> tuple[str, ...]: + """Read one optional metadata array as text values.""" + value = datainfo.get(key) + if value is None: + return () + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise Cielo6Error(f"Invalid Cielo experiment {key!r} value: expected a text array") + return tuple(value) + + def metadata_melt_records(channel_index: int) -> tuple[Cielo6MeltRecord, ...]: + """Read one additional optical channel from the flat metadata array.""" + key = f"MeltCurveChannel{channel_index + 1}" + value = datainfo.get(key) + if value is None: + return () + if not isinstance(value, list): + raise Cielo6Error(f"Invalid Cielo experiment {key!r} value: expected an array") + + expected_value_count = len(melt_records) * EXPERIMENT_WELL_COUNT + if len(value) != expected_value_count: + raise Cielo6Error( + f"Invalid Cielo experiment {key!r} value count: expected " + f"{expected_value_count}, got {len(value)}" + ) + try: + values = tuple(float(item) for item in value) + except (TypeError, ValueError) as error: + raise Cielo6Error(f"Invalid Cielo experiment {key!r} value") from error + return tuple( + Cielo6MeltRecord( + temperature_raw=record.temperature_raw, + values=values[index * EXPERIMENT_WELL_COUNT : (index + 1) * EXPERIMENT_WELL_COUNT], + channel_index=channel_index, + ) + for index, record in enumerate(melt_records) + ) + + melt_records += tuple( + record + for channel_index in range(1, EXPERIMENT_CHANNEL_COUNT) + for record in metadata_melt_records(channel_index) + ) + + dyes = tuple(strings(f"Channel{channel}") for channel in range(1, EXPERIMENT_CHANNEL_COUNT + 1)) + crosstalk = { + str(dye): floats(dye) + for channel_dyes in dyes + for dye in channel_dyes + if dye != "default" and datainfo.get(dye) is not None + } + temperature_keys = ( + "Block1Temp", + "Block2Temp", + "Block3Temp", + "Sample1Temp", + "Sample2Temp", + "Sample3Temp", + "HotlidTemp", + ) + try: + gain = int(datainfo.get("Gain:", 0)) + exposure_times = tuple( + int(datainfo.get(f"Channel {channel}expose time", 0)) + for channel in range(1, EXPERIMENT_CHANNEL_COUNT + 1) + ) + except (TypeError, ValueError) as error: + raise Cielo6Error("Invalid Cielo experiment gain or exposure time") from error + + required = { + "device_id": text("Device id:"), + "workspace": text("Workspace:"), + "program": text("Program:"), + } + missing = [name for name, value in required.items() if not value] + if missing: + raise Cielo6Error(f"Cielo experiment metadata is missing {', '.join(missing)}") + + return cls( + device_id=required["device_id"], + device_name=text("Device name:"), + software_versions=( + text("Instrument software Version:"), + text("Instrument control module software Version:"), + text("Instrument heater module software Version:"), + ), + workspace=required["workspace"], + program=required["program"], + run_started_at=text("Run start time:"), + run_ended_at=text("Run end time:"), + gain=gain, + exposure_times=exposure_times, + dyes=dyes, + dye_crosstalk_coefficients=crosstalk, + temperature_curves={key: integers(key) for key in temperature_keys}, + stored_program=stored_program, + collection_points=collection_points, + melt_records=melt_records, + processed_collection_points=processed_collection_points, + ) + + def to_amplification_csv(self) -> str: + """Serialize fluorescence in the OEM ``Amplification Values`` CSV shape. + + The OEM export writes a column-major well header, one ``Step{n}Channel{m}`` + label row per enabled channel, then one row per collection point starting + with the cycle number. Values use a compact float32-preserving decimal + representation. This output matches the OEM CSV structure and numeric values. + """ + lines = ["Data,"] + for column in range(1, 13): + for row_index in range(8): + lines.append(f"{chr(ord('A') + row_index)}{column},") + lines.append("\r\n") + + by_step: dict[int, list[Cielo6CollectionPoint]] = {} + for point in self.collection_points: + by_step.setdefault(point.step, []).append(point) + + for step in sorted(by_step): + points = tuple(by_step[step]) + for channel in range(EXPERIMENT_CHANNEL_COUNT): + if self.stored_program.channels[channel] <= 0: + continue + lines.append(f"Step{step}Channel{channel + 1}\r\n") + for point in points: + values = ",".join(_format_float32(value) for value in point.channels[channel]) + lines.append(f"{point.cycle},{values},\r\n") + return "\ufeff" + "".join(lines) + + def to_melting_csv(self) -> str: + """Serialize melting records in the OEM ``MeltingCurve`` CSV shape.""" + lines = ["Temperature,"] + for column in range(1, 13): + for row_index in range(8): + lines.append(f"{chr(ord('A') + row_index)}{column},") + lines.append("\r\n") + for record in self.melt_records: + values = ",".join(_format_float32(value) for value in record.values) + lines.append(f"{record.temperature:g},{values},\r\n") + return "\ufeff" + "".join(lines) + + +class Cielo6: + """Azure BioSystems Cielo 6 qPCR instrument. + + This driver supports read requests, empty-workspace operations, and experiment + runs. It uses the USB serial interface at 1,500,000 baud. The FT232R VID and + PID are not unique to this instrument. Therefore, the caller must select a + port. Setup requests the firmware identity. Setup also verifies a specified + device ID. + + ``run_experiment`` and ``run_protocol`` control a complete physical run and + heat the block. Command ``0x0B08`` transfers a program but does not store the + program. Therefore, PLR does not expose this command as a storage operation. + """ + + def __init__(self, port: str, device_id: Optional[str] = None, timeout: float = 1.0) -> None: + """Configure the serial transport without opening it.""" + if device_id is not None: + _encode_device_id(device_id) + self.device_id = device_id + self.io = Serial( + human_readable_device_name="Azure BioSystems Cielo 6", + port=port, + baudrate=1_500_000, + bytesize=8, + parity="N", + stopbits=1, + timeout=timeout, + write_timeout=timeout, + ) + self._request_lock = asyncio.Lock() + self._operation_lock = asyncio.Lock() + self._run_transition_lock = asyncio.Lock() + self._stop_requested = asyncio.Event() + self._is_setup = False + self._receive_buffer = bytearray() + self._active_program: Optional[Cielo6StoredProgram] = None + self._active_run_identity: Optional[tuple[tuple[int, int], tuple[int, int], str]] = None + self._run_phase = _Cielo6RunPhase.NONE + self._running_data: list[Cielo6RunningData] = [] + self._melting_data: list[Cielo6MeltingData] = [] + self.identity: Optional[Cielo6Identity] = None + self.latest_status: Optional[Cielo6Status] = None + + @property + def running_data(self) -> tuple[Cielo6RunningData, ...]: + """Return live amplification frames decoded so far in the current run.""" + return tuple(self._running_data) + + @property + def melting_data(self) -> tuple[Cielo6MeltingData, ...]: + """Return live melting frames decoded so far in the current run.""" + return tuple(self._melting_data) + + async def setup(self) -> None: + """Open the transport and verify the firmware identity.""" + async with self._operation_lock: + if self._is_setup: + return + self._receive_buffer.clear() + self._running_data.clear() + self._melting_data.clear() + self.identity = None + self.latest_status = None + try: + await self.io.setup() + identity = await self._request_identity(require_setup=False) + if self.device_id is not None and identity.device_id != self.device_id: + raise Cielo6Error( + f"Connected Cielo ID {identity.device_id!r} does not match {self.device_id!r}" + ) + except BaseException: + await self._stop_after_setup_failure() + raise + self.device_id = identity.device_id + self.identity = identity + self._is_setup = True + logger.info("[%s] connected to %s (%s)", self.io.port, identity.name, identity.device_id) + + async def stop(self) -> None: + """Close the serial connection.""" + async with self._operation_lock: + if not self._is_setup: + return + async with self._request_lock: + self._is_setup = False + try: + await self.io.stop() + finally: + self.identity = None + logger.info("[%s] disconnected", self.io.port) + + async def request_identity(self) -> Cielo6Identity: + """Request the firmware identity without a change to the instrument state.""" + return await self._request_identity() + + async def request_status(self) -> Cielo6Status: + """Read the instrument's current status without changing its state.""" + frame = await self._request(STATUS_QUERY_COMMAND) + status = Cielo6Status.from_payload(frame.payload) + self.latest_status = status + return status + + async def request_run_state( + self, program: Optional[Cielo6StoredProgram] = None + ) -> Cielo6RunState: + """Request one run snapshot and normalize the values for PLR. + + This method always requests status. It requests the experiment identity only + during an active run. Supply ``program`` for a run that another driver + instance started. This instance uses its uploaded program automatically. + """ + status = await self.request_status() + active = status.is_running or status.is_paused + experiment = await self.request_running_experiment_info() if active else None + if ( + program is None + and active + and self._active_program is not None + and self._run_phase is _Cielo6RunPhase.NONE + ): + self._clear_active_run_context() + active_program = program if program is not None else self._active_program + if program is None and self._run_phase is not _Cielo6RunPhase.DISPATCHED: + active_program = None + if not active and not (program is None and self._run_phase is _Cielo6RunPhase.DISPATCHED): + active_program = None + if ( + program is None + and active_program is not None + and active + and self._run_phase is _Cielo6RunPhase.DISPATCHED + and self._active_run_identity is None + ): + self._active_run_identity = self._run_identity(status) + if ( + program is None + and active_program is not None + and active + and self._run_phase is _Cielo6RunPhase.DISPATCHED + and self._active_run_identity is not None + and self._run_identity(status) != self._active_run_identity + ): + self._clear_active_run_context() + active_program = None + current_step_index = status.current_step - 1 if status.current_step > 0 else None + current_cycle_index = status.current_cycle - 1 if status.current_cycle > 0 else None + target_temperatures = None + if ( + active_program is not None + and current_step_index is not None + and current_step_index < active_program.step_count + ): + target_temperatures = active_program.step_target_temperatures(current_step_index) + state = Cielo6RunState( + status=status, + experiment=experiment, + observed_at=datetime.now(timezone.utc), + current_step_index=current_step_index, + current_cycle_index=current_cycle_index, + total_step_count=None if active_program is None else active_program.thermal_step_count, + total_cycle_count=None if active_program is None else active_program.cycle_count, + target_temperatures=target_temperatures, + amplification_data=self.running_data, + melting_data=self.melting_data, + ) + if ( + status.work_state is Cielo6WorkState.IDLE + and status.finished + and self._run_phase is _Cielo6RunPhase.DISPATCHED + and self._active_run_identity is not None + and self._run_identity(status) == self._active_run_identity + ): + self._clear_active_run_context() + return state + + async def request_workspace_summary(self) -> dict[str, list[str]]: + """Return stored workspace names and their program names without modifying storage.""" + summary_frame = await self._request(WORKSPACE_SUMMARY_GET_COMMAND, struct.pack(" Cielo6StoredProgram: + """Retrieve and verify a stored program without modifying instrument storage.""" + self._require_setup() + payload = ( + _encode_storage_name(workspace, "workspace") + b"^" + _encode_storage_name(program, "program") + ) + + device_id = self.device_id + if device_id is None: + raise Cielo6Error("Cielo device ID is unknown. Call setup() before you send commands.") + request = CieloFrame(device_id=device_id, command=PROGRAM_GET_COMMAND, payload=payload) + chunks: dict[int, bytes] = {} + async with self._request_lock: + self._require_setup() + await self.io.write(request.to_bytes()) + for _ in range(PROGRAM_CHUNK_COUNT): + response = await self._read_matching_frame(PROGRAM_GET_COMMAND, device_id) + if len(response.payload) != PROGRAM_CHUNK_SIZE + 1: + raise Cielo6Error( + f"Invalid Cielo program chunk size: expected {PROGRAM_CHUNK_SIZE + 1}, " + f"got {len(response.payload)}" + ) + index = response.payload[0] + if index >= PROGRAM_CHUNK_COUNT: + raise Cielo6Error(f"Invalid Cielo program chunk index: {index}") + if index in chunks: + raise Cielo6Error(f"Duplicate Cielo program chunk index: {index}") + chunks[index] = response.payload[1:] + return Cielo6StoredProgram.from_bytes( + b"".join(chunks[index] for index in range(PROGRAM_CHUNK_COUNT)) + ) + + async def request_experiment_summary(self) -> tuple[Cielo6ExperimentInfo, ...]: + """Return identities and raw timestamps for all stored experiment results.""" + header = await self._request(EXPERIMENT_DATA_SUMMARY_GET_COMMAND, struct.pack(" Optional[Cielo6ExperimentInfo]: + """Return the running experiment identity, or ``None`` when none is reported.""" + frame = await self._request(RUNNING_EXPERIMENT_INFOS_GET_COMMAND) + if not frame.payload: + return None + try: + fields = frame.payload.decode("ascii").split("^") + except UnicodeDecodeError as error: + raise Cielo6Error("Cielo running experiment information is not ASCII") from error + if len(fields) < 3 or not fields[2]: + return None + return Cielo6ExperimentInfo(workspace=fields[0], protocol=fields[1], name=fields[2]) + + async def request_experiment_data(self, experiment: Cielo6ExperimentInfo) -> bytes: + """Request one stored ``.AZE`` result and verify its MD5 hash.""" + self._require_setup() + path = "/".join((experiment.workspace, experiment.protocol, experiment.name)) + try: + payload = path.encode("ascii") + except UnicodeEncodeError as error: + raise ValueError("Cielo experiment path must contain only ASCII characters") from error + if not all((experiment.workspace, experiment.protocol, experiment.name)): + raise ValueError("Cielo experiment workspace, protocol, and name must be non-empty") + + info = await self._request(EXPERIMENT_DATA_FILE_INFO_GET_COMMAND, payload) + if len(info.payload) != 20: + raise Cielo6Error( + f"Invalid Cielo experiment file information: expected 20 bytes, got {len(info.payload)}" + ) + expected_size = int.from_bytes(info.payload[:4], byteorder="little", signed=True) + if expected_size < 0: + raise Cielo6Error(f"Invalid Cielo experiment file size: {expected_size}") + expected_md5 = info.payload[4:] + + device_id = self.device_id + if device_id is None: + raise Cielo6Error("Cielo device ID is unknown. Call setup() before you send commands.") + request = CieloFrame(device_id, EXPERIMENT_DATA_FILE_GET_COMMAND, payload) + data = bytearray() + async with self._request_lock: + self._require_setup() + await self.io.write(request.to_bytes()) + while len(data) < expected_size: + frame = await self._read_matching_frame(EXPERIMENT_DATA_FILE_GET_COMMAND, device_id) + if not frame.payload: + raise Cielo6Error( + f"Cielo experiment download stopped after {len(data)} of {expected_size} bytes" + ) + data.extend(frame.payload) + if len(data) > expected_size: + raise Cielo6Error( + f"Cielo experiment download exceeded declared size {expected_size}: got {len(data)} bytes" + ) + + result = bytes(data) + # The firmware uses MD5 for protocol-integrity verification. It does not use MD5 for security. + received_md5 = hashlib.md5(result).digest() # noqa: S324 + if received_md5 != expected_md5: + raise Cielo6Error( + f"Invalid Cielo experiment MD5: expected {expected_md5.hex()}, got {received_md5.hex()}" + ) + return result + + async def create_workspace(self, workspace: str) -> None: + """Create an empty workspace if the workspace does not exist.""" + _encode_storage_name(workspace, "workspace") + async with self._operation_lock: + self._require_setup() + await self._create_workspace(workspace) + + async def delete_workspace(self, workspace: str) -> None: + """Delete an empty workspace if the workspace exists.""" + _encode_storage_name(workspace, "workspace") + async with self._operation_lock: + self._require_setup() + await self._delete_workspace(workspace) + + async def delete_program(self, workspace: str, program: str) -> None: + """Permanently delete a stored program if the program exists.""" + workspace_payload = _encode_storage_name(workspace, "workspace") + program_payload = _encode_storage_name(program, "program") + async with self._operation_lock: + self._require_setup() + programs = (await self.request_workspace_summary()).get(workspace) + if programs is None or program not in programs: + return + logger.info("[%s] deleting program %r from workspace %r", self.io.port, program, workspace) + await self._mutation_request( + PROGRAM_DELETE_COMMAND, workspace_payload + b"^" + program_payload + ) + remaining_programs = (await self.request_workspace_summary()).get(workspace, []) + if program in remaining_programs: + raise Cielo6Error( + f"Cielo acknowledged program deletion but {program!r} remained in workspace {workspace!r}" + ) + + async def stop_run(self) -> None: + """Stop the current run and confirm the non-running state.""" + self._stop_requested.set() + async with self._run_transition_lock: + status = await self.request_status() + self._raise_for_firmware_error(status, "preparing to stop the run") + if status.is_running or status.is_paused: + await self._mutation_request(STOP_COMMAND, b"") + status = await self.request_status() + self._raise_for_firmware_error(status, "confirming that the run stopped") + if status.is_running or status.is_paused: + raise Cielo6Error( + "The Cielo acknowledged the stop request but still reports an active run." + ) + if status.work_state is not Cielo6WorkState.IDLE: + raise Cielo6Error( + "The Cielo reported an unexpected state after the stop request " + f"(work_status={status.work_status})." + ) + self._clear_active_run_context() + + async def pause_run(self) -> None: + """Pause the active run and confirm the paused state.""" + async with self._run_transition_lock: + status = await self.request_status() + self._raise_for_firmware_error(status, "preparing to pause the run") + if status.is_paused: + return + if not status.is_running: + raise Cielo6Error("Cannot pause the Cielo because no run is active.") + await self._mutation_request(PAUSE_COMMAND, b"") + status = await self.request_status() + self._raise_for_firmware_error(status, "confirming that the run paused") + if not status.is_paused: + raise Cielo6Error( + "The Cielo acknowledged the pause request but did not report a paused run " + f"(work_status={status.work_status})." + ) + + async def resume_run(self) -> None: + """Resume the paused run and confirm the running state.""" + async with self._run_transition_lock: + status = await self.request_status() + self._raise_for_firmware_error(status, "preparing to resume the run") + if status.is_running: + return + if not status.is_paused: + raise Cielo6Error("Cannot resume the Cielo because no run is paused.") + await self._mutation_request(RESUME_COMMAND, b"") + status = await self.request_status() + self._raise_for_firmware_error(status, "confirming that the run resumed") + if not status.is_running: + raise Cielo6Error( + "The Cielo acknowledged the resume request but did not report an active run " + f"(work_status={status.work_status})." + ) + + async def run_protocol( + self, + protocol: Cielo6ThermalProtocol, + *, + template: Cielo6StoredProgram, + workspace: str, + program_name: str, + result_name: Optional[str] = None, + poll_interval: float = 2.0, + timeout: Optional[float] = None, + ) -> Cielo6ResultFile: + """Compile a thermal protocol from a hardware-read template and run it. + + The workspace is created when missing so the result has a storage home. + The method validates all wait settings before it creates the workspace. + See :meth:`run_experiment` for the run and timeout behavior. + """ + self._validate_run_wait(poll_interval=poll_interval, timeout=timeout) + compiled = protocol.compile(template, workspace=workspace, name=program_name) + resolved_result_name = self._resolve_result_name(result_name) + async with self._operation_lock: + self._require_setup() + return await self._run_experiment( + compiled, + workspace=workspace, + protocol=program_name, + result_name=resolved_result_name, + poll_interval=poll_interval, + timeout=timeout, + ensure_workspace=True, + ) + + async def run_experiment( + self, + program: Cielo6StoredProgram, + *, + workspace: str, + protocol: str, + result_name: Optional[str] = None, + poll_interval: float = 2.0, + timeout: Optional[float] = None, + ) -> Cielo6ResultFile: + """Run a compiled program and download its verified result. + + This method starts a physical run and heats the block. + The method downloads the ``.AZE`` result and verifies its firmware MD5 value. + The method releases the firmware session after success or failure. + + A timeout stops only the PLR wait operation. It does not stop the physical + run. :class:`Cielo6RunTimeoutError` contains the last firmware status. The + backend also keeps the active program for subsequent state requests. + """ + self._validate_run_wait(poll_interval=poll_interval, timeout=timeout) + _encode_storage_name(workspace, "workspace") + _encode_storage_name(protocol, "protocol") + resolved_result_name = self._resolve_result_name(result_name) + async with self._operation_lock: + self._require_setup() + return await self._run_experiment( + program, + workspace=workspace, + protocol=protocol, + result_name=resolved_result_name, + poll_interval=poll_interval, + timeout=timeout, + ) + + async def _create_workspace(self, workspace: str) -> None: + """Create a workspace while the operation lock is held.""" + payload = _encode_storage_name(workspace, "workspace") + if workspace in await self.request_workspace_summary(): + return + logger.info("[%s] creating workspace %r", self.io.port, workspace) + await self._mutation_request(WORKSPACE_CREATE_COMMAND, payload) + if workspace not in await self.request_workspace_summary(): + raise Cielo6Error( + f"Cielo acknowledged workspace creation but {workspace!r} was not present in readback" + ) + + async def _delete_workspace(self, workspace: str) -> None: + """Delete an empty workspace while the operation lock is held.""" + payload = _encode_storage_name(workspace, "workspace") + summary = await self.request_workspace_summary() + programs = summary.get(workspace) + if programs is None: + return + if programs: + raise Cielo6Error( + f"Cannot delete non-empty Cielo workspace {workspace!r}. Delete its programs first." + ) + logger.info("[%s] deleting empty workspace %r", self.io.port, workspace) + await self._mutation_request(WORKSPACE_DELETE_COMMAND, payload) + if workspace in await self.request_workspace_summary(): + raise Cielo6Error( + f"Cielo acknowledged workspace deletion but {workspace!r} remained in readback" + ) + + async def _run_experiment( + self, + program: Cielo6StoredProgram, + *, + workspace: str, + protocol: str, + result_name: str, + poll_interval: float, + timeout: Optional[float], + ensure_workspace: bool = False, + ) -> Cielo6ResultFile: + """Run one experiment while the operation lock is held.""" + self._active_program = program + self._active_run_identity = None + self._run_phase = _Cielo6RunPhase.PREPARING + self._stop_requested.clear() + self._running_data.clear() + self._melting_data.clear() + run_finished = False + try: + async with self._firmware_session() as lock_status: + self._require_idle_for_run(lock_status) + async with self._run_transition_lock: + self._raise_if_stop_requested() + if ensure_workspace: + await self._create_workspace(workspace) + self._raise_if_stop_requested() + await self._initialize() + self._raise_if_stop_requested() + await self._upload_program(program) + self._raise_if_stop_requested() + await self._set_result_path(workspace, protocol, result_name) + self._raise_if_stop_requested() + started_status = await self._start_run() + self._active_run_identity = self._run_identity(started_status) + await self._wait_for_completion(poll_interval=poll_interval, timeout=timeout) + run_finished = True + experiment = await self._find_experiment(workspace, protocol, result_name) + return Cielo6ResultFile.from_bytes(await self.request_experiment_data(experiment)) + except BaseException as error: + if isinstance(error, Cielo6RunTimeoutError) and ( + error.latest_status.is_running or error.latest_status.is_paused + ): + self._active_run_identity = self._run_identity(error.latest_status) + raise + finally: + if self._run_phase is _Cielo6RunPhase.PREPARING or run_finished: + self._clear_active_run_context() + + @asynccontextmanager + async def _firmware_session(self) -> AsyncIterator[Cielo6Status]: + """Acquire one firmware session and release it exactly once.""" + status = await self._lock() + try: + yield status + except BaseException: + try: + await self._disconnect_session() + except Exception: + logger.exception("[%s] failed to release the Cielo session", self.io.port) + raise + else: + await self._disconnect_session() + + @staticmethod + def _resolve_result_name(result_name: Optional[str]) -> str: + """Return a validated result name, generating one when necessary.""" + resolved = result_name or f"PLR-{datetime.now():%Y%m%d-%H%M%S-%f}" + _encode_storage_name(resolved, "result") + return resolved + + async def _request_identity(self, *, require_setup: bool = True) -> Cielo6Identity: + """Request identity while setup owns the device lifecycle.""" + request = CieloFrame(device_id=DISCOVERY_DEVICE_ID, command=VERSION_CHECK_COMMAND) + async with self._request_lock: + if require_setup: + self._require_setup() + await self.io.write(request.to_bytes()) + response = await self._read_exact(IDENTITY_RESPONSE_SIZE) + return Cielo6Identity.from_bytes(response) + + async def _stop_after_setup_failure(self) -> None: + """Close a partially initialized transport without masking the setup error.""" + try: + await self.io.stop() + except Exception: + logger.exception("[%s] failed to close the Cielo after setup failure", self.io.port) + + def _require_setup(self) -> None: + """Require a verified, open Cielo transport.""" + if not self._is_setup: + raise RuntimeError("Cielo 6 is not set up. Call setup() first.") + + async def _lock(self) -> Cielo6Status: + """Acquire the firmware session and return its status.""" + frame = await self._request(SESSION_LOCK_COMMAND) + status = Cielo6Status.from_payload(frame.payload) + self.latest_status = status + return status + + async def _initialize(self) -> None: + """Prepare the instrument for an uploaded experiment.""" + await self._mutation_request(INITIALIZE_COMMAND, b"") + + async def _upload_program(self, program: Cielo6StoredProgram) -> None: + """Transfer a compiled program without storage on the instrument.""" + self._require_setup() + device_id = self.device_id + if device_id is None: + raise Cielo6Error("Cielo device ID is unknown. Call setup() before you send commands.") + data = program.to_bytes() + assert len(data) == PROGRAM_SIZE + async with self._request_lock: + self._require_setup() + for index in range(PROGRAM_CHUNK_COUNT): + chunk = data[index * PROGRAM_CHUNK_SIZE : (index + 1) * PROGRAM_CHUNK_SIZE] + request = CieloFrame(device_id, PROGRAM_UPLOAD_COMMAND, bytes([index]) + chunk) + await self.io.write(request.to_bytes()) + response = await self._read_matching_frame(PROGRAM_UPLOAD_COMMAND, device_id) + self._validate_mutation_result(response) + + async def _set_result_path(self, workspace: str, protocol: str, result_name: str) -> None: + """Set the storage path for the completed experiment.""" + payload = b"^".join( + ( + _encode_storage_name(workspace, "workspace"), + _encode_storage_name(protocol, "protocol"), + _encode_storage_name(result_name, "result"), + ) + ) + await self._mutation_request(RESULT_PATH_SET_COMMAND, payload) + + async def _start_run(self, *, wait: float = 1.0, attempts: int = 120) -> Cielo6Status: + """Start the uploaded program and confirm the new run state.""" + if wait < 0: + raise ValueError("Cielo run confirmation wait cannot be negative") + if attempts < 1: + raise ValueError("Cielo run confirmation attempts must be at least 1") + + status_before_run = await self.request_status() + self._require_idle_for_run(status_before_run) + device_id = self.device_id + if device_id is None: + raise Cielo6Error("Cielo device ID is unknown. Call setup() before you send commands.") + + request = CieloFrame(device_id=device_id, command=RUN_COMMAND) + async with self._request_lock: + self._raise_if_stop_requested() + self._run_phase = _Cielo6RunPhase.DISPATCHED + await self.io.write(request.to_bytes()) + + await asyncio.sleep(wait) + status = status_before_run + for _ in range(attempts): + status = await self.request_status() + self._raise_for_firmware_error(status, "confirming that the run started") + if status.is_running or status.is_paused: + return status + if self._is_new_completed_run(status_before_run, status): + return status + await asyncio.sleep(wait) + raise Cielo6RunTimeoutError(status) + + @staticmethod + def _is_new_completed_run(previous: Cielo6Status, current: Cielo6Status) -> bool: + """Return true if status identifies a new run that is complete.""" + if current.work_status != WORK_STATUS_IDLE or not current.finished: + return False + previous_identity = ( + previous.run_id, + previous.sample_id, + previous.file_name, + previous.finished, + ) + current_identity = (current.run_id, current.sample_id, current.file_name, current.finished) + return current_identity != previous_identity + + async def _disconnect_session(self) -> None: + """Release the session that :meth:`_lock` acquired.""" + await self._mutation_request(DISCONNECT_COMMAND, b"") + + async def _wait_for_completion( + self, *, poll_interval: float, timeout: Optional[float] + ) -> Cielo6Status: + """Wait for authoritative completion status from the firmware.""" + deadline = None if timeout is None else time.monotonic() + timeout + completion_settle_deadline: Optional[float] = None + while True: + status = await self.request_status() + self._raise_for_firmware_error(status, "waiting for run completion") + now = time.monotonic() + if status.work_state is Cielo6WorkState.IDLE: + if status.finished: + return status + if completion_settle_deadline is None: + completion_settle_deadline = now + _COMPLETION_SETTLE_TIMEOUT + elif now >= completion_settle_deadline: + raise Cielo6Error( + "The Cielo remained idle without reporting run completion " + f"(work_status={status.work_status})." + ) + elif status.is_running or status.is_paused: + completion_settle_deadline = None + else: + raise Cielo6Error( + "The Cielo reported an unexpected state while waiting for run completion " + f"(work_status={status.work_status})." + ) + if deadline is not None and now >= deadline: + raise Cielo6RunTimeoutError(status) + await asyncio.sleep( + poll_interval + if completion_settle_deadline is None + else min(poll_interval, _COMPLETION_SETTLE_POLL_INTERVAL) + ) + + @staticmethod + def _validate_run_wait(*, poll_interval: float, timeout: Optional[float]) -> None: + """Validate wait settings before PLR sends a command that changes the instrument.""" + if poll_interval <= 0: + raise ValueError("Cielo poll_interval must be greater than 0 seconds") + if timeout is not None and timeout <= 0: + raise ValueError("Cielo timeout must be greater than 0 seconds") + + def _raise_if_stop_requested(self) -> None: + """Cancel preparation before the run command crosses the hardware boundary.""" + if self._stop_requested.is_set(): + raise Cielo6Error("The Cielo run was stopped before dispatch.") + + @staticmethod + def _raise_for_firmware_error(status: Cielo6Status, operation: str) -> None: + """Raise when status contains a firmware-defined error state.""" + if status.work_state.is_error: + raise Cielo6FirmwareStateError(operation, status) + + @classmethod + def _require_idle_for_run(cls, status: Cielo6Status) -> None: + """Require a safe idle state before a run-changing command.""" + cls._raise_for_firmware_error(status, "preparing to start a run") + if status.work_state is Cielo6WorkState.IDLE: + return + if status.is_running or status.is_paused: + raise Cielo6Error( + "The Cielo already has an active run. Stop that run before you start another run." + ) + raise Cielo6Error( + f"The Cielo is not idle and cannot start a run (work_status={status.work_status})." + ) + + @staticmethod + def _run_identity(status: Cielo6Status) -> tuple[tuple[int, int], tuple[int, int], str]: + """Return the firmware fields that identify one run.""" + return status.run_id, status.sample_id, status.file_name + + def _clear_active_run_context(self) -> None: + """Discard local context after authoritative terminal readback.""" + self._active_program = None + self._active_run_identity = None + self._run_phase = _Cielo6RunPhase.NONE + + async def _find_experiment( + self, workspace: str, protocol: str, result_name: str + ) -> Cielo6ExperimentInfo: + """Find one completed result in the firmware summary.""" + for experiment in await self.request_experiment_summary(): + if ( + experiment.workspace == workspace + and experiment.protocol == protocol + and experiment.name == result_name + ): + return experiment + raise Cielo6Error(f"Completed run {result_name!r} was not present in the experiment summary") + + async def _request(self, command: int, payload: bytes = b"") -> CieloFrame: + """Send one command and return its matching response.""" + self._require_setup() + device_id = self.device_id + if device_id is None: + raise Cielo6Error("Cielo device ID is unknown. Call setup() before you send commands.") + request = CieloFrame(device_id=device_id, command=command, payload=payload) + async with self._request_lock: + self._require_setup() + await self.io.write(request.to_bytes()) + return await self._read_matching_frame(command, device_id) + + async def _mutation_request(self, command: int, payload: bytes) -> None: + """Send one mutation command and validate its result.""" + self._validate_mutation_result(await self._request(command, payload)) + + @staticmethod + def _validate_mutation_result(response: CieloFrame) -> None: + """Validate the execution result in a mutation response.""" + if len(response.payload) < 2: + raise Cielo6Error("Cielo mutation response does not contain an execution result") + result = int.from_bytes(response.payload[-2:], byteorder="little") + if result != EXEC_SUCCESSFUL: + raise Cielo6Error(f"Cielo firmware operation failed with result 0x{result:04x}") + + async def _read_frame(self) -> CieloFrame: + """Read one frame, recovering from unrelated bytes left in the serial stream.""" + discarded = 0 + while True: + while len(self._receive_buffer) < FRAME_HEADER_SIZE: + self._receive_buffer.extend( + await self._read_exact(FRAME_HEADER_SIZE - len(self._receive_buffer)) + ) + + frame_size = FRAME_OVERHEAD + self._receive_buffer[12] + while len(self._receive_buffer) < frame_size: + self._receive_buffer.extend(await self._read_exact(frame_size - len(self._receive_buffer))) + + candidate = bytes(self._receive_buffer[:frame_size]) + try: + frame = CieloFrame.from_bytes(candidate) + except Cielo6Error: + del self._receive_buffer[0] + discarded += 1 + if discarded > FRAME_OVERHEAD + MAX_PAYLOAD_SIZE: + raise Cielo6Error("Could not find a valid Cielo frame in the received serial data") + continue + + del self._receive_buffer[:frame_size] + if discarded: + logger.warning("[%s] discarded %d byte(s) before a valid frame", self.io.port, discarded) + return frame + + async def _read_matching_frame(self, command: int, device_id: str) -> CieloFrame: + """Read until the requested response arrives, retaining unsolicited status.""" + while True: + response = await self._read_frame() + if response.device_id != device_id: + raise Cielo6Error(f"Response device ID {response.device_id!r} does not match {device_id!r}") + if response.command == command: + return response + if response.command == STATUS_QUERY_COMMAND: + self.latest_status = Cielo6Status.from_payload(response.payload) + logger.debug("[%s] received unsolicited Cielo status", self.io.port) + continue + if response.command == RUNNING_EXPERIMENT_DATA_UPLOAD_COMMAND: + if len(response.payload) < 5: + raise Cielo6Error("Cielo running-data payload is too short to contain a data type") + if response.payload[4] == RUNNING_DATA_TYPE_NORMAL: + self._running_data.append(Cielo6RunningData.from_payload(response.payload)) + elif response.payload[4] == RUNNING_DATA_TYPE_MELTING: + self._melting_data.append(Cielo6MeltingData.from_payload(response.payload)) + else: + raise Cielo6Error(f"Unsupported Cielo running-data type: {response.payload[4]}") + logger.debug("[%s] retained decoded Cielo running-data frame", self.io.port) + continue + if response.command == RUN_COMMAND: + # The firmware can send this frame after it accepts command 0x0B03. + # The vendor dispatcher ignores this payload. Status command 0x0B02 + # supplies the authoritative run result. + logger.debug("[%s] ignored delayed Cielo run response", self.io.port) + continue + raise Cielo6Error( + f"Unexpected Cielo response command while waiting for 0x{command:04x}: " + f"0x{response.command:04x}" + ) + + async def _read_exact(self, size: int) -> bytes: + """Read an exact byte count or raise a protocol error.""" + data = bytearray() + while len(data) < size: + chunk = await self.io.read(size - len(data)) + if not chunk: + raise Cielo6Error(f"Incomplete Cielo response: expected {size} bytes, got {len(data)}") + data.extend(chunk) + return bytes(data) + + +__all__ = [ + "Cielo6", + "Cielo6AmplificationResult", + "Cielo6CollectionPoint", + "Cielo6Error", + "Cielo6ExperimentInfo", + "Cielo6FirmwareStateError", + "Cielo6Identity", + "Cielo6MeltingData", + "Cielo6MeltingCurveResult", + "Cielo6MeltRecord", + "Cielo6ResultFile", + "Cielo6RunTimeoutError", + "Cielo6RunState", + "Cielo6RunningData", + "Cielo6StoredProgram", + "Cielo6StoredProgramStep", + "Cielo6Status", + "Cielo6ThermalProtocol", + "Cielo6ThermalStep", + "Cielo6WorkState", +] diff --git a/pylabrobot/azure_biosystems/cielo6_tests.py b/pylabrobot/azure_biosystems/cielo6_tests.py new file mode 100644 index 00000000000..29f2c1c3657 --- /dev/null +++ b/pylabrobot/azure_biosystems/cielo6_tests.py @@ -0,0 +1,2376 @@ +import asyncio +import hashlib +import json +import struct +import unittest +import zlib +from dataclasses import replace +from datetime import timedelta +from typing import cast +from unittest.mock import AsyncMock, patch + +from pylabrobot.azure_biosystems.cielo6 import ( + DISCONNECT_COMMAND, + DISCOVERY_DEVICE_ID, + EXEC_SUCCESSFUL, + EXPERIMENT_DATA_FILE_GET_COMMAND, + EXPERIMENT_DATA_FILE_INFO_GET_COMMAND, + EXPERIMENT_DATA_SIZE, + EXPERIMENT_DATA_SUMMARY_GET_COMMAND, + INITIALIZE_COMMAND, + PAUSE_COMMAND, + PROGRAM_CHUNK_SIZE, + PROGRAM_DELETE_COMMAND, + PROGRAM_GET_COMMAND, + PROGRAM_UPLOAD_COMMAND, + RESULT_PATH_SET_COMMAND, + RESUME_COMMAND, + RUN_COMMAND, + RUNNING_DATA_TYPE_NORMAL, + RUNNING_EXPERIMENT_DATA_UPLOAD_COMMAND, + RUNNING_EXPERIMENT_INFOS_GET_COMMAND, + SESSION_LOCK_COMMAND, + STATUS_QUERY_COMMAND, + STOP_COMMAND, + VERSION_CHECK_COMMAND, + WORK_STATUS_IDLE, + WORK_STATUS_PAUSED, + WORK_STATUS_RUNNING, + WORKSPACE_CREATE_COMMAND, + WORKSPACE_DELETE_COMMAND, + WORKSPACE_SUMMARY_GET_COMMAND, + Cielo6, + Cielo6CollectionPoint, + Cielo6Error, + Cielo6ExperimentInfo, + Cielo6FirmwareStateError, + Cielo6Identity, + Cielo6MeltingData, + Cielo6MeltRecord, + Cielo6ResultFile, + Cielo6RunningData, + Cielo6RunTimeoutError, + Cielo6Status, + Cielo6StoredProgram, + Cielo6ThermalProtocol, + Cielo6ThermalStep, + Cielo6WorkState, + CieloFrame, +) +from pylabrobot.io.serial import Serial + + +def make_device(response: bytes = b"", *, is_setup: bool = True) -> Cielo6: + io = AsyncMock(spec=Serial) + io.port = "FAKE" + receive_buffer = bytearray(response) + + async def read(size: int = 1) -> bytes: + chunk = bytes(receive_buffer[:size]) + del receive_buffer[:size] + return chunk + + io.read.side_effect = read + with patch("pylabrobot.azure_biosystems.cielo6.Serial", return_value=io): + device = Cielo6(port="FAKE", device_id="12345678") + device._is_setup = is_setup + return device + + +def written_frames(device: Cielo6) -> list[CieloFrame]: + return [ + CieloFrame.from_bytes(call.args[0]) for call in cast(AsyncMock, device.io.write).call_args_list + ] + + +def identity_response(device_id: str = "12345678", name: str = "AZURE CIELO 6") -> bytes: + return ( + b"Azure QPCR SeriesID:" + + device_id.encode("ascii") + + b"Name:" + + name.ljust(20).encode("ascii") + + b"&USB" + ) + + +def program_bytes() -> bytes: + header = struct.pack( + "<4s6s6s9H6BHHf6HI", + b"P123", + bytes([1, 0, 1, 0, 0, 0]), + bytes([1, 2, 3, 4, 5, 6]), + 2, + 1, + 1050, + 30, + 1, + 25, + 0, + 3, + 2026, + 8, + 29, + 10, + 30, + 1, + 0, + 650, + 950, + 0.2, + 100, + 200, + 300, + 400, + 500, + 600, + 0, + ) + step = struct.pack( + " bytes: + frames = [ + CieloFrame( + "12345678", WORKSPACE_SUMMARY_GET_COMMAND, struct.pack(" bytes: + frames = [ + CieloFrame( + "12345678", EXPERIMENT_DATA_SUMMARY_GET_COMMAND, struct.pack(" bytes: + return struct.pack( + "<16s2I2IHHHhHHIIIhhHH16h16h", + b"RUN\0".ljust(16, b"\0"), + 11, + 12, + 21, + 22, + 1, + work_status, + 3, + 10450, + current_step, + current_cycle, + current_time_remaining, + program_time_total, + program_time_remaining, + 2250, + 3100, + 20, + is_finished, + *range(4000, 4016), + *range(5000, 5016), + ) + + +def running_data_payload( + index: int = 1, step: int = 3, position: int = 4, channel: int = 0, cycle: int = 1 +) -> bytes: + return ( + struct.pack(" bytes: + """Build a synthetic .AZE envelope with the verified firmware layout.""" + magic = b"Azure Data\x00" + datainfo = { + "Device id:": "QI6-0000", + "Device name:": "AZURE CIELO 6", + "Instrument software Version:": "1.4.4.0", + "Instrument control module software Version:": "1.1.3.7", + "Instrument heater module software Version:": "1.2.4.1", + "Program:": "PLR-Short-2C", + "Workspace:": "Public", + "Run start time:": "2026-08-29_13:08:36", + "Run end time:": "2026-08-29_13:12:35", + "Gain:": 10, + **{f"Channel {channel}expose time": 50 for channel in range(1, 7)}, + "Channel1": ["default", "FAM", "SYBR"], + "Channel2": ["default", "HEX", "VIC"], + "Channel3": ["default", "TAMRA"], + "Channel4": ["default", "ROX", "TEXAS RED"], + "Channel5": ["CY5", "default"], + "Channel6": ["default", "Quasar 705"], + "FAM": ["1.0", "0.0725", "0", "0", "0", "0"], + "Block1Temp": [2803, 2804], + "Block2Temp": [2806, 2807], + "Block3Temp": [2802, 2803], + "Sample1Temp": [2803, 2804], + "Sample2Temp": [2806, 2807], + "Sample3Temp": [2802, 2803], + "HotlidTemp": [3130, 3160], + } + datainfo_bytes = json.dumps(datainfo).encode("utf-8") + + def channel_values(channel: int, spike: float) -> tuple[float, ...]: + values = [float(channel + position / 100) for position in range(96)] + values[0] = spike # A1 + values[8] = spike / 2 # A2 + values[95] = spike / 3 # H12 + return tuple(values) + + def record(step: int, cycle: int, spike: float) -> bytes: + return struct.pack(" bytes: + magic_end = 4 + int.from_bytes(data[:4], "big") + program_end = magic_end + 4 + int.from_bytes(data[magic_end : magic_end + 4], "big") + metadata_length = int.from_bytes(data[program_end : program_end + 4], "big") + metadata_start = program_end + 4 + encoded = json.dumps(metadata).encode("utf-8") + return ( + data[:program_end] + + len(encoded).to_bytes(4, "big") + + encoded + + data[metadata_start + metadata_length :] + ) + + +def result_metadata(data: bytes) -> dict[str, object]: + magic_end = 4 + int.from_bytes(data[:4], "big") + program_end = magic_end + 4 + int.from_bytes(data[magic_end : magic_end + 4], "big") + metadata_length = int.from_bytes(data[program_end : program_end + 4], "big") + metadata_start = program_end + 4 + return cast( + dict[str, object], json.loads(data[metadata_start : metadata_start + metadata_length]) + ) + + +def replace_result_melting_data(data: bytes, records: bytes) -> bytes: + """Replace the binary melting section in a synthetic result file.""" + magic_end = 4 + int.from_bytes(data[:4], "big") + program_end = magic_end + 4 + int.from_bytes(data[magic_end : magic_end + 4], "big") + metadata_length = int.from_bytes(data[program_end : program_end + 4], "big") + melting_length_offset = program_end + 4 + metadata_length + return ( + data[:melting_length_offset] + + len(records).to_bytes(4, "big") + + records + + data[melting_length_offset + 4 :] + ) + + +class CieloFrameTests(unittest.TestCase): + def test_status_query_matches_vendor_crc_and_tail(self) -> None: + frame = CieloFrame(device_id="99999999", command=STATUS_QUERY_COMMAND) + self.assertEqual( + frame.to_bytes(), + bytes.fromhex("39 39 39 39 39 39 39 39 02 0b 00 00 00 63 28 55 aa"), + ) + + def test_round_trip_with_payload(self) -> None: + frame = CieloFrame(device_id="12345678", command=0x0B02, payload=b"abc") + self.assertEqual(CieloFrame.from_bytes(frame.to_bytes()), frame) + + def test_rejects_bad_crc(self) -> None: + encoded = bytearray(CieloFrame("12345678", 0x0B02).to_bytes()) + encoded[-4] ^= 1 + with self.assertRaisesRegex(Cielo6Error, "CRC"): + CieloFrame.from_bytes(bytes(encoded)) + + def test_rejects_bad_tail(self) -> None: + encoded = bytearray(CieloFrame("12345678", 0x0B02).to_bytes()) + encoded[-1] = 0 + with self.assertRaisesRegex(Cielo6Error, "tail"): + CieloFrame.from_bytes(bytes(encoded)) + + def test_rejects_invalid_device_id(self) -> None: + with self.assertRaisesRegex(ValueError, "8 ASCII"): + CieloFrame("short", 0x0B02) + + def test_rejects_oversized_payload(self) -> None: + with self.assertRaisesRegex(ValueError, "255"): + CieloFrame("12345678", 0x0B02, bytes(256)) + + +class CieloStatusTests(unittest.TestCase): + def test_decodes_complete_128_byte_status(self) -> None: + payload = struct.pack( + "<16s2I2IHHHhHHIIIhhHH16h16h", + b"RUN-001\0".ljust(16, b"\0"), + 11, + 12, + 21, + 22, + 1, + 2, + 3, + 10450, + 4, + 5, + 60, + 3600, + 3540, + 2250, + 3100, + 25, + 0, + *range(4000, 4016), + *range(5000, 5016), + ) + + status = Cielo6Status.from_payload(payload) + + self.assertEqual(status.file_name, "RUN-001") + self.assertEqual(status.run_id, (11, 12)) + self.assertEqual(status.sample_id, (21, 22)) + self.assertEqual(status.hot_lid_temperature_raw, 10450) + self.assertEqual(status.block_temperatures_raw, tuple(range(4000, 4016))) + self.assertEqual(status.sample_temperatures, tuple(value / 100 for value in range(5000, 5016))) + self.assertEqual(status.hot_lid_temperature, 104.5) + self.assertEqual(status.environment_temperature, 22.5) + self.assertEqual(status.radiator_temperature, 31.0) + self.assertEqual(status.block_temperatures, tuple(value / 100 for value in range(4000, 4016))) + self.assertEqual(status.work_state, Cielo6WorkState.UNKNOWN) + self.assertEqual(status.progress, 1 / 60) + + def test_exposes_typed_run_state_without_hiding_unknown_firmware_values(self) -> None: + running = Cielo6Status.from_payload(status_payload(WORK_STATUS_RUNNING)) + unknown = Cielo6Status.from_payload(status_payload(9999)) + + self.assertEqual(running.work_state, Cielo6WorkState.RUNNING) + self.assertTrue(running.is_running) + self.assertFalse(running.is_paused) + self.assertEqual(unknown.work_state, Cielo6WorkState.UNKNOWN) + self.assertTrue(Cielo6WorkState.RUN_ERROR.is_error) + self.assertFalse(Cielo6WorkState.RUNNING.is_error) + + def test_progress_is_absent_without_firmware_total_and_clamped(self) -> None: + absent = Cielo6Status.from_payload( + status_payload(program_time_total=0, program_time_remaining=0) + ) + overrun = Cielo6Status.from_payload( + status_payload(program_time_total=100, program_time_remaining=150) + ) + + self.assertIsNone(absent.progress) + self.assertEqual(overrun.progress, 0.0) + + def test_rejects_wrong_payload_size(self) -> None: + with self.assertRaisesRegex(Cielo6Error, "expected 128"): + Cielo6Status.from_payload(bytes(127)) + + +class CieloIdentityTests(unittest.TestCase): + def test_decodes_captured_response_shape(self) -> None: + identity = Cielo6Identity.from_bytes(identity_response()) + self.assertEqual(identity.device_id, "12345678") + self.assertEqual(identity.name, "AZURE CIELO 6") + self.assertEqual(identity.transport, "USB") + + def test_rejects_wrong_transport(self) -> None: + with self.assertRaisesRegex(Cielo6Error, "transport"): + Cielo6Identity.from_bytes(identity_response()[:-4] + b"&TCP") + + +class CieloProgramTests(unittest.TestCase): + def test_decodes_complete_program_and_crc(self) -> None: + encoded = program_bytes() + program = Cielo6StoredProgram.from_bytes(encoded) + self.assertEqual(program.identifier, b"P123") + self.assertEqual(program.name, "Test") + self.assertEqual(program.workspace, "Public") + self.assertEqual(program.step_count, 2) + self.assertEqual(len(program.steps), 2) + self.assertEqual(program.steps[0].name, 0x5AF1) + self.assertEqual(program.steps[0].temperatures_raw, tuple(range(6000, 6016))) + self.assertEqual(program.to_bytes(), encoded) + + def test_rejects_bad_program_crc(self) -> None: + data = bytearray(program_bytes()) + data[100] ^= 1 + with self.assertRaisesRegex(Cielo6Error, "CRC32"): + Cielo6StoredProgram.from_bytes(bytes(data)) + + def test_preserves_inactive_step_bytes(self) -> None: + data = bytearray(program_bytes()) + data[128] = 0xA5 + data[-4:] = zlib.crc32(data[:-4]).to_bytes(4, byteorder="little") + program = Cielo6StoredProgram.from_bytes(bytes(data)) + self.assertEqual(program.to_bytes(), bytes(data)) + + def test_crc32_is_derived_from_current_program_content(self) -> None: + program = Cielo6StoredProgram.from_bytes(program_bytes()) + renamed = replace(program, name="Renamed") + self.assertNotEqual(renamed.crc32, program.crc32) + self.assertEqual(renamed.crc32, zlib.crc32(renamed.to_bytes()[:-4])) + + def test_compiles_short_qpcr_protocol_from_hardware_template(self) -> None: + template = Cielo6StoredProgram.from_bytes(program_bytes()) + protocol = Cielo6ThermalProtocol( + steps=( + Cielo6ThermalStep(95, 30), + Cielo6ThermalStep(95, 5), + Cielo6ThermalStep(60, 15, collect_fluorescence=True), + ), + repeat_from_step=1, + cycles=2, + sample_volume=20, + ) + + compiled = protocol.compile(template, workspace="Public", name="PLR-Short-2C") + + self.assertEqual(compiled.name, "PLR-Short-2C") + self.assertEqual(compiled.workspace, "Public") + self.assertEqual(compiled.channels, template.channels) + self.assertEqual(compiled.positions, template.positions) + self.assertEqual(compiled.hot_lid_temperature_raw, template.hot_lid_temperature_raw) + self.assertEqual(compiled.melting_curve_mode, 0) + self.assertEqual(compiled.sample_volume, 20) + self.assertEqual(compiled.step_count, 4) + self.assertEqual(compiled.steps[2].temperatures_raw[:3], (6000, 6000, 6000)) + self.assertEqual(compiled.steps[2].collection_mode, 1) + self.assertEqual(compiled.steps[3].function, 4) + self.assertEqual(compiled.steps[3].to_step, 2) + self.assertEqual(compiled.steps[3].goto_times, 1) + self.assertEqual(compiled.thermal_step_count, 3) + self.assertEqual(compiled.cycle_count, 2) + self.assertEqual(compiled.step_target_temperatures(2)[:3], (60.0,) * 3) + self.assertEqual(compiled.step_target_temperatures(2)[3:], (0.0,) * 13) + self.assertEqual( + Cielo6StoredProgram.from_bytes(compiled.to_bytes()).to_bytes(), compiled.to_bytes() + ) + + def test_rejects_cycles_without_repeat_group(self) -> None: + with self.assertRaisesRegex(ValueError, "repeat_from_step"): + Cielo6ThermalProtocol(steps=(Cielo6ThermalStep(60, 10),), cycles=2) + + +class CieloRunningDataTests(unittest.TestCase): + def test_decodes_normal_payload(self) -> None: + payload = ( + struct.pack(" None: + payload = ( + struct.pack(" None: + payload = bytearray(running_data_payload()) + payload[4] = 3 + with self.assertRaisesRegex(Cielo6Error, "data type"): + Cielo6RunningData.from_payload(bytes(payload)) + + def test_rejects_truncated_payload(self) -> None: + with self.assertRaisesRegex(Cielo6Error, "expected 77"): + Cielo6RunningData.from_payload(running_data_payload()[:-1]) + + +class CieloResultFileTests(unittest.TestCase): + def test_parses_verified_layout_and_well_order(self) -> None: + result = Cielo6ResultFile.from_bytes(result_file_bytes()) + + self.assertEqual(result.device_id, "QI6-0000") + self.assertEqual(result.workspace, "Public") + self.assertEqual(result.program, "PLR-Short-2C") + self.assertEqual(result.stored_program.name, "Test") + self.assertEqual(result.stored_program.workspace, "Public") + self.assertEqual(result.gain, 10) + self.assertEqual(result.exposure_times, (50,) * 6) + self.assertEqual(result.dyes[0], ("default", "FAM", "SYBR")) + self.assertEqual(result.temperature_curves["Block1Temp"], (2803, 2804)) + self.assertEqual(len(result.collection_points), 2) + self.assertEqual(len(result.processed_collection_points), 2) + self.assertEqual(result.collection_points[0].step, 3) + self.assertEqual(result.collection_points[0].cycle, 1) + self.assertEqual(result.collection_points[1].cycle, 2) + + point = result.collection_points[0] + self.assertEqual(point.well_value(0, 1, 0), 7.5) # A1 + self.assertEqual(point.well_value(0, 2, 0), 3.75) # A2 + self.assertEqual(point.well_value(7, 12, 0), 2.5) # H12 + self.assertEqual(point.channels[0][0], 7.5) + self.assertEqual(point.channels[0][8], 3.75) + self.assertEqual(point.channels[0][95], 2.5) + + def test_collection_point_validates_shape(self) -> None: + with self.assertRaisesRegex(ValueError, "6 channels"): + Cielo6CollectionPoint(step=3, cycle=1, channels=((0.0,) * 96,)) + with self.assertRaisesRegex(ValueError, "96"): + Cielo6CollectionPoint(step=3, cycle=1, channels=((0.0,) * 5,) * 6) + with self.assertRaisesRegex(ValueError, "row"): + Cielo6CollectionPoint(step=3, cycle=1, channels=((0.0,) * 96,) * 6).well_value(8, 1, 0) + with self.assertRaisesRegex(ValueError, "column"): + Cielo6CollectionPoint(step=3, cycle=1, channels=((0.0,) * 96,) * 6).well_value(0, 13, 0) + + def test_rejects_bad_magic(self) -> None: + data = bytearray(result_file_bytes()) + data[5] = 0x58 + with self.assertRaisesRegex(Cielo6Error, "magic"): + Cielo6ResultFile.from_bytes(bytes(data)) + + def test_rejects_invalid_experiment_length(self) -> None: + base = result_file_bytes() + length_offset = len(base) - 4 * EXPERIMENT_DATA_SIZE - 8 + cases = ((-2, "length"), (EXPERIMENT_DATA_SIZE + 1, "multiple")) + for value, message in cases: + with self.subTest(value=value): + data = bytearray(base) + data[length_offset : length_offset + 4] = value.to_bytes(4, "big", signed=True) + with self.assertRaisesRegex(Cielo6Error, message): + Cielo6ResultFile.from_bytes(bytes(data)) + + def test_accepts_empty_amplification_sections(self) -> None: + base = result_file_bytes() + length_offset = len(base) - 4 * EXPERIMENT_DATA_SIZE - 8 + + for empty_length in (-1, 0): + with self.subTest(empty_length=empty_length): + encoded_length = empty_length.to_bytes(4, "big", signed=True) + data = base[:length_offset] + encoded_length + encoded_length + + result = Cielo6ResultFile.from_bytes(data) + + self.assertEqual(result.collection_points, ()) + self.assertEqual(result.processed_collection_points, ()) + self.assertEqual(result.to_amplification_results(), []) + self.assertEqual(result.to_processed_amplification_results(), []) + + def test_rejects_truncated_section_with_domain_error(self) -> None: + with self.assertRaisesRegex(Cielo6Error, "Incomplete.*amplification data"): + Cielo6ResultFile.from_bytes(result_file_bytes()[:-1]) + + def test_rejects_non_object_metadata(self) -> None: + with self.assertRaisesRegex(Cielo6Error, "expected an object"): + Cielo6ResultFile.from_bytes(replace_result_metadata(result_file_bytes(), [])) + + def test_rejects_invalid_json_metadata(self) -> None: + data = bytearray(replace_result_metadata(result_file_bytes(), {})) + magic_end = 4 + int.from_bytes(data[:4], "big") + program_end = magic_end + 4 + int.from_bytes(data[magic_end : magic_end + 4], "big") + data[program_end + 4] = 0xFF + + with self.assertRaisesRegex(Cielo6Error, "JSON metadata"): + Cielo6ResultFile.from_bytes(bytes(data)) + + def test_rejects_invalid_optional_metadata_values(self) -> None: + cases = ( + ({"FAM": ["invalid"]}, "'FAM' value"), + ({"FAM": "not-a-list"}, "expected an array"), + ({"Block1Temp": ["invalid"]}, "'Block1Temp' value"), + ({"Channel1": "FAM"}, "expected a text array"), + ({"Gain:": "invalid"}, "gain or exposure"), + ) + data = result_file_bytes() + raw_metadata = result_metadata(data) + + for update, message in cases: + with self.subTest(update=update): + metadata = {**raw_metadata, **update} + with self.assertRaisesRegex(Cielo6Error, message): + Cielo6ResultFile.from_bytes(replace_result_metadata(data, metadata)) + + def test_uses_empty_values_for_missing_optional_metadata(self) -> None: + data = result_file_bytes() + metadata = result_metadata(data) + del metadata["FAM"] + del metadata["Block1Temp"] + + result = Cielo6ResultFile.from_bytes(replace_result_metadata(data, metadata)) + + self.assertNotIn("FAM", result.dye_crosstalk_coefficients) + self.assertEqual(result.temperature_curves["Block1Temp"], ()) + + def test_rejects_missing_required_metadata(self) -> None: + data = result_file_bytes() + metadata = result_metadata(data) + del metadata["Device id:"] + + with self.assertRaisesRegex(Cielo6Error, "missing device_id"): + Cielo6ResultFile.from_bytes(replace_result_metadata(data, metadata)) + + def test_rejects_invalid_melting_and_processed_lengths(self) -> None: + base = result_file_bytes() + magic_end = 4 + int.from_bytes(base[:4], "big") + program_end = magic_end + 4 + int.from_bytes(base[magic_end : magic_end + 4], "big") + metadata_length = int.from_bytes(base[program_end : program_end + 4], "big") + melting_length_offset = program_end + 4 + metadata_length + experiment_length_offset = melting_length_offset + 4 + experiment_length = int.from_bytes( + base[experiment_length_offset : experiment_length_offset + 4], "big" + ) + processed_length_offset = experiment_length_offset + 4 + experiment_length + + cases = ( + (melting_length_offset, -2, "melting data length"), + (melting_length_offset, 1, "melting data length"), + (processed_length_offset, -2, "processed amplification data length"), + (processed_length_offset, 1, "processed amplification data length"), + ) + for offset, value, message in cases: + with self.subTest(value=value, message=message): + data = bytearray(base) + data[offset : offset + 4] = value.to_bytes(4, "big", signed=True) + with self.assertRaisesRegex(Cielo6Error, message): + Cielo6ResultFile.from_bytes(bytes(data)) + + def test_rejects_trailing_bytes(self) -> None: + with self.assertRaisesRegex(Cielo6Error, "trailing byte"): + Cielo6ResultFile.from_bytes(result_file_bytes() + b"unexpected") + + def test_keeps_processed_amplification_separate_from_raw_data(self) -> None: + data = bytearray(result_file_bytes()) + processed_data_start = len(data) - 2 * EXPERIMENT_DATA_SIZE + struct.pack_into(" None: + base = result_file_bytes() + records = ( + struct.pack(" None: + records = ( + struct.pack(" None: + cases = ( + ("invalid", "expected an array"), + ([1.0] * 95, "value count"), + (["invalid"] * 96, "value"), + ) + records = struct.pack(" None: + with self.assertRaisesRegex(ValueError, "channel_index"): + Cielo6MeltRecord(temperature_raw=6000, values=(1.0,) * 96, channel_index=6) + + def test_to_amplification_csv_matches_oem_export_shape(self) -> None: + result = Cielo6ResultFile.from_bytes(result_file_bytes()) + + csv = result.to_amplification_csv() + self.assertTrue(csv.startswith("\ufeff")) + lines = csv.splitlines() + lines[0] = lines[0].lstrip("\ufeff") + + self.assertTrue(lines[0].startswith("Data,A1,B1,C1,D1,E1,F1,G1,H1,A2,")) + self.assertTrue(lines[0].endswith("H12,")) + # The template enables channels 0 and 2. Step 3 has two collection points. + self.assertEqual(lines[1], "Step3Channel1") + self.assertTrue(lines[2].startswith("1,7.5,0.01,0.02,0.03,0.04,0.05,0.06,0.07,3.75,")) + self.assertTrue(lines[3].startswith("2,7.1,0.01,0.02,0.03,0.04,0.05,0.06,0.07,3.55,")) + self.assertEqual(lines[4], "Step3Channel3") + self.assertEqual(len(lines[5].split(",")), 98) + + def test_converts_amplification_records_to_plr_plate_data(self) -> None: + result = Cielo6ResultFile.from_bytes(result_file_bytes()) + + amplification_results = result.to_amplification_results() + + self.assertEqual(len(amplification_results), 4) + first = amplification_results[0] + self.assertEqual(first.cycle, 1) + self.assertEqual(first.step, 3) + self.assertEqual(first.channel_index, 0) + self.assertEqual(len(first.data), 8) + self.assertEqual(len(first.data[0]), 12) + self.assertEqual(first.data[0][0], 7.5) # A1 + self.assertEqual(first.data[0][1], 3.75) # A2 + self.assertEqual(first.data[7][11], 2.5) # H12 + self.assertEqual(amplification_results[1].channel_index, 2) + self.assertEqual(len(result.to_processed_amplification_results()), 4) + + def test_converts_melting_records_to_plr_plate_data(self) -> None: + result = replace( + Cielo6ResultFile.from_bytes(result_file_bytes()), + melt_records=( + Cielo6MeltRecord( + temperature_raw=6050, + values=tuple(float(value) for value in range(96)), + channel_index=2, + ), + ), + ) + + melting_results = result.to_melting_curve_results() + + self.assertEqual(len(melting_results), 1) + self.assertEqual(melting_results[0].temperature, 60.5) + self.assertEqual(melting_results[0].channel_index, 2) + self.assertEqual(melting_results[0].data[0][0], 0.0) # A1 + self.assertEqual(melting_results[0].data[0][1], 8.0) # A2 + self.assertEqual(melting_results[0].data[7][11], 95.0) # H12 + + def test_to_melting_csv_matches_oem_export_shape(self) -> None: + result = Cielo6ResultFile.from_bytes(result_file_bytes()) + with_melt = replace( + result, + melt_records=(Cielo6MeltRecord(temperature_raw=6000, values=(1.0,) * 96, channel_index=0),), + ) + + csv = with_melt.to_melting_csv() + lines = csv.splitlines() + lines[0] = lines[0].lstrip("\ufeff") + + self.assertTrue(lines[0].startswith("Temperature,A1,B1,C1,D1,E1,F1,G1,H1,A2,")) + self.assertTrue(lines[0].endswith("H12,")) + self.assertEqual(lines[1], "60," + ",".join("1" for _ in range(96)) + ",") + + +class Cielo6LifecycleTests(unittest.IsolatedAsyncioTestCase): + async def test_setup_and_stop_delegate_to_serial_transport(self) -> None: + device = make_device(identity_response(), is_setup=False) + await device.setup() + await device.stop() + cast(AsyncMock, device.io.setup).assert_awaited_once() + cast(AsyncMock, device.io.stop).assert_awaited_once() + cast(AsyncMock, device.io.write).assert_awaited_once_with( + CieloFrame(DISCOVERY_DEVICE_ID, VERSION_CHECK_COMMAND).to_bytes() + ) + + async def test_setup_and_stop_are_idempotent(self) -> None: + device = make_device(identity_response(), is_setup=False) + + await device.setup() + await device.setup() + await device.stop() + await device.stop() + + cast(AsyncMock, device.io.setup).assert_awaited_once() + cast(AsyncMock, device.io.stop).assert_awaited_once() + + async def test_commands_require_setup(self) -> None: + device = make_device(is_setup=False) + + with self.assertRaisesRegex(RuntimeError, "Call setup"): + await device.request_status() + + cast(AsyncMock, device.io.write).assert_not_awaited() + + async def test_setup_rejects_another_instrument(self) -> None: + device = make_device(identity_response(device_id="87654321"), is_setup=False) + with self.assertRaisesRegex(Cielo6Error, "does not match"): + await device.setup() + cast(AsyncMock, device.io.stop).assert_awaited_once() + + async def test_setup_failure_is_not_masked_by_transport_cleanup_failure(self) -> None: + device = make_device(identity_response(device_id="87654321"), is_setup=False) + cast(AsyncMock, device.io.stop).side_effect = RuntimeError("close failed") + + with self.assertRaisesRegex(Cielo6Error, "does not match"): + await device.setup() + + self.assertFalse(device._is_setup) + + async def test_setup_clears_connection_state_and_keeps_active_program(self) -> None: + device = make_device(identity_response(), is_setup=False) + program = Cielo6StoredProgram.from_bytes(program_bytes()) + device._receive_buffer.extend(b"partial frame") + device._running_data.append(Cielo6RunningData.from_payload(running_data_payload())) + device._melting_data.append( + Cielo6MeltingData.from_payload(struct.pack(" None: + device = make_device(is_setup=False) + device._receive_buffer.extend(b"partial frame") + device._running_data.append(Cielo6RunningData.from_payload(running_data_payload())) + device.latest_status = Cielo6Status.from_payload(status_payload()) + cast(AsyncMock, device.io.setup).side_effect = RuntimeError("port unavailable") + + with self.assertRaisesRegex(RuntimeError, "port unavailable"): + await device.setup() + + self.assertEqual(device._receive_buffer, bytearray()) + self.assertEqual(device.running_data, ()) + self.assertIsNone(device.latest_status) + cast(AsyncMock, device.io.stop).assert_awaited_once() + + +class Cielo6StatusQueryTests(unittest.IsolatedAsyncioTestCase): + async def test_request_status_uses_only_read_only_status_command(self) -> None: + payload = bytes(128) + response = CieloFrame("12345678", STATUS_QUERY_COMMAND, payload).to_bytes() + device = make_device(response) + + status = await device.request_status() + + cast(AsyncMock, device.io.write).assert_awaited_once_with( + CieloFrame("12345678", STATUS_QUERY_COMMAND).to_bytes() + ) + self.assertEqual(status.block_temperatures_raw, (0,) * 16) + + async def test_request_status_handles_fragmented_response(self) -> None: + response = CieloFrame("12345678", STATUS_QUERY_COMMAND, bytes(128)).to_bytes() + device = make_device() + chunks = [response[:4], response[4:13], response[13:30], response[30:]] + cast(AsyncMock, device.io.read).side_effect = chunks + + status = await device.request_status() + + self.assertEqual(status.file_name, "") + + async def test_request_status_recovers_from_unrelated_leading_byte(self) -> None: + response = CieloFrame("12345678", STATUS_QUERY_COMMAND, bytes(128)).to_bytes() + device = make_device(b"\xff" + response) + + status = await device.request_status() + + self.assertEqual(status.file_name, "") + + async def test_request_status_retains_decoded_unsolicited_running_data(self) -> None: + running_data = running_data_payload() + response = ( + CieloFrame("12345678", RUNNING_EXPERIMENT_DATA_UPLOAD_COMMAND, running_data).to_bytes() + + CieloFrame("12345678", STATUS_QUERY_COMMAND, bytes(128)).to_bytes() + ) + device = make_device(response) + + await device.request_status() + + self.assertEqual(len(device.running_data), 1) + self.assertEqual(device.running_data[0].step_number, 3) + self.assertEqual(device.running_data[0].values, tuple(range(16))) + + async def test_request_status_retains_decoded_unsolicited_melting_data(self) -> None: + melting_data = ( + struct.pack(" None: + cases = ((b"1234", "too short"), (b"1234\x03", "Unsupported")) + for payload, message in cases: + with self.subTest(payload=payload): + response = CieloFrame( + "12345678", RUNNING_EXPERIMENT_DATA_UPLOAD_COMMAND, payload + ).to_bytes() + device = make_device(response) + with self.assertRaisesRegex(Cielo6Error, message): + await device.request_status() + + async def test_request_status_ignores_delayed_run_response(self) -> None: + response = ( + CieloFrame("12345678", RUN_COMMAND).to_bytes() + + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_RUNNING)).to_bytes() + ) + device = make_device(response) + + status = await device.request_status() + + self.assertEqual(status.work_state, Cielo6WorkState.RUNNING) + + async def test_request_status_rejects_other_device(self) -> None: + response = CieloFrame("87654321", STATUS_QUERY_COMMAND, bytes(128)).to_bytes() + device = make_device(response) + with self.assertRaisesRegex(Cielo6Error, "device ID"): + await device.request_status() + + async def test_request_status_rejects_wrong_command(self) -> None: + response = CieloFrame("12345678", 0x0B01, bytes(128)).to_bytes() + device = make_device(response) + with self.assertRaisesRegex(Cielo6Error, "Unexpected"): + await device.request_status() + + async def test_request_status_rejects_truncated_response(self) -> None: + device = make_device(b"1234") + with self.assertRaisesRegex(Cielo6Error, "Incomplete"): + await device.request_status() + + +class Cielo6StorageQueryTests(unittest.IsolatedAsyncioTestCase): + async def test_request_workspace_summary_reads_indexed_entries(self) -> None: + responses = b"".join( + ( + CieloFrame("12345678", WORKSPACE_SUMMARY_GET_COMMAND, struct.pack(" None: + responses = b"".join( + ( + CieloFrame("12345678", WORKSPACE_SUMMARY_GET_COMMAND, struct.pack(" None: + data = program_bytes() + indices = list(reversed(range(16))) + responses = b"".join( + CieloFrame( + "12345678", + PROGRAM_GET_COMMAND, + bytes([index]) + data[index * PROGRAM_CHUNK_SIZE : (index + 1) * PROGRAM_CHUNK_SIZE], + ).to_bytes() + for index in indices + ) + device = make_device(responses) + + program = await device.request_program("Public", "Test") + + self.assertEqual(program.name, "Test") + cast(AsyncMock, device.io.write).assert_awaited_once_with( + CieloFrame("12345678", PROGRAM_GET_COMMAND, b"Public^Test").to_bytes() + ) + + async def test_request_program_retains_unsolicited_status_before_response(self) -> None: + data = program_bytes() + responses = CieloFrame("12345678", STATUS_QUERY_COMMAND, bytes(128)).to_bytes() + b"".join( + CieloFrame( + "12345678", + PROGRAM_GET_COMMAND, + bytes([index]) + data[index * PROGRAM_CHUNK_SIZE : (index + 1) * PROGRAM_CHUNK_SIZE], + ).to_bytes() + for index in range(16) + ) + device = make_device(responses) + + program = await device.request_program("Public", "Test") + + self.assertEqual(program.name, "Test") + self.assertIsNotNone(device.latest_status) + + async def test_request_program_rejects_duplicate_chunk(self) -> None: + data = program_bytes() + responses = b"".join( + CieloFrame( + "12345678", + PROGRAM_GET_COMMAND, + bytes([0]) + data[:PROGRAM_CHUNK_SIZE], + ).to_bytes() + for _ in range(16) + ) + device = make_device(responses) + with self.assertRaisesRegex(Cielo6Error, "Duplicate"): + await device.request_program("Public", "Test") + + async def test_request_experiment_summary_reads_indexed_entries(self) -> None: + device = make_device( + experiment_summary_response( + "Research^PCR^Run-1^2026-08-29 10:00^2026-08-29 11:00", + "QC^Melt^Check\0^2026-08-28 09:00^2026-08-28 09:30", + ) + ) + + results = await device.request_experiment_summary() + + self.assertEqual( + results, + ( + Cielo6ExperimentInfo( + workspace="Research", + protocol="PCR", + name="Run-1", + started_at_raw="2026-08-29 10:00", + ended_at_raw="2026-08-29 11:00", + ), + Cielo6ExperimentInfo( + workspace="QC", + protocol="Melt", + name="Check", + started_at_raw="2026-08-28 09:00", + ended_at_raw="2026-08-28 09:30", + ), + ), + ) + + async def test_request_experiment_summary_rejects_short_entry(self) -> None: + device = make_device(experiment_summary_response("Research^PCR^Run-1")) + with self.assertRaisesRegex(Cielo6Error, "expected 5 fields"): + await device.request_experiment_summary() + + +class Cielo6RunStateQueryTests(unittest.IsolatedAsyncioTestCase): + async def test_request_running_experiment_info(self) -> None: + response = CieloFrame( + "12345678", RUNNING_EXPERIMENT_INFOS_GET_COMMAND, b"Research^PCR^Run-1" + ).to_bytes() + device = make_device(response) + + result = await device.request_running_experiment_info() + + self.assertEqual( + result, Cielo6ExperimentInfo(workspace="Research", protocol="PCR", name="Run-1") + ) + + async def test_request_running_experiment_info_returns_none_for_empty_payload(self) -> None: + response = CieloFrame("12345678", RUNNING_EXPERIMENT_INFOS_GET_COMMAND).to_bytes() + device = make_device(response) + self.assertIsNone(await device.request_running_experiment_info()) + + async def test_request_running_experiment_info_returns_none_for_stale_empty_name(self) -> None: + response = CieloFrame( + "12345678", RUNNING_EXPERIMENT_INFOS_GET_COMMAND, b"Public^Public^" + ).to_bytes() + device = make_device(response) + self.assertIsNone(await device.request_running_experiment_info()) + + async def test_request_run_state_normalizes_one_snapshot_with_program_context(self) -> None: + template = Cielo6StoredProgram.from_bytes(program_bytes()) + program = Cielo6ThermalProtocol( + steps=( + Cielo6ThermalStep(95, 30), + Cielo6ThermalStep(95, 5), + Cielo6ThermalStep(60, 15, collect_fluorescence=True), + ), + repeat_from_step=1, + cycles=2, + ).compile(template, workspace="Public", name="PLR-Short-2C") + responses = ( + CieloFrame( + "12345678", RUNNING_EXPERIMENT_DATA_UPLOAD_COMMAND, running_data_payload() + ).to_bytes() + + CieloFrame( + "12345678", + STATUS_QUERY_COMMAND, + status_payload( + WORK_STATUS_RUNNING, + current_step=3, + current_cycle=2, + program_time_total=200, + program_time_remaining=50, + ), + ).to_bytes() + + CieloFrame( + "12345678", RUNNING_EXPERIMENT_INFOS_GET_COMMAND, b"Public^PLR-Short-2C^Run-1" + ).to_bytes() + ) + device = make_device(responses) + + state = await device.request_run_state(program) + + self.assertEqual(state.status.work_state, Cielo6WorkState.RUNNING) + self.assertEqual(state.experiment, Cielo6ExperimentInfo("Public", "PLR-Short-2C", "Run-1")) + self.assertEqual(state.current_step_index, 2) + self.assertEqual(state.current_cycle_index, 1) + self.assertEqual(state.total_step_count, 3) + self.assertEqual(state.total_cycle_count, 2) + assert state.target_temperatures is not None + self.assertEqual(state.target_temperatures[:3], (60.0,) * 3) + self.assertEqual(state.target_temperatures[3:], (0.0,) * 13) + self.assertEqual(state.progress, 0.75) + self.assertEqual(len(state.amplification_data), 1) + self.assertEqual(state.estimated_completion_at, state.observed_at + timedelta(seconds=50)) + + async def test_request_finished_run_state_avoids_identity_request(self) -> None: + response = CieloFrame( + "12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_IDLE, 1) + ).to_bytes() + device = make_device(response) + + state = await device.request_run_state() + + self.assertIsNone(state.experiment) + self.assertIsNone(state.total_step_count) + self.assertIsNone(state.total_cycle_count) + self.assertIsNone(state.estimated_completion_at) + cast(AsyncMock, device.io.write).assert_awaited_once() + + async def test_request_idle_run_state_avoids_identity_request_and_eta(self) -> None: + response = CieloFrame( + "12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_IDLE, 0) + ).to_bytes() + device = make_device(response) + + state = await device.request_run_state() + + self.assertIsNone(state.experiment) + self.assertIsNone(state.estimated_completion_at) + cast(AsyncMock, device.io.write).assert_awaited_once() + + async def test_completed_run_state_returns_then_clears_retained_program_context(self) -> None: + response = CieloFrame( + "12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_IDLE, 1) + ).to_bytes() + device = make_device(response) + program = Cielo6StoredProgram.from_bytes(program_bytes()) + device._active_program = program + device._active_run_identity = ((11, 12), (21, 22), "RUN") + device._run_phase = type(device._run_phase).DISPATCHED + + state = await device.request_run_state() + + self.assertEqual(state.total_step_count, program.thermal_step_count) + self.assertIsNone(device._active_program) + + async def test_preparation_does_not_attach_program_to_stale_finished_status(self) -> None: + response = CieloFrame( + "12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_IDLE, 1) + ).to_bytes() + device = make_device(response) + program = Cielo6StoredProgram.from_bytes(program_bytes()) + device._active_program = program + device._run_phase = type(device._run_phase).PREPARING + + state = await device.request_run_state() + + self.assertIsNone(state.total_step_count) + self.assertIsNone(state.total_cycle_count) + self.assertIs(device._active_program, program) + self.assertIsNone(device._active_run_identity) + + async def test_new_active_run_does_not_reuse_stale_program_context(self) -> None: + responses = ( + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_RUNNING)).to_bytes() + + CieloFrame( + "12345678", RUNNING_EXPERIMENT_INFOS_GET_COMMAND, b"Public^Other^Run-2" + ).to_bytes() + ) + device = make_device(responses) + device._active_program = Cielo6StoredProgram.from_bytes(program_bytes()) + device._active_run_identity = ((99, 100), (101, 102), "OLD") + + state = await device.request_run_state() + + self.assertIsNone(state.total_step_count) + self.assertIsNone(state.target_temperatures) + self.assertIsNone(device._active_program) + + +class Cielo6ResultTransferTests(unittest.IsolatedAsyncioTestCase): + async def test_request_experiment_data_verifies_size_and_md5(self) -> None: + experiment = Cielo6ExperimentInfo(workspace="Research", protocol="PCR", name="Run-1") + data = bytes(range(255)) + b"tail" + info = len(data).to_bytes(4, byteorder="little", signed=True) + hashlib.md5(data).digest() + responses = ( + CieloFrame("12345678", EXPERIMENT_DATA_FILE_INFO_GET_COMMAND, info).to_bytes() + + CieloFrame("12345678", EXPERIMENT_DATA_FILE_GET_COMMAND, data[:255]).to_bytes() + + CieloFrame("12345678", EXPERIMENT_DATA_FILE_GET_COMMAND, data[255:]).to_bytes() + ) + device = make_device(responses) + + result = await device.request_experiment_data(experiment) + + self.assertEqual(result, data) + self.assertEqual( + [frame.payload for frame in written_frames(device)], + [b"Research/PCR/Run-1", b"Research/PCR/Run-1"], + ) + + async def test_request_experiment_data_rejects_bad_md5(self) -> None: + experiment = Cielo6ExperimentInfo(workspace="Research", protocol="PCR", name="Run-1") + data = b"result" + info = len(data).to_bytes(4, byteorder="little", signed=True) + bytes(16) + responses = ( + CieloFrame("12345678", EXPERIMENT_DATA_FILE_INFO_GET_COMMAND, info).to_bytes() + + CieloFrame("12345678", EXPERIMENT_DATA_FILE_GET_COMMAND, data).to_bytes() + ) + device = make_device(responses) + + with self.assertRaisesRegex(Cielo6Error, "MD5"): + await device.request_experiment_data(experiment) + + async def test_request_experiment_data_rejects_oversized_stream(self) -> None: + experiment = Cielo6ExperimentInfo(workspace="Research", protocol="PCR", name="Run-1") + info = (3).to_bytes(4, byteorder="little", signed=True) + hashlib.md5(b"abc").digest() + responses = ( + CieloFrame("12345678", EXPERIMENT_DATA_FILE_INFO_GET_COMMAND, info).to_bytes() + + CieloFrame("12345678", EXPERIMENT_DATA_FILE_GET_COMMAND, b"abcd").to_bytes() + ) + device = make_device(responses) + + with self.assertRaisesRegex(Cielo6Error, "exceeded"): + await device.request_experiment_data(experiment) + + async def test_request_experiment_data_rejects_invalid_file_information(self) -> None: + experiment = Cielo6ExperimentInfo(workspace="Research", protocol="PCR", name="Run-1") + cases = ( + (bytes(19), "expected 20"), + ((-1).to_bytes(4, "little", signed=True) + bytes(16), "size"), + ) + for payload, message in cases: + with self.subTest(message=message): + response = CieloFrame("12345678", EXPERIMENT_DATA_FILE_INFO_GET_COMMAND, payload).to_bytes() + device = make_device(response) + with self.assertRaisesRegex(Cielo6Error, message): + await device.request_experiment_data(experiment) + + async def test_request_experiment_data_rejects_empty_chunk(self) -> None: + experiment = Cielo6ExperimentInfo(workspace="Research", protocol="PCR", name="Run-1") + info = (1).to_bytes(4, "little", signed=True) + hashlib.md5(b"x").digest() + responses = ( + CieloFrame("12345678", EXPERIMENT_DATA_FILE_INFO_GET_COMMAND, info).to_bytes() + + CieloFrame("12345678", EXPERIMENT_DATA_FILE_GET_COMMAND).to_bytes() + ) + device = make_device(responses) + + with self.assertRaisesRegex(Cielo6Error, "download stopped"): + await device.request_experiment_data(experiment) + + async def test_request_experiment_data_validates_path_before_io(self) -> None: + cases = ( + (Cielo6ExperimentInfo("", "PCR", "Run-1"), "non-empty"), + (Cielo6ExperimentInfo("Research", "PCR", "RĂșn-1"), "ASCII"), + ) + for experiment, message in cases: + with self.subTest(experiment=experiment): + device = make_device() + with self.assertRaisesRegex(ValueError, message): + await device.request_experiment_data(experiment) + cast(AsyncMock, device.io.write).assert_not_awaited() + + +class Cielo6StorageMutationTests(unittest.IsolatedAsyncioTestCase): + async def test_operation_lock_serializes_storage_mutations(self) -> None: + device = make_device() + entered = asyncio.Event() + release = asyncio.Event() + + async def create_workspace(_workspace: str) -> None: + entered.set() + await release.wait() + + with ( + patch.object(device, "_create_workspace", new=AsyncMock(side_effect=create_workspace)), + patch.object(device, "_delete_workspace", new=AsyncMock()) as delete_workspace, + ): + create_task = asyncio.create_task(device.create_workspace("First")) + await entered.wait() + delete_task = asyncio.create_task(device.delete_workspace("Second")) + await asyncio.sleep(0) + delete_workspace.assert_not_awaited() + release.set() + await asyncio.gather(create_task, delete_task) + + delete_workspace.assert_awaited_once_with("Second") + + async def test_status_read_remains_available_during_an_operation(self) -> None: + response = CieloFrame( + "12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_RUNNING) + ).to_bytes() + device = make_device(response) + + async with device._operation_lock: + status = await device.request_status() + + self.assertTrue(status.is_running) + + async def test_create_workspace_preflights_and_confirms_readback(self) -> None: + success = EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + responses = ( + workspace_summary_response() + + CieloFrame("12345678", WORKSPACE_CREATE_COMMAND, success).to_bytes() + + workspace_summary_response("Validation^") + ) + device = make_device(responses) + + await device.create_workspace("Validation") + + self.assertEqual( + [call.args[0] for call in cast(AsyncMock, device.io.write).call_args_list], + [ + CieloFrame("12345678", WORKSPACE_SUMMARY_GET_COMMAND, struct.pack(" None: + device = make_device(workspace_summary_response("Validation^")) + + await device.create_workspace("Validation") + + commands = [frame.command for frame in written_frames(device)] + self.assertNotIn(WORKSPACE_CREATE_COMMAND, commands) + + async def test_create_workspace_rejects_inconsistent_readback(self) -> None: + success = EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + responses = ( + workspace_summary_response() + + CieloFrame("12345678", WORKSPACE_CREATE_COMMAND, success).to_bytes() + + workspace_summary_response() + ) + device = make_device(responses) + + with self.assertRaisesRegex(Cielo6Error, "not present in readback"): + await device.create_workspace("Validation") + + async def test_mutation_rejects_firmware_error(self) -> None: + responses = ( + workspace_summary_response() + + CieloFrame("12345678", WORKSPACE_CREATE_COMMAND, b"\x02\x5a").to_bytes() + ) + device = make_device(responses) + with self.assertRaisesRegex(Cielo6Error, "0x5a02"): + await device.create_workspace("Validation") + + async def test_delete_workspace_rejects_non_empty_workspace(self) -> None: + device = make_device(workspace_summary_response("Validation^RoundTrip")) + with self.assertRaisesRegex(Cielo6Error, "non-empty"): + await device.delete_workspace("Validation") + self.assertEqual(cast(AsyncMock, device.io.write).await_count, 2) + + async def test_delete_program_confirms_readback(self) -> None: + success = EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + responses = ( + workspace_summary_response("Validation^RoundTrip", "Validation^Keep") + + CieloFrame("12345678", PROGRAM_DELETE_COMMAND, success).to_bytes() + + workspace_summary_response("Validation^Keep") + ) + device = make_device(responses) + + await device.delete_program("Validation", "RoundTrip") + + frames = written_frames(device) + deletion = next(frame for frame in frames if frame.command == PROGRAM_DELETE_COMMAND) + self.assertEqual(deletion.payload, b"Validation^RoundTrip") + + async def test_delete_program_is_noop_when_program_is_absent(self) -> None: + device = make_device(workspace_summary_response("Validation^Keep")) + + await device.delete_program("Validation", "RoundTrip") + + commands = [frame.command for frame in written_frames(device)] + self.assertNotIn(PROGRAM_DELETE_COMMAND, commands) + + async def test_delete_program_rejects_inconsistent_readback(self) -> None: + success = EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + responses = ( + workspace_summary_response("Validation^RoundTrip") + + CieloFrame("12345678", PROGRAM_DELETE_COMMAND, success).to_bytes() + + workspace_summary_response("Validation^RoundTrip") + ) + device = make_device(responses) + + with self.assertRaisesRegex(Cielo6Error, "remained"): + await device.delete_program("Validation", "RoundTrip") + + async def test_delete_empty_workspace_confirms_readback(self) -> None: + success = EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + responses = ( + workspace_summary_response("Validation^") + + CieloFrame("12345678", WORKSPACE_DELETE_COMMAND, success).to_bytes() + + workspace_summary_response() + ) + device = make_device(responses) + + await device.delete_workspace("Validation") + + commands = [frame.command for frame in written_frames(device)] + self.assertEqual(commands.count(WORKSPACE_DELETE_COMMAND), 1) + + async def test_delete_workspace_is_noop_when_absent(self) -> None: + device = make_device(workspace_summary_response()) + + await device.delete_workspace("Validation") + + commands = [frame.command for frame in written_frames(device)] + self.assertNotIn(WORKSPACE_DELETE_COMMAND, commands) + + async def test_delete_workspace_rejects_inconsistent_readback(self) -> None: + success = EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + responses = ( + workspace_summary_response("Validation^") + + CieloFrame("12345678", WORKSPACE_DELETE_COMMAND, success).to_bytes() + + workspace_summary_response("Validation^") + ) + device = make_device(responses) + + with self.assertRaisesRegex(Cielo6Error, "remained in readback"): + await device.delete_workspace("Validation") + + async def test_storage_names_match_firmware_field_width(self) -> None: + device = make_device() + with self.assertRaisesRegex(ValueError, "30 ASCII"): + await device.create_workspace("x" * 31) + + +class Cielo6RunCommandTests(unittest.IsolatedAsyncioTestCase): + async def test_lock_returns_status_snapshot(self) -> None: + response = CieloFrame("12345678", SESSION_LOCK_COMMAND, status_payload()).to_bytes() + device = make_device(response) + + status = await device._lock() + + self.assertIsInstance(status, Cielo6Status) + self.assertEqual(status.work_status, WORK_STATUS_IDLE) + self.assertEqual(device.latest_status, status) + cast(AsyncMock, device.io.write).assert_awaited_once_with( + CieloFrame("12345678", SESSION_LOCK_COMMAND).to_bytes() + ) + + async def test_initialize_uses_mutation_ack(self) -> None: + response = CieloFrame( + "12345678", INITIALIZE_COMMAND, EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + ).to_bytes() + device = make_device(response) + + await device._initialize() + + cast(AsyncMock, device.io.write).assert_awaited_once_with( + CieloFrame("12345678", INITIALIZE_COMMAND).to_bytes() + ) + + async def test_upload_program_sends_indexed_chunks(self) -> None: + success = EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + responses = b"".join( + CieloFrame("12345678", PROGRAM_UPLOAD_COMMAND, success).to_bytes() for _ in range(16) + ) + device = make_device(responses) + program = Cielo6StoredProgram.from_bytes(program_bytes()) + + await device._upload_program(program) + + writes = [call.args[0] for call in cast(AsyncMock, device.io.write).call_args_list] + self.assertEqual(len(writes), 16) + for index, encoded in enumerate(writes): + frame = CieloFrame.from_bytes(encoded) + self.assertEqual(frame.command, PROGRAM_UPLOAD_COMMAND) + self.assertEqual(frame.payload[0], index) + self.assertEqual( + frame.payload[1:], + program.to_bytes()[index * PROGRAM_CHUNK_SIZE : (index + 1) * PROGRAM_CHUNK_SIZE], + ) + + async def test_set_result_path_joins_names(self) -> None: + response = CieloFrame( + "12345678", RESULT_PATH_SET_COMMAND, EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + ).to_bytes() + device = make_device(response) + + await device._set_result_path("Public", "PLR-Short-2C", "Run-1") + + cast(AsyncMock, device.io.write).assert_awaited_once_with( + CieloFrame("12345678", RESULT_PATH_SET_COMMAND, b"Public^PLR-Short-2C^Run-1").to_bytes() + ) + + async def test_start_run_confirms_running_without_run_ack(self) -> None: + response = ( + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload()).to_bytes() + + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_RUNNING)).to_bytes() + ) + device = make_device(response) + + status = await device._start_run(wait=0.0, attempts=1) + + self.assertEqual(status.work_status, WORK_STATUS_RUNNING) + self.assertEqual( + [call.args[0] for call in cast(AsyncMock, device.io.write).call_args_list], + [ + CieloFrame("12345678", STATUS_QUERY_COMMAND).to_bytes(), + CieloFrame("12345678", RUN_COMMAND).to_bytes(), + CieloFrame("12345678", STATUS_QUERY_COMMAND).to_bytes(), + ], + ) + + async def test_start_run_rejects_idle_state(self) -> None: + response = ( + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload()).to_bytes() + + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload()).to_bytes() + ) + device = make_device(response) + with self.assertRaisesRegex(Cielo6RunTimeoutError, "may still be active"): + await device._start_run(wait=0.0, attempts=1) + + async def test_start_run_waits_through_firmware_preparing_state(self) -> None: + responses = ( + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload()).to_bytes() + + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload()).to_bytes() + + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_RUNNING)).to_bytes() + ) + device = make_device(responses) + + status = await device._start_run(wait=0.0, attempts=3) + + self.assertEqual(status.work_status, WORK_STATUS_RUNNING) + commands = [frame.command for frame in written_frames(device)] + self.assertEqual(commands, [STATUS_QUERY_COMMAND, RUN_COMMAND, *([STATUS_QUERY_COMMAND] * 2)]) + + async def test_start_run_validates_confirmation_bounds_before_io(self) -> None: + device = make_device() + + with self.assertRaisesRegex(ValueError, "wait cannot be negative"): + await device._start_run(wait=-1) + with self.assertRaisesRegex(ValueError, "attempts must be at least 1"): + await device._start_run(attempts=0) + + cast(AsyncMock, device.io.write).assert_not_awaited() + + async def test_start_run_accepts_short_run_with_new_identity(self) -> None: + before = status_payload(WORK_STATUS_IDLE, 1) + after = bytearray(before) + struct.pack_into(" None: + stale = status_payload(WORK_STATUS_IDLE, 1) + response = ( + CieloFrame("12345678", STATUS_QUERY_COMMAND, stale).to_bytes() + + CieloFrame("12345678", STATUS_QUERY_COMMAND, stale).to_bytes() + ) + device = make_device(response) + + with self.assertRaisesRegex(Cielo6RunTimeoutError, "may still be active"): + await device._start_run(wait=0.0, attempts=1) + + async def test_start_run_fails_immediately_on_firmware_error(self) -> None: + response = ( + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload()).to_bytes() + + CieloFrame( + "12345678", STATUS_QUERY_COMMAND, status_payload(Cielo6WorkState.RUN_ERROR) + ).to_bytes() + ) + device = make_device(response) + + with self.assertRaisesRegex(Cielo6FirmwareStateError, "RUN_ERROR") as raised: + await device._start_run(wait=0.0, attempts=10) + + self.assertEqual(raised.exception.status.work_state, Cielo6WorkState.RUN_ERROR) + self.assertEqual(cast(AsyncMock, device.io.write).await_count, 3) + + async def test_stop_run_and_disconnect_use_mutation_acks(self) -> None: + success = EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + responses = ( + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_RUNNING)).to_bytes() + + CieloFrame("12345678", STOP_COMMAND, success).to_bytes() + + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload()).to_bytes() + + CieloFrame("12345678", DISCONNECT_COMMAND, success).to_bytes() + ) + device = make_device(responses) + + await device.stop_run() + await device._disconnect_session() + + commands = [frame.command for frame in written_frames(device)] + self.assertEqual( + commands, + [STATUS_QUERY_COMMAND, STOP_COMMAND, STATUS_QUERY_COMMAND, DISCONNECT_COMMAND], + ) + + async def test_stop_run_is_noop_when_firmware_is_idle(self) -> None: + response = CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload()).to_bytes() + device = make_device(response) + + await device.stop_run() + + self.assertEqual([frame.command for frame in written_frames(device)], [STATUS_QUERY_COMMAND]) + + async def test_stop_run_keeps_context_when_status_is_still_active(self) -> None: + success = EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + responses = ( + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_RUNNING)).to_bytes() + + CieloFrame("12345678", STOP_COMMAND, success).to_bytes() + + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_RUNNING)).to_bytes() + ) + device = make_device(responses) + program = Cielo6StoredProgram.from_bytes(program_bytes()) + device._active_program = program + + with self.assertRaisesRegex(Cielo6Error, "still reports an active run"): + await device.stop_run() + + self.assertIs(device._active_program, program) + + async def test_stop_run_does_not_clear_context_on_firmware_error(self) -> None: + responses = CieloFrame( + "12345678", STATUS_QUERY_COMMAND, status_payload(Cielo6WorkState.STOP_ERROR) + ).to_bytes() + device = make_device(responses) + program = Cielo6StoredProgram.from_bytes(program_bytes()) + device._active_program = program + + with self.assertRaisesRegex(Cielo6FirmwareStateError, "STOP_ERROR"): + await device.stop_run() + + self.assertIs(device._active_program, program) + + async def test_pause_run_sends_command_and_confirms_paused_state(self) -> None: + success = EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + responses = ( + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_RUNNING)).to_bytes() + + CieloFrame("12345678", PAUSE_COMMAND, success).to_bytes() + + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_PAUSED)).to_bytes() + ) + device = make_device(responses) + + await device.pause_run() + + self.assertEqual( + [frame.command for frame in written_frames(device)], + [STATUS_QUERY_COMMAND, PAUSE_COMMAND, STATUS_QUERY_COMMAND], + ) + + async def test_pause_run_is_noop_when_already_paused(self) -> None: + response = CieloFrame( + "12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_PAUSED) + ).to_bytes() + device = make_device(response) + + await device.pause_run() + + self.assertEqual([frame.command for frame in written_frames(device)], [STATUS_QUERY_COMMAND]) + + async def test_pause_run_rejects_idle_state(self) -> None: + response = CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload()).to_bytes() + device = make_device(response) + + with self.assertRaisesRegex(Cielo6Error, "no run is active"): + await device.pause_run() + + async def test_pause_run_rejects_inconsistent_readback(self) -> None: + success = EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + responses = ( + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_RUNNING)).to_bytes() + + CieloFrame("12345678", PAUSE_COMMAND, success).to_bytes() + + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_RUNNING)).to_bytes() + ) + device = make_device(responses) + + with self.assertRaisesRegex(Cielo6Error, "did not report a paused run"): + await device.pause_run() + + async def test_resume_run_sends_command_and_confirms_running_state(self) -> None: + success = EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + responses = ( + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_PAUSED)).to_bytes() + + CieloFrame("12345678", RESUME_COMMAND, success).to_bytes() + + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_RUNNING)).to_bytes() + ) + device = make_device(responses) + + await device.resume_run() + + self.assertEqual( + [frame.command for frame in written_frames(device)], + [STATUS_QUERY_COMMAND, RESUME_COMMAND, STATUS_QUERY_COMMAND], + ) + + async def test_resume_run_is_noop_when_already_running(self) -> None: + response = CieloFrame( + "12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_RUNNING) + ).to_bytes() + device = make_device(response) + + await device.resume_run() + + self.assertEqual([frame.command for frame in written_frames(device)], [STATUS_QUERY_COMMAND]) + + async def test_resume_run_rejects_idle_state(self) -> None: + response = CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload()).to_bytes() + device = make_device(response) + + with self.assertRaisesRegex(Cielo6Error, "no run is paused"): + await device.resume_run() + + async def test_resume_run_rejects_inconsistent_readback(self) -> None: + success = EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + responses = ( + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_PAUSED)).to_bytes() + + CieloFrame("12345678", RESUME_COMMAND, success).to_bytes() + + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_PAUSED)).to_bytes() + ) + device = make_device(responses) + + with self.assertRaisesRegex(Cielo6Error, "did not report an active run"): + await device.resume_run() + + +class Cielo6RunWorkflowTests(unittest.IsolatedAsyncioTestCase): + async def test_wait_for_completion_times_out_with_latest_running_state(self) -> None: + device = make_device() + running = Cielo6Status.from_payload(status_payload(WORK_STATUS_RUNNING)) + with patch.object(device, "request_status", new=AsyncMock(return_value=running)): + with self.assertRaisesRegex(Cielo6RunTimeoutError, "may still be active"): + await device._wait_for_completion(poll_interval=0.0, timeout=0.0) + + async def test_wait_for_completion_fails_immediately_on_firmware_error(self) -> None: + device = make_device() + error = Cielo6Status.from_payload(status_payload(Cielo6WorkState.ERROR_1)) + with patch.object(device, "request_status", new=AsyncMock(return_value=error)): + with self.assertRaisesRegex(Cielo6FirmwareStateError, "ERROR_1"): + await device._wait_for_completion(poll_interval=1.0, timeout=None) + + async def test_wait_for_completion_allows_finished_flag_to_settle_after_idle(self) -> None: + device = make_device() + idle = Cielo6Status.from_payload(status_payload(WORK_STATUS_IDLE, 0)) + finished = Cielo6Status.from_payload(status_payload(WORK_STATUS_IDLE, 1)) + with ( + patch.object(device, "request_status", new=AsyncMock(side_effect=(idle, finished))), + patch("pylabrobot.azure_biosystems.cielo6.asyncio.sleep", new=AsyncMock()) as sleep, + ): + result = await device._wait_for_completion(poll_interval=1.0, timeout=None) + + self.assertIs(result, finished) + sleep.assert_awaited_once_with(0.25) + + async def test_wait_for_completion_rejects_persistent_idle_without_completion(self) -> None: + device = make_device() + idle = Cielo6Status.from_payload(status_payload(WORK_STATUS_IDLE, 0)) + with ( + patch.object(device, "request_status", new=AsyncMock(return_value=idle)), + patch("pylabrobot.azure_biosystems.cielo6.time.monotonic", side_effect=(0.0, 5.0)), + patch("pylabrobot.azure_biosystems.cielo6.asyncio.sleep", new=AsyncMock()) as sleep, + ): + with self.assertRaisesRegex(Cielo6Error, "remained idle without.*completion"): + await device._wait_for_completion(poll_interval=1.0, timeout=None) + + sleep.assert_awaited_once_with(0.25) + + async def test_run_experiment_validates_wait_parameters_before_io(self) -> None: + program = Cielo6StoredProgram.from_bytes(program_bytes()) + cases = ((0.0, None, "poll_interval"), (1.0, 0.0, "timeout")) + + for poll_interval, timeout, message in cases: + with self.subTest(message=message): + device = make_device() + with self.assertRaisesRegex(ValueError, message): + await device.run_experiment( + program, + workspace="Public", + protocol="PCR", + poll_interval=poll_interval, + timeout=timeout, + ) + cast(AsyncMock, device.io.write).assert_not_awaited() + + async def test_stop_during_preparation_prevents_run_dispatch(self) -> None: + device = make_device() + program = Cielo6StoredProgram.from_bytes(program_bytes()) + idle = Cielo6Status.from_payload(status_payload()) + initialize_started = asyncio.Event() + release_initialize = asyncio.Event() + + async def initialize() -> None: + initialize_started.set() + await release_initialize.wait() + + with ( + patch.object(device, "_lock", new=AsyncMock(return_value=idle)), + patch.object(device, "_initialize", new=AsyncMock(side_effect=initialize)), + patch.object(device, "_upload_program", new=AsyncMock()) as upload_program, + patch.object(device, "_set_result_path", new=AsyncMock()), + patch.object(device, "_start_run", new=AsyncMock()) as start_run, + patch.object(device, "request_status", new=AsyncMock(return_value=idle)), + patch.object(device, "_disconnect_session", new=AsyncMock()), + ): + run = asyncio.create_task(device.run_experiment(program, workspace="Public", protocol="PCR")) + await initialize_started.wait() + stop = asyncio.create_task(device.stop_run()) + await asyncio.sleep(0) + release_initialize.set() + with self.assertRaisesRegex(Cielo6Error, "stopped before dispatch"): + await run + await stop + + upload_program.assert_not_awaited() + start_run.assert_not_awaited() + + async def test_start_confirmation_timeout_keeps_indeterminate_run_context(self) -> None: + device = make_device() + program = Cielo6StoredProgram.from_bytes(program_bytes()) + idle = Cielo6Status.from_payload(status_payload()) + + async def time_out_after_dispatch() -> Cielo6Status: + device._run_phase = type(device._run_phase).DISPATCHED + device.latest_status = idle + raise Cielo6RunTimeoutError(idle) + + with ( + patch.object(device, "_lock", new=AsyncMock(return_value=idle)), + patch.object(device, "_initialize", new=AsyncMock()), + patch.object(device, "_upload_program", new=AsyncMock()), + patch.object(device, "_set_result_path", new=AsyncMock()), + patch.object(device, "_start_run", new=AsyncMock(side_effect=time_out_after_dispatch)), + patch.object(device, "_disconnect_session", new=AsyncMock()), + ): + with self.assertRaises(Cielo6RunTimeoutError): + await device.run_experiment(program, workspace="Public", protocol="PCR") + + self.assertIs(device._active_program, program) + + async def test_timeout_keeps_program_context_for_active_hardware(self) -> None: + device = make_device() + program = Cielo6StoredProgram.from_bytes(program_bytes()) + idle = Cielo6Status.from_payload(status_payload()) + running = Cielo6Status.from_payload(status_payload(WORK_STATUS_RUNNING)) + + async def report_dispatched_run() -> Cielo6Status: + device._run_phase = type(device._run_phase).DISPATCHED + return running + + with ( + patch.object(device, "_lock", new=AsyncMock(return_value=idle)), + patch.object(device, "_initialize", new=AsyncMock()), + patch.object(device, "_upload_program", new=AsyncMock()), + patch.object(device, "_set_result_path", new=AsyncMock()), + patch.object(device, "_start_run", new=AsyncMock(side_effect=report_dispatched_run)), + patch.object( + device, + "_wait_for_completion", + new=AsyncMock(side_effect=Cielo6RunTimeoutError(running)), + ), + patch.object(device, "_disconnect_session", new=AsyncMock()), + ): + with self.assertRaises(Cielo6RunTimeoutError): + await device.run_experiment( + program, + workspace="Public", + protocol="PCR", + poll_interval=1.0, + ) + + self.assertIs(device._active_program, program) + + async def test_cancellation_after_run_dispatch_keeps_context_and_releases_session(self) -> None: + device = make_device() + program = Cielo6StoredProgram.from_bytes(program_bytes()) + idle = Cielo6Status.from_payload(status_payload()) + + async def cancel_after_dispatch() -> None: + device._run_phase = type(device._run_phase).DISPATCHED + raise asyncio.CancelledError + + with ( + patch.object(device, "_lock", new=AsyncMock(return_value=idle)), + patch.object(device, "_initialize", new=AsyncMock()), + patch.object(device, "_upload_program", new=AsyncMock()), + patch.object(device, "_set_result_path", new=AsyncMock()), + patch.object(device, "_start_run", new=AsyncMock(side_effect=cancel_after_dispatch)), + patch.object(device, "_disconnect_session", new=AsyncMock()) as disconnect, + ): + with self.assertRaises(asyncio.CancelledError): + await device.run_experiment(program, workspace="Public", protocol="PCR") + + self.assertIs(device._active_program, program) + disconnect.assert_awaited_once() + + async def test_run_experiment_rejects_active_firmware_state_before_initialize(self) -> None: + device = make_device() + program = Cielo6StoredProgram.from_bytes(program_bytes()) + running = Cielo6Status.from_payload(status_payload(WORK_STATUS_RUNNING)) + with ( + patch.object(device, "_lock", new=AsyncMock(return_value=running)), + patch.object(device, "_initialize", new=AsyncMock()) as initialize, + patch.object(device, "_disconnect_session", new=AsyncMock()) as disconnect, + ): + with self.assertRaisesRegex(Cielo6Error, "already has an active run"): + await device.run_experiment(program, workspace="Public", protocol="PCR") + + initialize.assert_not_awaited() + disconnect.assert_awaited_once() + + async def test_run_experiment_rejects_error_state_and_releases_session(self) -> None: + device = make_device() + program = Cielo6StoredProgram.from_bytes(program_bytes()) + error = Cielo6Status.from_payload(status_payload(Cielo6WorkState.RUN_ERROR)) + with ( + patch.object(device, "_lock", new=AsyncMock(return_value=error)), + patch.object(device, "_initialize", new=AsyncMock()) as initialize, + patch.object(device, "_disconnect_session", new=AsyncMock()) as disconnect, + ): + with self.assertRaisesRegex(Cielo6FirmwareStateError, "RUN_ERROR"): + await device.run_experiment(program, workspace="Public", protocol="PCR") + + initialize.assert_not_awaited() + disconnect.assert_awaited_once() + + async def test_error_after_run_dispatch_keeps_context_and_releases_session(self) -> None: + device = make_device() + program = Cielo6StoredProgram.from_bytes(program_bytes()) + idle = Cielo6Status.from_payload(status_payload()) + running = Cielo6Status.from_payload(status_payload(WORK_STATUS_RUNNING)) + error = Cielo6Status.from_payload(status_payload(Cielo6WorkState.ERROR_1)) + + async def report_dispatched_run() -> Cielo6Status: + device._run_phase = type(device._run_phase).DISPATCHED + return running + + async def report_error(*, poll_interval: float, timeout: object) -> Cielo6Status: + del poll_interval, timeout + device.latest_status = error + raise Cielo6FirmwareStateError("waiting for run completion", error) + + with ( + patch.object(device, "_lock", new=AsyncMock(return_value=idle)), + patch.object(device, "_initialize", new=AsyncMock()), + patch.object(device, "_upload_program", new=AsyncMock()), + patch.object(device, "_set_result_path", new=AsyncMock()), + patch.object(device, "_start_run", new=AsyncMock(side_effect=report_dispatched_run)), + patch.object(device, "_wait_for_completion", new=AsyncMock(side_effect=report_error)), + patch.object(device, "_disconnect_session", new=AsyncMock()) as disconnect, + ): + with self.assertRaisesRegex(Cielo6FirmwareStateError, "ERROR_1"): + await device.run_experiment(program, workspace="Public", protocol="PCR") + + self.assertIs(device._active_program, program) + disconnect.assert_awaited_once() + + async def test_run_experiment_serializes_complete_run_workflows(self) -> None: + device = make_device() + program = Cielo6StoredProgram.from_bytes(program_bytes()) + idle = Cielo6Status.from_payload(status_payload()) + experiment = Cielo6ExperimentInfo("Public", "PCR", "Result") + first_initialize_started = asyncio.Event() + release_first_initialize = asyncio.Event() + + async def initialize() -> None: + if not first_initialize_started.is_set(): + first_initialize_started.set() + await release_first_initialize.wait() + + with ( + patch.object(device, "_lock", new=AsyncMock(return_value=idle)), + patch.object(device, "_initialize", new=AsyncMock(side_effect=initialize)) as initialize_mock, + patch.object(device, "_upload_program", new=AsyncMock()), + patch.object(device, "_set_result_path", new=AsyncMock()), + patch.object(device, "_start_run", new=AsyncMock(return_value=idle)), + patch.object(device, "_wait_for_completion", new=AsyncMock(return_value=idle)), + patch.object(device, "_find_experiment", new=AsyncMock(return_value=experiment)), + patch.object( + device, "request_experiment_data", new=AsyncMock(return_value=result_file_bytes()) + ), + patch.object(device, "_disconnect_session", new=AsyncMock()), + ): + first = asyncio.create_task( + device.run_experiment(program, workspace="Public", protocol="PCR", result_name="First") + ) + await first_initialize_started.wait() + second = asyncio.create_task( + device.run_experiment(program, workspace="Public", protocol="PCR", result_name="Second") + ) + await asyncio.sleep(0) + self.assertEqual(initialize_mock.await_count, 1) + release_first_initialize.set() + await asyncio.gather(first, second) + + self.assertEqual(initialize_mock.await_count, 2) + + async def test_default_result_name_is_bounded_and_not_based_on_protocol_length(self) -> None: + device = make_device() + program = Cielo6StoredProgram.from_bytes(program_bytes()) + idle = Cielo6Status.from_payload(status_payload()) + experiment = Cielo6ExperimentInfo("Public", "P" * 30, "Result") + with ( + patch.object(device, "_lock", new=AsyncMock(return_value=idle)), + patch.object(device, "_initialize", new=AsyncMock()), + patch.object(device, "_upload_program", new=AsyncMock()), + patch.object(device, "_set_result_path", new=AsyncMock()) as set_result_path, + patch.object(device, "_start_run", new=AsyncMock(return_value=idle)), + patch.object(device, "_wait_for_completion", new=AsyncMock(return_value=idle)), + patch.object(device, "_find_experiment", new=AsyncMock(return_value=experiment)), + patch.object( + device, "request_experiment_data", new=AsyncMock(return_value=result_file_bytes()) + ), + patch.object(device, "_disconnect_session", new=AsyncMock()), + ): + await device.run_experiment(program, workspace="Public", protocol="P" * 30) + + assert set_result_path.await_args is not None + result_name = set_result_path.await_args.args[2] + self.assertLessEqual(len(result_name), 30) + self.assertRegex(result_name, r"^PLR-\d{8}-\d{6}-\d{6}$") + + async def test_find_experiment_rejects_missing_completed_result(self) -> None: + device = make_device() + with patch.object(device, "request_experiment_summary", new=AsyncMock(return_value=())): + with self.assertRaisesRegex(Cielo6Error, "was not present"): + await device._find_experiment("Public", "PCR", "Run-1") + + async def test_run_experiment_orchestrates_verified_sequence(self) -> None: + result_data = result_file_bytes() + running = running_data_payload() + success = EXEC_SUCCESSFUL.to_bytes(2, byteorder="little") + info = ( + len(result_data).to_bytes(4, byteorder="little", signed=True) + + hashlib.md5(result_data).digest() + ) + frames = [ + CieloFrame("12345678", SESSION_LOCK_COMMAND, status_payload()).to_bytes(), + CieloFrame("12345678", INITIALIZE_COMMAND, success).to_bytes(), + ] + frames.extend( + CieloFrame("12345678", PROGRAM_UPLOAD_COMMAND, success).to_bytes() for _ in range(16) + ) + frames.extend( + ( + CieloFrame("12345678", RESULT_PATH_SET_COMMAND, success).to_bytes(), + CieloFrame("12345678", STATUS_QUERY_COMMAND, status_payload()).to_bytes(), + CieloFrame( + "12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_RUNNING) + ).to_bytes(), + CieloFrame("12345678", RUNNING_EXPERIMENT_DATA_UPLOAD_COMMAND, running).to_bytes(), + CieloFrame( + "12345678", STATUS_QUERY_COMMAND, status_payload(WORK_STATUS_IDLE, 1) + ).to_bytes(), + ) + ) + frames.append( + experiment_summary_response( + "Public^PLR-Short-2C^PLR-Short-2C-20260829-130646^2026-08-29_13:08:36^2026-08-29_13:12:35" + ) + ) + frames.append(CieloFrame("12345678", EXPERIMENT_DATA_FILE_INFO_GET_COMMAND, info).to_bytes()) + frames.extend( + CieloFrame( + "12345678", + EXPERIMENT_DATA_FILE_GET_COMMAND, + result_data[start : start + 255], + ).to_bytes() + for start in range(0, len(result_data), 255) + ) + frames.append(CieloFrame("12345678", DISCONNECT_COMMAND, success).to_bytes()) + device = make_device(b"".join(frames)) + + result = await device.run_experiment( + Cielo6StoredProgram.from_bytes(program_bytes()), + workspace="Public", + protocol="PLR-Short-2C", + result_name="PLR-Short-2C-20260829-130646", + poll_interval=0.001, + ) + + self.assertEqual(result.workspace, "Public") + self.assertEqual(result.program, "PLR-Short-2C") + self.assertEqual(len(device.running_data), 1) + self.assertEqual(device.running_data[0], Cielo6RunningData.from_payload(running)) + commands = [frame.command for frame in written_frames(device)] + self.assertEqual( + commands, + [ + SESSION_LOCK_COMMAND, + INITIALIZE_COMMAND, + *([PROGRAM_UPLOAD_COMMAND] * 16), + RESULT_PATH_SET_COMMAND, + STATUS_QUERY_COMMAND, + RUN_COMMAND, + STATUS_QUERY_COMMAND, + STATUS_QUERY_COMMAND, + EXPERIMENT_DATA_SUMMARY_GET_COMMAND, + EXPERIMENT_DATA_SUMMARY_GET_COMMAND, + EXPERIMENT_DATA_FILE_INFO_GET_COMMAND, + EXPERIMENT_DATA_FILE_GET_COMMAND, + DISCONNECT_COMMAND, + ], + ) + + async def test_run_experiment_preserves_primary_error_when_session_release_fails(self) -> None: + device = make_device() + program = Cielo6StoredProgram.from_bytes(program_bytes()) + with ( + patch.object( + device, + "_lock", + new=AsyncMock(return_value=Cielo6Status.from_payload(status_payload())), + ), + patch.object(device, "_initialize", new=AsyncMock(side_effect=RuntimeError("run failed"))), + patch.object( + device, + "_disconnect_session", + new=AsyncMock(side_effect=Cielo6Error("release failed")), + ) as disconnect, + ): + with self.assertRaisesRegex(RuntimeError, "run failed"): + await device.run_experiment( + program, + workspace="Public", + protocol="PLR-Short-2C", + result_name="Run-1", + ) + + disconnect.assert_awaited_once() + + async def test_run_experiment_reports_session_release_failure_after_success(self) -> None: + device = make_device() + program = Cielo6StoredProgram.from_bytes(program_bytes()) + experiment = Cielo6ExperimentInfo("Public", "PLR-Short-2C", "Run-1") + with ( + patch.object( + device, + "_lock", + new=AsyncMock(return_value=Cielo6Status.from_payload(status_payload())), + ), + patch.object(device, "_initialize", new=AsyncMock()), + patch.object(device, "_upload_program", new=AsyncMock()), + patch.object(device, "_set_result_path", new=AsyncMock()), + patch.object(device, "_start_run", new=AsyncMock()), + patch.object(device, "_wait_for_completion", new=AsyncMock()), + patch.object(device, "_find_experiment", new=AsyncMock(return_value=experiment)), + patch.object( + device, + "request_experiment_data", + new=AsyncMock(return_value=result_file_bytes()), + ), + patch.object( + device, + "_disconnect_session", + new=AsyncMock(side_effect=Cielo6Error("release failed")), + ), + ): + with self.assertRaisesRegex(Cielo6Error, "release failed"): + await device.run_experiment( + program, + workspace="Public", + protocol="PLR-Short-2C", + result_name="Run-1", + ) + + async def test_run_protocol_compiles_and_delegates(self) -> None: + device = make_device() + template = Cielo6StoredProgram.from_bytes(program_bytes()) + protocol = Cielo6ThermalProtocol( + steps=( + Cielo6ThermalStep(95, 30), + Cielo6ThermalStep(95, 5), + Cielo6ThermalStep(60, 15, collect_fluorescence=True), + ), + repeat_from_step=1, + cycles=2, + sample_volume=20, + ) + compiled = protocol.compile(template, workspace="Public", name="PLR-Short-2C") + expected = Cielo6ResultFile.from_bytes(result_file_bytes()) + with ( + patch.object(device, "_create_workspace", new=AsyncMock()) as create_workspace, + patch.object( + device, "_run_experiment", new=AsyncMock(return_value=expected) + ) as run_experiment, + ): + result = await device.run_protocol( + protocol, + template=template, + workspace="Public", + program_name="PLR-Short-2C", + result_name="Run-1", + poll_interval=0.5, + timeout=10.0, + ) + + create_workspace.assert_not_awaited() + run_experiment.assert_awaited_once_with( + compiled, + workspace="Public", + protocol="PLR-Short-2C", + result_name="Run-1", + poll_interval=0.5, + timeout=10.0, + ensure_workspace=True, + ) + self.assertEqual(result, expected) + + async def test_run_protocol_rejects_active_state_before_workspace_creation(self) -> None: + device = make_device() + template = Cielo6StoredProgram.from_bytes(program_bytes()) + protocol = Cielo6ThermalProtocol(steps=(Cielo6ThermalStep(30, 1),)) + running = Cielo6Status.from_payload(status_payload(WORK_STATUS_RUNNING)) + with ( + patch.object(device, "_lock", new=AsyncMock(return_value=running)), + patch.object(device, "_create_workspace", new=AsyncMock()) as create_workspace, + patch.object(device, "_disconnect_session", new=AsyncMock()), + ): + with self.assertRaisesRegex(Cielo6Error, "already has an active run"): + await device.run_protocol( + protocol, + template=template, + workspace="Validation", + program_name="Short", + ) + + create_workspace.assert_not_awaited() + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/io/serial.py b/pylabrobot/io/serial.py index 85dd33dc7df..b268895ccde 100644 --- a/pylabrobot/io/serial.py +++ b/pylabrobot/io/serial.py @@ -24,6 +24,16 @@ logger = logging.getLogger(__name__) +def _bytes_to_capture_text(data: bytes) -> str: + """Represent arbitrary serial bytes losslessly in capture JSON strings.""" + return data.decode("latin-1") + + +def _capture_text_to_bytes(data: str) -> bytes: + """Restore bytes written by :func:`_bytes_to_capture_text`.""" + return data.encode("latin-1") + + @dataclass class SerialCommand(Command): data: str @@ -261,7 +271,7 @@ async def write(self, data: bytes): logger.log(LOG_LEVEL_IO, "[%s] write %s", self._port, data) capturer.record( - SerialCommand(device_id=self.port, action="write", data=data.decode("unicode_escape")) + SerialCommand(device_id=self.port, action="write", data=_bytes_to_capture_text(data)) ) emit_event( "io.write", @@ -283,7 +293,7 @@ async def read(self, num_bytes: int = 1) -> bytes: if len(data) != 0: logger.log(LOG_LEVEL_IO, "[%s] read %s", self._port, data) capturer.record( - SerialCommand(device_id=self.port, action="read", data=data.decode("unicode_escape")) + SerialCommand(device_id=self.port, action="read", data=_bytes_to_capture_text(data)) ) emit_event( "io.read", @@ -307,7 +317,7 @@ async def readline(self) -> bytes: # type: ignore # very dumb it's reading from if len(data) != 0: logger.log(LOG_LEVEL_IO, "[%s] readline %s", self._port, data) capturer.record( - SerialCommand(device_id=self.port, action="readline", data=data.decode("unicode_escape")) + SerialCommand(device_id=self.port, action="readline", data=_bytes_to_capture_text(data)) ) emit_event( "io.read", @@ -438,8 +448,9 @@ async def write(self, data: bytes): and next_command.action == "write" ): raise ValidationError(f"Next line is {next_command}, expected Serial write") - if next_command.data != data.decode("unicode_escape"): - align_sequences(expected=next_command.data, actual=data.decode("unicode_escape")) + actual = _bytes_to_capture_text(data) + if next_command.data != actual: + align_sequences(expected=next_command.data, actual=actual) raise ValidationError("Data mismatch: difference was written to stdout.") async def read(self, num_bytes: int = 1) -> bytes: @@ -451,7 +462,7 @@ async def read(self, num_bytes: int = 1) -> bytes: and len(next_command.data) == num_bytes ): raise ValidationError(f"Next line is {next_command}, expected Serial read {num_bytes}") - return next_command.data.encode() + return _capture_text_to_bytes(next_command.data) async def readline(self) -> bytes: # type: ignore # very dumb it's reading from pyserial next_command = SerialCommand(**self.cr.next_command()) @@ -461,7 +472,7 @@ async def readline(self) -> bytes: # type: ignore # very dumb it's reading from and next_command.action == "readline" ): raise ValidationError(f"Next line is {next_command}, expected Serial readline") - return next_command.data.encode() + return _capture_text_to_bytes(next_command.data) async def send_break(self, duration: float): next_command = SerialCommand(**self.cr.next_command()) diff --git a/pylabrobot/io/serial_tests.py b/pylabrobot/io/serial_tests.py new file mode 100644 index 00000000000..f6934ec792b --- /dev/null +++ b/pylabrobot/io/serial_tests.py @@ -0,0 +1,78 @@ +import tempfile +import unittest +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from pylabrobot.io import capture as capture_module +from pylabrobot.io.capture import CaptureReader, capturer +from pylabrobot.io.serial import Serial, SerialValidator + + +def _reset_capture_validation_state() -> None: + """Reset the capture state after each serial test.""" + capture_module._capture_or_validation_active = False + + +class _FakePySerial: + """Provide deterministic serial data without a physical device.""" + + def __init__(self, read_data: bytes, line_data: bytes) -> None: + self._read_data = bytearray(read_data) + self._line_data = line_data + self.written = bytearray() + self.is_open = True + + def write(self, data: bytes) -> int: + self.written.extend(data) + return len(data) + + def read(self, size: int) -> bytes: + data = bytes(self._read_data[:size]) + del self._read_data[:size] + return data + + def readline(self) -> bytes: + return self._line_data + + def close(self) -> None: + self.is_open = False + + +class SerialCaptureEncodingTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.addCleanup(_reset_capture_validation_state) + + async def test_binary_capture_replays_through_validator(self) -> None: + written = bytes(range(256)) + b"\\U\\x\\" + read = bytes(reversed(range(256))) + b"\\" + line = b"line\\U\xff\n" + serial = Serial(human_readable_device_name="test", port="/dev/test") + serial._executor = ThreadPoolExecutor(max_workers=1) + device = _FakePySerial(read_data=read, line_data=line) + serial._ser = device # type: ignore[assignment] + + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "capture.json" + capturer.start(path) + self.addCleanup(lambda: capturer.capture_active and capturer.stop()) + await serial.write(written) + captured_read = await serial.read(len(read)) + captured_line = await serial.readline() + capturer.stop() + await serial.stop() + + self.assertEqual(bytes(device.written), written) + self.assertEqual(captured_read, read) + self.assertEqual(captured_line, line) + + reader = CaptureReader(str(path)) + validator = SerialValidator(reader, human_readable_device_name="test", port="/dev/test") + reader.start() + await validator.write(written) + self.assertEqual(await validator.read(len(read)), read) + self.assertEqual(await validator.readline(), line) + reader.done() + + +if __name__ == "__main__": + unittest.main()