From 84f3f9600cc0b93b508c3b1b74ee1c8fc43e2281 Mon Sep 17 00:00:00 2001 From: Cody Moore <46687103+cmoscy@users.noreply.github.com> Date: Sun, 3 May 2026 21:39:17 -0700 Subject: [PATCH 1/5] ODTC V1 port init --- docs/user_guide/inheco/odtc/hello-world.ipynb | 799 ++++++++++++++---- .../capabilities/thermocycling/__init__.py | 29 + .../capabilities/thermocycling/backend.py | 54 ++ .../capabilities/thermocycling/chatterbox.py | 66 ++ .../capabilities/thermocycling/standard.py | 207 +++++ .../thermocycling/tests/__init__.py | 0 .../thermocycling/tests/standard_tests.py | 193 +++++ .../thermocycling/tests/thermocycler_tests.py | 118 +++ .../thermocycling/thermocycler.py | 151 ++++ pylabrobot/inheco/odtc/__init__.py | 32 + pylabrobot/inheco/odtc/backend.py | 389 +++++++++ pylabrobot/inheco/odtc/door.py | 72 ++ pylabrobot/inheco/odtc/driver.py | 261 ++++++ pylabrobot/inheco/odtc/model.py | 449 ++++++++++ pylabrobot/inheco/odtc/odtc.py | 190 +++++ pylabrobot/inheco/odtc/protocol.py | 637 ++++++++++++++ pylabrobot/inheco/odtc/tests/__init__.py | 0 pylabrobot/inheco/odtc/tests/door_tests.py | 76 ++ pylabrobot/inheco/odtc/tests/model_tests.py | 213 +++++ .../inheco/odtc/tests/protocol_tests.py | 341 ++++++++ .../inheco/odtc/tests/sila_interface_tests.py | 223 +++++ pylabrobot/inheco/odtc/tests/xml_tests.py | 267 ++++++ pylabrobot/inheco/odtc/xml.py | 625 ++++++++++++++ .../inheco/scila/inheco_sila_interface.py | 383 +++++++-- 24 files changed, 5542 insertions(+), 233 deletions(-) create mode 100644 pylabrobot/capabilities/thermocycling/__init__.py create mode 100644 pylabrobot/capabilities/thermocycling/backend.py create mode 100644 pylabrobot/capabilities/thermocycling/chatterbox.py create mode 100644 pylabrobot/capabilities/thermocycling/standard.py create mode 100644 pylabrobot/capabilities/thermocycling/tests/__init__.py create mode 100644 pylabrobot/capabilities/thermocycling/tests/standard_tests.py create mode 100644 pylabrobot/capabilities/thermocycling/tests/thermocycler_tests.py create mode 100644 pylabrobot/capabilities/thermocycling/thermocycler.py create mode 100644 pylabrobot/inheco/odtc/__init__.py create mode 100644 pylabrobot/inheco/odtc/backend.py create mode 100644 pylabrobot/inheco/odtc/door.py create mode 100644 pylabrobot/inheco/odtc/driver.py create mode 100644 pylabrobot/inheco/odtc/model.py create mode 100644 pylabrobot/inheco/odtc/odtc.py create mode 100644 pylabrobot/inheco/odtc/protocol.py create mode 100644 pylabrobot/inheco/odtc/tests/__init__.py create mode 100644 pylabrobot/inheco/odtc/tests/door_tests.py create mode 100644 pylabrobot/inheco/odtc/tests/model_tests.py create mode 100644 pylabrobot/inheco/odtc/tests/protocol_tests.py create mode 100644 pylabrobot/inheco/odtc/tests/sila_interface_tests.py create mode 100644 pylabrobot/inheco/odtc/tests/xml_tests.py create mode 100644 pylabrobot/inheco/odtc/xml.py diff --git a/docs/user_guide/inheco/odtc/hello-world.ipynb b/docs/user_guide/inheco/odtc/hello-world.ipynb index 2ee3ba0c774..06a9c2ae72e 100644 --- a/docs/user_guide/inheco/odtc/hello-world.ipynb +++ b/docs/user_guide/inheco/odtc/hello-world.ipynb @@ -1,159 +1,644 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "fa0retc47os", - "source": "# Inheco ODTC\n\nThe Inheco ODTC (On Deck Thermal Cycler) is a thermocycler designed for automated PCR workflows. It features:\n\n- Precise temperature control (4 -- 99 °C, up to 4.4 °C/s ramp rate)\n- Heated lid to prevent condensation\n- Motorized door for automated plate handling\n- 96-well plate format\n\nThe ODTC communicates over Ethernet using the SiLA 2 protocol. You will need the IP address of the device and network connectivity between your computer and the ODTC.\n\nSee the [Inheco ODTC product page](https://www.inheco.com/odtc.html) for hardware details.", - "metadata": {} + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Inheco ODTC\n", + "\n", + "The Inheco ODTC (On Deck Thermal Cycler) is a thermocycler designed for automated PCR workflows. It features:\n", + "\n", + "- Precise temperature control (4 – 99 °C, up to 4.4 °C/s ramp rate for the 96-well variant)\n", + "- Heated lid to prevent condensation\n", + "- Motorized door for automated plate handling\n", + "- 96-well and 384-well plate formats\n", + "\n", + "The ODTC communicates over Ethernet using the SiLA 1 (SOAP) protocol. You will need the IP address of the device and network connectivity between your computer and the ODTC.\n", + "\n", + "See the [Inheco ODTC product page](https://www.inheco.com/odtc.html) for hardware details.\n", + "\n", + "## Architecture overview\n", + "\n", + "The ODTC integrates into PLR's v1b1 Capability architecture:\n", + "\n", + "- **`ODTC`** — the top-level `Device` (also a `Resource` with physical dimensions). Owns two capabilities.\n", + "- **`odtc.tc`** — the `Thermocycler` capability. All temperature control and protocol execution.\n", + "- **`odtc.door`** — the `LoadingTray` capability. Motorized door open/close and plate-access resource.\n", + "- **`ODTCThermocyclerBackend.RunProtocolParams`** — device-specific parameters (variant, fluid quantity, PID, etc.) passed as `backend_params` to `run_protocol()`.\n", + "- **`Protocol` / `Stage` / `Step` / `Ramp`** — the abstract protocol model. Device-agnostic, composable, fully serializable." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.inheco.odtc import ODTC, DoorStateUnknownError, FluidQuantity\n", + "from pylabrobot.inheco.odtc.backend import ODTCThermocyclerBackend\n", + "from pylabrobot.inheco.odtc.model import ODTCProtocol\n", + "from pylabrobot.capabilities.thermocycling import (\n", + " Protocol,\n", + " Stage,\n", + " Step,\n", + " Ramp,\n", + " Overshoot,\n", + " FULL_SPEED,\n", + ")\n", + "\n", + "odtc = ODTC(\n", + " odtc_ip=\"192.168.1.50\", # replace with your ODTC's IP address\n", + " variant=96, # 96 or 384\n", + " name=\"odtc\",\n", + ")\n", + "\n", + "await odtc.setup() # connects, resets, and initialises the device" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "> **Tip:** Thermocycler operations go through `odtc.tc`; door operations go through `odtc.door`. The `ODTC` device handles the SiLA connection lifecycle." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Door control\n", + "\n", + "`odtc.door` is a `LoadingTray` capability — it controls the motorized door **and** acts as the plate-holding resource in the PLR resource tree (arms can pick up and place plates via `odtc.door`).\n", + "\n", + "### Door state\n", + "\n", + "The ODTC firmware provides no door-state query command, so PLR tracks state locally:\n", + "\n", + "- **Unknown** (initial, or after reconnect) — `odtc.door.backend.is_open` raises `DoorStateUnknownError`\n", + "- **Open** — after a successful `odtc.door.open()`\n", + "- **Closed** — after a successful `odtc.door.close()`\n", + "\n", + "State resets to unknown whenever the device connection is re-established, because the physical door position may have changed while disconnected." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await odtc.door.open()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Check door state (raises DoorStateUnknownError if neither open() nor close() has been called)\n", + "try:\n", + " print(\"Door is open:\", odtc.door.backend.is_open)\n", + "except DoorStateUnknownError as e:\n", + " print(\"State unknown:\", e)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await odtc.door.close()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Temperature sensing\n", + "\n", + "The ODTC has multiple internal sensors. `request_temperatures()` returns all of them at once:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sensors = await odtc.tc.backend.request_temperatures()\n", + "print(sensors)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The standard `request_block_temperature()` and `request_lid_temperature()` methods are also available:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "block_temp = await odtc.tc.request_block_temperature()\n", + "lid_temp = await odtc.tc.request_lid_temperature()\n", + "print(f\"Block: {block_temp:.1f} °C Lid: {lid_temp:.1f} °C\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Holding at a constant temperature\n", + "\n", + "`set_block_temperature()` creates an ODTC *pre-method* internally, which ramps the block and lid to the target and holds there indefinitely. The call returns immediately (fire-and-forget). Call `deactivate_block()` to stop.\n", + "\n", + "> **Note:** The first call takes several minutes because the device stabilises the block temperature evenly before entering the steady-state hold." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await odtc.tc.set_block_temperature(37.0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "temp = await odtc.tc.request_block_temperature()\n", + "print(f\"Block: {temp:.1f} °C\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await odtc.tc.deactivate_block()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Running a PCR protocol\n", + "\n", + "Protocols are built from `Protocol` → `Stage` → `Step` objects. This is the same model across all PLR thermocyclers.\n", + "\n", + "### The protocol model\n", + "\n", + "| Type | Key fields |\n", + "|------|------------|\n", + "| `Step` | `temperature` (°C), `hold_seconds`, `ramp` (optional `Ramp`) |\n", + "| `Ramp` | `rate` (°C/s, default = full device speed), `overshoot` (optional) |\n", + "| `Stage` | `steps`, `repeats` (cycling count), `inner_stages` (nested loops) |\n", + "| `Protocol` | `stages`, `name`, `lid_temperature` (default lid temp) |\n", + "\n", + "### Defining a protocol" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pcr_protocol = Protocol(\n", + " name=\"StandardPCR\",\n", + " lid_temperature=105.0, # default lid temp for all steps\n", + " stages=[\n", + " # Initial denaturation — 5 min at 95 °C\n", + " Stage(\n", + " steps=[Step(temperature=95.0, hold_seconds=300)],\n", + " repeats=1,\n", + " ),\n", + " # PCR cycling — 30 cycles\n", + " Stage(\n", + " steps=[\n", + " Step(temperature=95.0, hold_seconds=30), # Denature\n", + " Step(temperature=55.0, hold_seconds=30), # Anneal\n", + " Step(temperature=72.0, hold_seconds=60), # Extend\n", + " ],\n", + " repeats=30,\n", + " ),\n", + " # Final extension — 10 min at 72 °C\n", + " Stage(\n", + " steps=[Step(temperature=72.0, hold_seconds=600)],\n", + " repeats=1,\n", + " ),\n", + " # Hold — 4 °C indefinitely\n", + " Stage(\n", + " steps=[Step(temperature=4.0, hold_seconds=float(\"inf\"))],\n", + " repeats=1,\n", + " ),\n", + " ],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### ODTC-specific run parameters\n", + "\n", + "`ODTCThermocyclerBackend.RunProtocolParams` carries device config:\n", + "\n", + "| Parameter | Meaning | Default |\n", + "|-----------|---------|--------|\n", + "| `variant` | 96 or 384 | 96 |\n", + "| `fluid_quantity` | `FluidQuantity.UL_10_TO_29` / `UL_30_TO_74` / `UL_75_TO_100` | `UL_30_TO_74` |\n", + "| `post_heating` | Keep block warm after method | True |\n", + "| `dynamic_pre_method_duration` | Device reports live pre-heat time | True |\n", + "| `apply_overshoot` | Auto-compute overshoot for steps without explicit `Ramp.overshoot` | True |\n", + "| `name` | Protocol name stored on device (None = scratch) | None |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "params = ODTCThermocyclerBackend.RunProtocolParams(\n", + " variant=96,\n", + " fluid_quantity=FluidQuantity.UL_30_TO_74, # 30–74 µL samples\n", + " post_heating=True,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Running the protocol" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await odtc.tc.run_protocol(pcr_protocol, backend_params=params)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`run_protocol()` is fire-and-forget by default — it uploads the method, starts execution, and returns immediately.\n", + "\n", + "### Monitoring progress" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import asyncio\n", + "\n", + "# Wait for the first DataEvent to confirm the method actually started\n", + "progress = await odtc.tc.wait_for_first_progress(timeout=60.0)\n", + "print(\"Protocol started:\", progress)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Poll progress every 5 seconds\n", + "for _ in range(6):\n", + " progress = await odtc.tc.request_progress()\n", + " if progress is not None:\n", + " print(progress)\n", + " await asyncio.sleep(5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Stopping a protocol" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await odtc.tc.stop_protocol()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Custom ramp rates\n", + "\n", + "Control how fast the block transitions between steps using `Ramp(rate=...)`. The rate is in °C/s.\n", + "\n", + "- `Ramp()` or `FULL_SPEED` — as fast as the device can go (hardware default)\n", + "- `Ramp(rate=4.4)` — maximum heating rate for 96-well variant\n", + "- `Ramp(rate=2.2)` — maximum cooling rate for 96-well variant\n", + "- `Ramp(rate=1.0)` — gentle ramp for sensitive applications" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "precise_protocol = Protocol(\n", + " name=\"PreciseRamps\",\n", + " stages=[\n", + " Stage(\n", + " steps=[\n", + " Step(temperature=95.0, hold_seconds=30, ramp=Ramp(rate=4.4)), # max heating\n", + " Step(temperature=55.0, hold_seconds=30, ramp=Ramp(rate=2.2)), # max cooling\n", + " Step(temperature=72.0, hold_seconds=60, ramp=Ramp(rate=1.5)), # gentle\n", + " ],\n", + " repeats=10,\n", + " ),\n", + " ],\n", + ")\n", + "\n", + "await odtc.tc.run_protocol(\n", + " precise_protocol,\n", + " backend_params=ODTCThermocyclerBackend.RunProtocolParams(fluid_quantity=1),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Per-step lid temperature\n", + "\n", + "The heated lid temperature can be set globally on `Protocol.lid_temperature`, or overridden per step via `Step.lid_temperature`.\n", + "\n", + "> **Note:** `lid_temperature` refers to the *heated cover* that prevents condensation — it is separate from the motorized `door` capability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "lid_protocol = Protocol(\n", + " name=\"PerStepLid\",\n", + " lid_temperature=105.0, # default for all steps\n", + " stages=[\n", + " Stage(\n", + " steps=[\n", + " Step(temperature=95.0, hold_seconds=30), # lid = 105 °C (default)\n", + " Step(temperature=55.0, hold_seconds=30, lid_temperature=100.0), # override\n", + " Step(temperature=72.0, hold_seconds=60, lid_temperature=110.0), # override\n", + " ],\n", + " repeats=5,\n", + " ),\n", + " ],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Overshoot control\n", + "\n", + "The ODTC uses *overshoot* to achieve fast, precise ramp rates: the block briefly exceeds the target temperature, then settles to the exact plateau. The backend computes optimal overshoot parameters automatically.\n", + "\n", + "You can also specify an explicit overshoot:\n", + "\n", + "```python\n", + "Ramp(\n", + " rate=4.4,\n", + " overshoot=Overshoot(\n", + " target_temp=5.5, # °C above plateau to briefly reach\n", + " hold_seconds=0.0, # time at peak (0 = triangular pulse)\n", + " return_rate=2.2, # °C/s falling back to plateau\n", + " )\n", + ")\n", + "```\n", + "\n", + "When `overshoot` is `None` (the default), the backend computes it from temperature delta, ramp rate, and fluid quantity. To **disable** overshoot entirely, pass `apply_overshoot=False` to `RunProtocolParams` or `ODTCProtocol.from_protocol()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Auto-computed overshoot (recommended)\n", + "auto_overshoot_protocol = Protocol(\n", + " stages=[\n", + " Stage(\n", + " steps=[\n", + " Step(temperature=95.0, hold_seconds=30, ramp=Ramp(rate=4.4)),\n", + " Step(temperature=55.0, hold_seconds=30, ramp=Ramp(rate=2.2)),\n", + " ],\n", + " repeats=35,\n", + " ),\n", + " ],\n", + ")\n", + "\n", + "# Explicit overshoot override\n", + "explicit_overshoot_protocol = Protocol(\n", + " stages=[\n", + " Stage(\n", + " steps=[\n", + " Step(\n", + " temperature=95.0,\n", + " hold_seconds=30,\n", + " ramp=Ramp(\n", + " rate=4.4,\n", + " overshoot=Overshoot(\n", + " target_temp=6.0,\n", + " hold_seconds=0.0,\n", + " return_rate=2.2,\n", + " ),\n", + " ),\n", + " ),\n", + " ],\n", + " repeats=1,\n", + " )\n", + " ],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Nested cycling loops\n", + "\n", + "`Stage.inner_stages` supports nested PCR loops. The ODTC encodes these natively using goto/loop firmware instructions.\n", + "\n", + "Example: outer 30-cycle loop containing an inner 5-cycle loop:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "nested_protocol = Protocol(\n", + " name=\"NestedCycles\",\n", + " stages=[\n", + " Stage(\n", + " steps=[Step(temperature=95.0, hold_seconds=10)],\n", + " repeats=30,\n", + " inner_stages=[\n", + " Stage(\n", + " steps=[\n", + " Step(temperature=55.0, hold_seconds=10),\n", + " Step(temperature=72.0, hold_seconds=20),\n", + " ],\n", + " repeats=5,\n", + " ),\n", + " ],\n", + " ),\n", + " Stage(\n", + " steps=[Step(temperature=50.0, hold_seconds=20)],\n", + " repeats=30,\n", + " ),\n", + " ],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Storing named protocols\n", + "\n", + "You can compile and upload a protocol to the device once, then run it later by name — without recompiling or re-uploading. The method persists on the device across sessions until explicitly deleted.\n", + "\n", + "Use `ODTCProtocol.from_protocol()` to compile the protocol with a name, then `upload_protocol()` to store it, and `run_stored_protocol()` to execute it later." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Compile the protocol to the ODTC's device-native form with a persistent name\n", + "odtc_protocol = ODTCProtocol.from_protocol(\n", + " pcr_protocol,\n", + " variant=96,\n", + " fluid_quantity=FluidQuantity.UL_30_TO_74,\n", + " name=\"StandardPCR\", # is_scratch=False: persists on device\n", + " apply_overshoot=True, # default; set False to disable auto-overshoot\n", + ")\n", + "print(odtc_protocol)\n", + "\n", + "# Upload once — survives device Reset and can be run in future sessions\n", + "await odtc.tc.backend.upload_protocol(odtc_protocol)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# List methods currently stored on the device\n", + "method_set = await odtc.tc.backend.get_method_set()\n", + "print(method_set)\n", + "\n", + "# Retrieve a stored protocol by name (returns None if not found or is a premethod)\n", + "stored = await odtc.tc.backend.get_protocol(\"StandardPCR\")\n", + "print(stored)\n", + "\n", + "# Run a stored named protocol without re-uploading\n", + "# (works even after device Reset or reconnect)\n", + "await odtc.tc.backend.run_stored_protocol(\"StandardPCR\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Variant: 384-well\n", + "\n", + "The 384-well ODTC uses a higher maximum heating slope (5.0 °C/s vs 4.4 °C/s) and supports plate type 2." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# odtc_384 = ODTC(odtc_ip=\"169.254.x.x\", variant=384, name=\"odtc_384\")\n", + "# await odtc_384.setup()\n", + "#\n", + "# await odtc_384.tc.run_protocol(\n", + "# pcr_protocol,\n", + "# backend_params=ODTCThermocyclerBackend.RunProtocolParams(\n", + "# variant=384,\n", + "# fluid_quantity=2, # 75–100 µL\n", + "# plate_type=0,\n", + "# ),\n", + "# )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await odtc.stop() # deactivates block, closes SiLA connection" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } }, - { - "cell_type": "markdown", - "id": "lhr3z1jpxeo", - "source": "## Setup", - "metadata": {} - }, - { - "cell_type": "code", - "id": "1sizl6o45mj", - "source": "from pylabrobot.resources.coordinate import Coordinate\nfrom pylabrobot.legacy.thermocycling.inheco import ExperimentalODTCBackend\nfrom pylabrobot.legacy.thermocycling.thermocycler import Thermocycler\n\nodtc = Thermocycler(\n name=\"odtc\",\n size_x=159.0,\n size_y=245.0,\n size_z=228.0,\n backend=ExperimentalODTCBackend(ip=\"169.254.151.99\"), # replace with your ODTC's IP address\n child_location=Coordinate(0, 0, 0),\n)\nawait odtc.setup()", - "metadata": {}, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "markdown", - "id": "bml5t8cmqum", - "source": "## Door control\n\nOpen and close the motorized door for plate access:", - "metadata": {} - }, - { - "cell_type": "code", - "id": "cvnumnehnmg", - "source": "await odtc.open_lid()", - "metadata": {}, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "code", - "id": "e7gzc7c4uu5", - "source": "await odtc.close_lid()", - "metadata": {}, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "markdown", - "id": "ipw9nhnuyeb", - "source": "## Temperature control\n\nThe ODTC exposes block and lid temperature control. For general concepts, see [Temperature Control](../../capabilities/temperature-control).\n\n### Reading sensor data\n\nGet current temperatures from all sensors:", - "metadata": {} - }, - { - "cell_type": "code", - "id": "wvs8p0vpi3", - "source": "sensor_data = await odtc.backend.get_sensor_data()\nprint(sensor_data)", - "metadata": {}, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "markdown", - "id": "67olcts1713", - "source": "### Setting block temperature\n\nSet a constant block temperature. The ODTC uses a \"pre-method\" internally, which takes several minutes to stabilize the block and lid evenly before reaching the target.", - "metadata": {} - }, - { - "cell_type": "code", - "id": "jmrk1sspss", - "source": "await odtc.set_block_temperature([37.0])", - "metadata": {}, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "code", - "id": "dipk9575div", - "source": "temp = await odtc.get_block_current_temperature()\nprint(f\"Block temperature: {temp[0]} °C\")", - "metadata": {}, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "code", - "id": "7hw4nqxxmer", - "source": "await odtc.deactivate_block()", - "metadata": {}, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "markdown", - "id": "e6lgmq4nhtl", - "source": "## Running PCR protocols\n\nThe ODTC can run multi-stage PCR protocols defined using `Protocol`, `Stage`, and `Step` objects. A protocol consists of stages, each containing steps with a target temperature and hold time. Stages can repeat multiple times for cycling.\n\n### Defining a protocol", - "metadata": {} - }, - { - "cell_type": "code", - "id": "y6cgg24rvb", - "source": "from pylabrobot.legacy.thermocycling.standard import Protocol, Stage, Step\n\npcr_protocol = Protocol(\n stages=[\n # Initial denaturation\n Stage(\n steps=[Step(temperature=[95.0], hold_seconds=300)], # 95 °C for 5 min\n repeats=1,\n ),\n # PCR cycling (30 cycles)\n Stage(\n steps=[\n Step(temperature=[95.0], hold_seconds=30), # Denature: 95 °C, 30 s\n Step(temperature=[55.0], hold_seconds=30), # Anneal: 55 °C, 30 s\n Step(temperature=[72.0], hold_seconds=60), # Extend: 72 °C, 60 s\n ],\n repeats=30,\n ),\n # Final extension\n Stage(\n steps=[Step(temperature=[72.0], hold_seconds=600)], # 72 °C for 10 min\n repeats=1,\n ),\n # Hold\n Stage(\n steps=[Step(temperature=[4.0], hold_seconds=0)], # 4 °C hold\n repeats=1,\n ),\n ]\n)", - "metadata": {}, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "markdown", - "id": "jm5ljrmir3g", - "source": "### Running the protocol", - "metadata": {} - }, - { - "cell_type": "code", - "id": "cks0kwzd8ac", - "source": "await odtc.run_protocol(\n protocol=pcr_protocol,\n block_max_volume=20.0, # maximum sample volume in uL\n start_block_temperature=25.0, # starting block temperature\n start_lid_temperature=105.0, # lid temperature (typically 105 °C to prevent condensation)\n)", - "metadata": {}, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "markdown", - "id": "yrhel3at07", - "source": "### Custom ramp rates\n\nYou can specify custom temperature ramp rates (°C/s) for individual steps via the `rate` parameter:", - "metadata": {} - }, - { - "cell_type": "code", - "id": "ypntgxtgpp", - "source": "custom_protocol = Protocol(\n stages=[\n Stage(\n steps=[\n Step(temperature=[95.0], hold_seconds=60, rate=4.4), # fast ramp\n Step(temperature=[60.0], hold_seconds=30, rate=2.0), # slower ramp\n ],\n repeats=1,\n ),\n ]\n)\n\nawait odtc.run_protocol(\n protocol=custom_protocol,\n block_max_volume=25.0,\n start_block_temperature=25.0,\n start_lid_temperature=105.0,\n)", - "metadata": {}, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "markdown", - "id": "pkbix9va6y", - "source": "## Teardown", - "metadata": {} - }, - { - "cell_type": "code", - "id": "99bdml259c", - "source": "await odtc.stop()", - "metadata": {}, - "execution_count": null, - "outputs": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.12.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/pylabrobot/capabilities/thermocycling/__init__.py b/pylabrobot/capabilities/thermocycling/__init__.py new file mode 100644 index 00000000000..af3cbcabe24 --- /dev/null +++ b/pylabrobot/capabilities/thermocycling/__init__.py @@ -0,0 +1,29 @@ +"""pylabrobot.capabilities.thermocycling — thermocycler capability module.""" + +from .backend import ThermocyclerBackend +from .chatterbox import ThermocyclerChatterboxBackend +from .standard import ( + FULL_SPEED, + BlockStatus, + LidStatus, + Overshoot, + Protocol, + Ramp, + Stage, + Step, +) +from .thermocycler import Thermocycler + +__all__ = [ + "Overshoot", + "Ramp", + "FULL_SPEED", + "Step", + "Stage", + "Protocol", + "LidStatus", + "BlockStatus", + "ThermocyclerBackend", + "ThermocyclerChatterboxBackend", + "Thermocycler", +] diff --git a/pylabrobot/capabilities/thermocycling/backend.py b/pylabrobot/capabilities/thermocycling/backend.py new file mode 100644 index 00000000000..42352ac48c7 --- /dev/null +++ b/pylabrobot/capabilities/thermocycling/backend.py @@ -0,0 +1,54 @@ +"""Abstract backend for thermocyclers.""" + +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import Any, Optional + +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend + +from .standard import Protocol + + +class ThermocyclerBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend interface for thermocycler devices.""" + + @abstractmethod + async def run_protocol( + self, + protocol: Protocol, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Execute a thermocycler protocol. Fire-and-forget by default; backends + may support a ``wait`` flag via ``backend_params``.""" + + @abstractmethod + async def set_block_temperature( + self, + temperature: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Set the block to a target temperature and hold. Fire-and-forget by default.""" + + @abstractmethod + async def deactivate_block(self, backend_params: Optional[BackendParams] = None) -> None: + """Stop block temperature control.""" + + @abstractmethod + async def request_block_temperature(self) -> float: + """Return current block temperature in °C.""" + + @abstractmethod + async def request_lid_temperature(self) -> float: + """Return current lid temperature in °C.""" + + async def request_progress(self) -> Optional[Any]: + """Return backend-specific progress object for the running protocol. + + Returns None if no protocol is running or progress is not available. + Backends override this to provide rich progress data. + """ + return None + + async def stop_protocol(self, backend_params: Optional[BackendParams] = None) -> None: + """Stop the currently running protocol. Default: no-op (override if supported).""" diff --git a/pylabrobot/capabilities/thermocycling/chatterbox.py b/pylabrobot/capabilities/thermocycling/chatterbox.py new file mode 100644 index 00000000000..fc4d0c3819d --- /dev/null +++ b/pylabrobot/capabilities/thermocycling/chatterbox.py @@ -0,0 +1,66 @@ +"""Chatterbox (in-memory, device-free) backend for thermocycler testing.""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from pylabrobot.capabilities.capability import BackendParams + +from .backend import ThermocyclerBackend +from .standard import Protocol + +logger = logging.getLogger(__name__) + + +class ThermocyclerChatterboxBackend(ThermocyclerBackend): + """In-memory thermocycler backend for testing and simulation. + + All operations succeed immediately and log at INFO level. + Stores the last-set temperatures for assertion in tests. + """ + + def __init__(self) -> None: + self._block_temperature: float = 25.0 + self._lid_temperature: float = 25.0 + self._current_protocol: Optional[Protocol] = None + + async def _on_setup(self, backend_params: Optional[BackendParams] = None) -> None: + self._block_temperature = 25.0 + self._lid_temperature = 25.0 + self._current_protocol = None + + async def run_protocol( + self, + protocol: Protocol, + backend_params: Optional[BackendParams] = None, + ) -> None: + logger.info("ThermocyclerChatterbox: run_protocol name=%r", protocol.name) + self._current_protocol = protocol + + async def set_block_temperature( + self, + temperature: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + logger.info("ThermocyclerChatterbox: set_block_temperature %.1f°C", temperature) + self._block_temperature = temperature + + async def deactivate_block(self, backend_params: Optional[BackendParams] = None) -> None: + logger.info("ThermocyclerChatterbox: deactivate_block") + self._current_protocol = None + + async def request_block_temperature(self) -> float: + return self._block_temperature + + async def request_lid_temperature(self) -> float: + return self._lid_temperature + + async def request_progress(self) -> Optional[Any]: + if self._current_protocol is None: + return None + return {"protocol_name": self._current_protocol.name, "running": True} + + async def stop_protocol(self, backend_params: Optional[BackendParams] = None) -> None: + logger.info("ThermocyclerChatterbox: stop_protocol") + self._current_protocol = None diff --git a/pylabrobot/capabilities/thermocycling/standard.py b/pylabrobot/capabilities/thermocycling/standard.py new file mode 100644 index 00000000000..96d083f3f08 --- /dev/null +++ b/pylabrobot/capabilities/thermocycling/standard.py @@ -0,0 +1,207 @@ +"""Standard types for thermocycler protocols. + +Defines the abstract protocol model: +- Overshoot / Ramp: step transition profile +- Step / Stage / Protocol: hierarchical cycle description +- LidStatus / BlockStatus: runtime state enums + +These types are backend-agnostic. Backend-specific parameters +attach to Step.backend_params (BackendParams subclass). +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, List, Optional + +from pylabrobot.serializer import SerializableMixin + +if TYPE_CHECKING: + from pylabrobot.capabilities.capability import BackendParams + + +@dataclass(frozen=True) +class Overshoot: + """Transient temperature excursion during a step transition. + + The backend decides how to honor this: use a native device overshoot + construct (ODTC), insert an explicit intermediate step, or ignore it. + When not specified, the backend computes overshoot from hardware physics + and the requested ramp rate. + + Args: + target_temp: Peak temperature to briefly reach (°C). + hold_seconds: Time to spend at the peak (seconds). + return_rate: Ramp rate falling back to the step target (°C/s). + """ + + target_temp: float + hold_seconds: float + return_rate: float + + +@dataclass(frozen=True) +class Ramp: + """Transition profile into a step's target temperature. + + Usage: + Ramp() # full device speed, no overshoot + Ramp(rate=5.0) # linear 5 °C/s + Ramp(rate=5.0, overshoot=...) # fast ramp with managed overshoot + + Args: + rate: Ramp rate in °C/s. ``float('inf')`` means as fast as the + device allows (the default). + overshoot: Optional overshoot hint. If None, the backend decides + whether and how to overshoot based on hardware physics. + """ + + rate: float = float("inf") + overshoot: Optional[Overshoot] = None + + +FULL_SPEED = Ramp() +"""Canonical zero-boilerplate Ramp: full device speed, no overshoot.""" + + +@dataclass +class Step(SerializableMixin): + """A single temperature hold in a thermocycler profile. + + Args: + temperature: Target block temperature in °C. + hold_seconds: Seconds to hold at target. Use ``float('inf')`` for + an indefinite hold (wait until continued). + ramp: Transition profile into this step's temperature. + Defaults to FULL_SPEED (full device speed, no overshoot). + lid_temperature: Optional lid/cover target temperature in °C. + None means use the Stage or Protocol default. + backend_params: Optional backend-specific per-step parameters + (e.g. ``ODTCThermocyclerBackend.StepParams``). Opaque to PLR core. + """ + + temperature: float + hold_seconds: float + ramp: Ramp = field(default_factory=Ramp) + lid_temperature: Optional[float] = None + backend_params: Optional["BackendParams"] = None + + def serialize(self) -> dict: + return { + "type": self.__class__.__name__, + "temperature": self.temperature, + "hold_seconds": self.hold_seconds, + "ramp": { + "rate": self.ramp.rate, + "overshoot": { + "target_temp": self.ramp.overshoot.target_temp, + "hold_seconds": self.ramp.overshoot.hold_seconds, + "return_rate": self.ramp.overshoot.return_rate, + } + if self.ramp.overshoot is not None + else None, + }, + "lid_temperature": self.lid_temperature, + } + + @classmethod + def deserialize(cls, data: dict) -> "Step": + ramp_data = data.get("ramp", {}) + overshoot_data = ramp_data.get("overshoot") + overshoot = ( + Overshoot( + target_temp=overshoot_data["target_temp"], + hold_seconds=overshoot_data["hold_seconds"], + return_rate=overshoot_data["return_rate"], + ) + if overshoot_data is not None + else None + ) + ramp = Ramp(rate=ramp_data.get("rate", float("inf")), overshoot=overshoot) + return cls( + temperature=data["temperature"], + hold_seconds=data["hold_seconds"], + ramp=ramp, + lid_temperature=data.get("lid_temperature"), + ) + + +@dataclass +class Stage(SerializableMixin): + """A set of steps that repeats a fixed number of times. + + Args: + steps: The ordered steps in this stage. + repeats: Number of times the stage repeats (default 1). + inner_stages: Nested child stages for complex cycling patterns + (e.g. inner PCR loop inside an outer denaturation loop). + Empty list means no nesting. + """ + + steps: List[Step] + repeats: int = 1 + inner_stages: List["Stage"] = field(default_factory=list) + + def serialize(self) -> dict: + return { + "type": self.__class__.__name__, + "steps": [s.serialize() for s in self.steps], + "repeats": self.repeats, + "inner_stages": [s.serialize() for s in self.inner_stages], + } + + @classmethod + def deserialize(cls, data: dict) -> "Stage": + steps = [Step.deserialize(s) for s in data.get("steps", [])] + inner_stages = [Stage.deserialize(s) for s in data.get("inner_stages", [])] + return cls(steps=steps, repeats=data.get("repeats", 1), inner_stages=inner_stages) + + +@dataclass +class Protocol(SerializableMixin): + """A complete thermocycler run profile. + + Args: + stages: Ordered list of stages that constitute the protocol. + name: Protocol name used for device storage and logging. Empty string + means unnamed / scratch. + lid_temperature: Default lid/cover temperature in °C applied to all + steps unless overridden at the Stage or Step level. None means use + the device/backend default. + """ + + stages: List[Stage] + name: str = "" + lid_temperature: Optional[float] = None + + def serialize(self) -> dict: + return { + "type": self.__class__.__name__, + "stages": [s.serialize() for s in self.stages], + "name": self.name, + "lid_temperature": self.lid_temperature, + } + + @classmethod + def deserialize(cls, data: dict) -> "Protocol": + stages = [Stage.deserialize(s) for s in data.get("stages", [])] + return cls( + stages=stages, + name=data.get("name", ""), + lid_temperature=data.get("lid_temperature"), + ) + + +class LidStatus(enum.Enum): + """Temperature status of the thermocycler lid.""" + + IDLE = "idle" + HOLDING_AT_TARGET = "holding at target" + + +class BlockStatus(enum.Enum): + """Temperature status of the thermocycler block.""" + + IDLE = "idle" + HOLDING_AT_TARGET = "holding at target" diff --git a/pylabrobot/capabilities/thermocycling/tests/__init__.py b/pylabrobot/capabilities/thermocycling/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/capabilities/thermocycling/tests/standard_tests.py b/pylabrobot/capabilities/thermocycling/tests/standard_tests.py new file mode 100644 index 00000000000..3b847bd0f52 --- /dev/null +++ b/pylabrobot/capabilities/thermocycling/tests/standard_tests.py @@ -0,0 +1,193 @@ +"""Tests for thermocycling standard types.""" + +import math +import unittest + +from pylabrobot.capabilities.thermocycling.standard import ( + FULL_SPEED, + BlockStatus, + LidStatus, + Overshoot, + Protocol, + Ramp, + Stage, + Step, +) + + +class TestOvershoot(unittest.TestCase): + def test_construction(self): + o = Overshoot(target_temp=101.0, hold_seconds=2.0, return_rate=2.2) + self.assertEqual(o.target_temp, 101.0) + self.assertEqual(o.hold_seconds, 2.0) + self.assertEqual(o.return_rate, 2.2) + + def test_frozen(self): + o = Overshoot(target_temp=101.0, hold_seconds=2.0, return_rate=2.2) + with self.assertRaises(Exception): + o.target_temp = 99.0 # type: ignore + + +class TestRamp(unittest.TestCase): + def test_default_is_full_speed(self): + r = Ramp() + self.assertEqual(r, FULL_SPEED) + self.assertTrue(math.isinf(r.rate)) + self.assertIsNone(r.overshoot) + + def test_with_rate(self): + r = Ramp(rate=5.0) + self.assertEqual(r.rate, 5.0) + self.assertIsNone(r.overshoot) + + def test_with_overshoot(self): + o = Overshoot(target_temp=101.0, hold_seconds=2.0, return_rate=2.2) + r = Ramp(rate=5.0, overshoot=o) + self.assertEqual(r.overshoot, o) + + def test_frozen(self): + r = Ramp(rate=5.0) + with self.assertRaises(Exception): + r.rate = 3.0 # type: ignore + + +class TestStep(unittest.TestCase): + def test_defaults(self): + s = Step(temperature=95.0, hold_seconds=30.0) + self.assertEqual(s.temperature, 95.0) + self.assertEqual(s.hold_seconds, 30.0) + self.assertEqual(s.ramp, FULL_SPEED) + self.assertIsNone(s.lid_temperature) + self.assertIsNone(s.backend_params) + + def test_with_ramp(self): + r = Ramp(rate=4.4) + s = Step(temperature=95.0, hold_seconds=30.0, ramp=r) + self.assertEqual(s.ramp.rate, 4.4) + + def test_with_lid_temperature(self): + s = Step(temperature=55.0, hold_seconds=30.0, lid_temperature=110.0) + self.assertEqual(s.lid_temperature, 110.0) + + def test_inf_hold(self): + s = Step(temperature=4.0, hold_seconds=float("inf")) + self.assertTrue(math.isinf(s.hold_seconds)) + + def test_serialize_deserialize_no_overshoot(self): + s = Step(temperature=72.0, hold_seconds=60.0, ramp=Ramp(rate=2.2), lid_temperature=105.0) + data = s.serialize() + self.assertEqual(data["temperature"], 72.0) + self.assertEqual(data["ramp"]["rate"], 2.2) + self.assertIsNone(data["ramp"]["overshoot"]) + self.assertEqual(data["lid_temperature"], 105.0) + s2 = Step.deserialize(data) + self.assertEqual(s2.temperature, 72.0) + self.assertEqual(s2.ramp.rate, 2.2) + self.assertIsNone(s2.ramp.overshoot) + + def test_serialize_deserialize_with_overshoot(self): + o = Overshoot(target_temp=101.0, hold_seconds=2.0, return_rate=2.2) + s = Step(temperature=95.0, hold_seconds=30.0, ramp=Ramp(rate=4.4, overshoot=o)) + data = s.serialize() + self.assertIsNotNone(data["ramp"]["overshoot"]) + self.assertEqual(data["ramp"]["overshoot"]["target_temp"], 101.0) + s2 = Step.deserialize(data) + self.assertIsNotNone(s2.ramp.overshoot) + self.assertEqual(s2.ramp.overshoot.target_temp, 101.0) + self.assertEqual(s2.ramp.overshoot.return_rate, 2.2) + + def test_serialize_default_ramp(self): + s = Step(temperature=95.0, hold_seconds=30.0) + data = s.serialize() + s2 = Step.deserialize(data) + self.assertTrue(math.isinf(s2.ramp.rate)) + self.assertIsNone(s2.ramp.overshoot) + + +class TestStage(unittest.TestCase): + def test_defaults(self): + steps = [Step(temperature=95.0, hold_seconds=30.0)] + stage = Stage(steps=steps) + self.assertEqual(stage.repeats, 1) + self.assertEqual(stage.inner_stages, []) + + def test_with_repeats(self): + steps = [Step(temperature=95.0, hold_seconds=30.0)] + stage = Stage(steps=steps, repeats=35) + self.assertEqual(stage.repeats, 35) + + def test_inner_stages_default_is_empty_list(self): + stage = Stage(steps=[]) + self.assertIsInstance(stage.inner_stages, list) + self.assertEqual(len(stage.inner_stages), 0) + + def test_inner_stages_are_independent(self): + s1 = Stage(steps=[]) + s2 = Stage(steps=[]) + s1.inner_stages.append(Stage(steps=[])) + self.assertEqual(len(s1.inner_stages), 1) + self.assertEqual(len(s2.inner_stages), 0) + + def test_serialize_deserialize(self): + inner = Stage(steps=[Step(temperature=72.0, hold_seconds=60.0)], repeats=5) + outer = Stage( + steps=[Step(temperature=95.0, hold_seconds=10.0)], + repeats=30, + inner_stages=[inner], + ) + data = outer.serialize() + outer2 = Stage.deserialize(data) + self.assertEqual(outer2.repeats, 30) + self.assertEqual(len(outer2.inner_stages), 1) + self.assertEqual(outer2.inner_stages[0].repeats, 5) + self.assertEqual(outer2.inner_stages[0].steps[0].temperature, 72.0) + + +class TestProtocol(unittest.TestCase): + def test_defaults(self): + p = Protocol(stages=[]) + self.assertEqual(p.name, "") + self.assertIsNone(p.lid_temperature) + + def test_with_name_and_lid(self): + p = Protocol(stages=[], name="MyPCR", lid_temperature=105.0) + self.assertEqual(p.name, "MyPCR") + self.assertEqual(p.lid_temperature, 105.0) + + def test_serialize_deserialize(self): + p = Protocol( + stages=[ + Stage( + steps=[ + Step(temperature=95.0, hold_seconds=30.0, ramp=Ramp(rate=4.4)), + Step(temperature=55.0, hold_seconds=30.0), + ], + repeats=35, + ) + ], + name="TestPCR", + lid_temperature=110.0, + ) + data = p.serialize() + p2 = Protocol.deserialize(data) + self.assertEqual(p2.name, "TestPCR") + self.assertEqual(p2.lid_temperature, 110.0) + self.assertEqual(len(p2.stages), 1) + self.assertEqual(p2.stages[0].repeats, 35) + self.assertEqual(p2.stages[0].steps[0].temperature, 95.0) + self.assertEqual(p2.stages[0].steps[0].ramp.rate, 4.4) + self.assertEqual(p2.stages[0].steps[1].temperature, 55.0) + + +class TestEnums(unittest.TestCase): + def test_lid_status_values(self): + self.assertEqual(LidStatus.IDLE.value, "idle") + self.assertEqual(LidStatus.HOLDING_AT_TARGET.value, "holding at target") + + def test_block_status_values(self): + self.assertEqual(BlockStatus.IDLE.value, "idle") + self.assertEqual(BlockStatus.HOLDING_AT_TARGET.value, "holding at target") + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/capabilities/thermocycling/tests/thermocycler_tests.py b/pylabrobot/capabilities/thermocycling/tests/thermocycler_tests.py new file mode 100644 index 00000000000..7bd4b2adb69 --- /dev/null +++ b/pylabrobot/capabilities/thermocycling/tests/thermocycler_tests.py @@ -0,0 +1,118 @@ +"""Tests for Thermocycler capability and chatterbox backend.""" + +import unittest + +from pylabrobot.capabilities.thermocycling.chatterbox import ThermocyclerChatterboxBackend +from pylabrobot.capabilities.thermocycling.standard import ( + Protocol, + Ramp, + Stage, + Step, +) +from pylabrobot.capabilities.thermocycling.thermocycler import Thermocycler + + +def _make_thermocycler() -> Thermocycler: + backend = ThermocyclerChatterboxBackend() + return Thermocycler(backend=backend) + + +def _simple_protocol() -> Protocol: + return Protocol( + stages=[ + Stage(steps=[Step(temperature=95.0, hold_seconds=30.0)], repeats=1), + Stage( + steps=[ + Step(temperature=95.0, hold_seconds=10.0, ramp=Ramp(rate=4.4)), + Step(temperature=55.0, hold_seconds=30.0), + Step(temperature=72.0, hold_seconds=60.0), + ], + repeats=35, + ), + ], + name="TestPCR", + ) + + +class TestThermocyclerCapability(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.tc = _make_thermocycler() + await self.tc._on_setup() + + async def asyncTearDown(self): + await self.tc._on_stop() + + async def test_run_protocol_stores_current_protocol(self): + protocol = _simple_protocol() + await self.tc.run_protocol(protocol) + self.assertIs(self.tc._current_protocol, protocol) + + async def test_set_block_temperature(self): + await self.tc.set_block_temperature(37.0) + self.assertAlmostEqual(self.tc.backend._block_temperature, 37.0) + + async def test_request_block_temperature(self): + await self.tc.set_block_temperature(72.0) + temp = await self.tc.request_block_temperature() + self.assertAlmostEqual(temp, 72.0) + + async def test_request_lid_temperature(self): + temp = await self.tc.request_lid_temperature() + self.assertAlmostEqual(temp, 25.0) + + async def test_deactivate_block_clears_protocol(self): + await self.tc.run_protocol(_simple_protocol()) + await self.tc.deactivate_block() + progress = await self.tc.request_progress() + self.assertIsNone(progress) + + async def test_request_progress_none_when_idle(self): + progress = await self.tc.request_progress() + self.assertIsNone(progress) + + async def test_request_progress_after_run(self): + await self.tc.run_protocol(_simple_protocol()) + progress = await self.tc.request_progress() + self.assertIsNotNone(progress) + self.assertEqual(progress["protocol_name"], "TestPCR") + + async def test_stop_protocol(self): + await self.tc.run_protocol(_simple_protocol()) + await self.tc.stop_protocol() + self.assertIsNone(self.tc._current_protocol) + + async def test_on_stop_deactivates(self): + await self.tc.run_protocol(_simple_protocol()) + await self.tc._on_stop() + self.assertIsNone(self.tc._current_protocol) + self.assertFalse(self.tc._setup_finished) + + async def test_wait_for_first_progress(self): + await self.tc.run_protocol(_simple_protocol()) + progress = await self.tc.wait_for_first_progress(timeout=1.0) + self.assertIsNotNone(progress) + + async def test_wait_for_first_progress_timeout(self): + with self.assertRaises(TimeoutError): + await self.tc.wait_for_first_progress(timeout=0.1) + + +class TestNeedCapabilityReady(unittest.IsolatedAsyncioTestCase): + async def test_guard_fires_before_setup(self): + tc = _make_thermocycler() + with self.assertRaises(RuntimeError): + await tc.run_protocol(_simple_protocol()) + with self.assertRaises(RuntimeError): + await tc.set_block_temperature(37.0) + with self.assertRaises(RuntimeError): + await tc.request_block_temperature() + + async def test_guard_passes_after_setup(self): + tc = _make_thermocycler() + await tc._on_setup() + await tc.set_block_temperature(37.0) + await tc._on_stop() + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/capabilities/thermocycling/thermocycler.py b/pylabrobot/capabilities/thermocycling/thermocycler.py new file mode 100644 index 00000000000..dd88fdb661f --- /dev/null +++ b/pylabrobot/capabilities/thermocycling/thermocycler.py @@ -0,0 +1,151 @@ +"""Thermocycler capability — user-facing API.""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any, Optional + +from pylabrobot.capabilities.capability import BackendParams, Capability, need_capability_ready + +from .backend import ThermocyclerBackend +from .standard import BlockStatus, LidStatus, Protocol + + +class Thermocycler(Capability): + """Thermocycler capability for running PCR and other temperature-cycling protocols. + + Wraps a ThermocyclerBackend and exposes a clean, guarded API. + Owned by a Device; lifecycle managed via ``_on_setup`` / ``_on_stop``. + """ + + def __init__(self, backend: ThermocyclerBackend) -> None: + super().__init__(backend=backend) + self.backend: ThermocyclerBackend = backend + self._current_protocol: Optional[Protocol] = None + + # ------------------------------------------------------------------ + # Protocol execution + # ------------------------------------------------------------------ + + @need_capability_ready + async def run_protocol( + self, + protocol: Protocol, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Execute a thermocycler protocol. + + Args: + protocol: The protocol to run. + backend_params: Optional backend-specific parameters (e.g. variant, + fluid_quantity for ODTC). + """ + self._current_protocol = protocol + await self.backend.run_protocol(protocol, backend_params=backend_params) + + @need_capability_ready + async def stop_protocol(self, backend_params: Optional[BackendParams] = None) -> None: + """Stop the currently running protocol.""" + await self.backend.stop_protocol(backend_params=backend_params) + self._current_protocol = None + + # ------------------------------------------------------------------ + # Temperature control + # ------------------------------------------------------------------ + + @need_capability_ready + async def set_block_temperature( + self, + temperature: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Set block temperature and hold. + + Args: + temperature: Target block temperature in °C. + backend_params: Optional backend-specific parameters. + """ + await self.backend.set_block_temperature(temperature, backend_params=backend_params) + + @need_capability_ready + async def deactivate_block(self, backend_params: Optional[BackendParams] = None) -> None: + """Deactivate block temperature control.""" + await self.backend.deactivate_block(backend_params=backend_params) + + @need_capability_ready + async def request_block_temperature(self) -> float: + """Return current block temperature in °C.""" + return await self.backend.request_block_temperature() + + @need_capability_ready + async def request_lid_temperature(self) -> float: + """Return current lid temperature in °C.""" + return await self.backend.request_lid_temperature() + + # ------------------------------------------------------------------ + # Progress and status + # ------------------------------------------------------------------ + + @need_capability_ready + async def request_progress(self) -> Optional[Any]: + """Return backend-specific progress for the running protocol, or None.""" + return await self.backend.request_progress() + + async def wait_for_first_progress(self, timeout: float = 60.0) -> Any: + """Block until the backend reports non-None progress, or raise TimeoutError. + + Useful for confirming that a protocol has actually started executing. + + Args: + timeout: Maximum seconds to wait (default 60). + + Returns: + The first non-None progress object. + + Raises: + RuntimeError: If capability is not set up. + TimeoutError: If no progress arrives within ``timeout`` seconds. + """ + if not self._setup_finished: + raise RuntimeError("Thermocycler capability is not set up.") + start = time.time() + while time.time() - start < timeout: + progress = await self.backend.request_progress() + if progress is not None: + return progress + await asyncio.sleep(0.5) + raise TimeoutError(f"No protocol progress received within {timeout}s.") + + async def get_block_status(self) -> BlockStatus: + """Return current block status (convenience wrapper).""" + if not self._setup_finished: + return BlockStatus.IDLE + try: + await self.backend.request_block_temperature() + return BlockStatus.HOLDING_AT_TARGET + except Exception: + return BlockStatus.IDLE + + async def get_lid_status(self) -> LidStatus: + """Return current lid status (convenience wrapper).""" + if not self._setup_finished: + return LidStatus.IDLE + try: + await self.backend.request_lid_temperature() + return LidStatus.HOLDING_AT_TARGET + except Exception: + return LidStatus.IDLE + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def _on_stop(self) -> None: + if self._setup_finished: + try: + await self.backend.deactivate_block() + except Exception: + pass + self._current_protocol = None + await super()._on_stop() diff --git a/pylabrobot/inheco/odtc/__init__.py b/pylabrobot/inheco/odtc/__init__.py new file mode 100644 index 00000000000..e44839fa850 --- /dev/null +++ b/pylabrobot/inheco/odtc/__init__.py @@ -0,0 +1,32 @@ +"""pylabrobot.inheco.odtc — Inheco ODTC thermocycler.""" + +from .backend import ODTCThermocyclerBackend +from .door import DoorStateUnknownError, ODTCDoorBackend +from .driver import ODTCDriver +from .model import ( + FluidQuantity, + ODTCPID, + ODTCMethodSet, + ODTCProgress, + ODTCProtocol, + ODTCSensorValues, + normalize_variant, + volume_to_fluid_quantity, +) +from .odtc import ODTC + +__all__ = [ + "ODTC", + "ODTCDriver", + "ODTCThermocyclerBackend", + "ODTCDoorBackend", + "DoorStateUnknownError", + "FluidQuantity", + "ODTCProtocol", + "ODTCPID", + "ODTCMethodSet", + "ODTCSensorValues", + "ODTCProgress", + "normalize_variant", + "volume_to_fluid_quantity", +] diff --git a/pylabrobot/inheco/odtc/backend.py b/pylabrobot/inheco/odtc/backend.py new file mode 100644 index 00000000000..508972400b5 --- /dev/null +++ b/pylabrobot/inheco/odtc/backend.py @@ -0,0 +1,389 @@ +"""ODTCThermocyclerBackend — v1b1 CapabilityBackend for the ODTC.""" + +from __future__ import annotations + +import asyncio +import logging +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from typing import Any, List, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.thermocycling.backend import ThermocyclerBackend +from pylabrobot.capabilities.thermocycling.standard import Protocol +from pylabrobot.inheco.scila.inheco_sila_interface import SiLAState + +from .driver import ODTCDriver +from .model import ( + FluidQuantity, + ODTCPID, + ODTCMethodSet, + ODTCProgress, + ODTCProtocol, + ODTCSensorValues, + ODTCVariant, + normalize_variant, + volume_to_fluid_quantity, +) +from .protocol import ( + _from_protocol, + build_progress_from_data_event, +) +from .xml import ( + method_set_to_xml, + parse_method_set, + parse_sensor_values, +) + + +class ODTCThermocyclerBackend(ThermocyclerBackend): + """ThermocyclerBackend implementation for the Inheco ODTC. + + Uses ODTCDriver for SiLA communication. Accepts plain Protocol (compiles + via ODTCProtocol.from_protocol) or ODTCProtocol directly (used as-is). + + Device config is passed via RunProtocolParams; per-step overrides via + StepParams attached to Step.backend_params. + """ + + @dataclass + class RunProtocolParams(BackendParams): + """ODTC-specific parameters for run_protocol / execute_method. + + Replaces the old ODTCConfig. Pass as backend_params to run_protocol(). + """ + variant: ODTCVariant = 96 + fluid_quantity: FluidQuantity = field(default=FluidQuantity.UL_30_TO_74) + plate_type: int = 0 + post_heating: bool = True + pid_set: List[ODTCPID] = field(default_factory=lambda: [ODTCPID(number=1)]) + dynamic_pre_method_duration: bool = True + default_heating_slope: Optional[float] = None # None = hardware max + default_cooling_slope: Optional[float] = None # None = hardware max + name: Optional[str] = None + creator: Optional[str] = None + apply_overshoot: bool = True + """If True (default), auto-compute overshoot for steps without an explicit Ramp.overshoot. + If False, no overshoot is applied regardless of ramp rate or fluid quantity. + Explicit Ramp.overshoot values are always honoured either way.""" + + @dataclass + class StepParams(BackendParams): + """Per-step ODTC overrides. Attach to Step.backend_params.""" + pid_number: int = 1 + + def __init__( + self, + driver: ODTCDriver, + variant: int = 96, + logger: Optional[logging.Logger] = None, + ) -> None: + self._driver = driver + self._variant: ODTCVariant = normalize_variant(variant) + self.logger = logger or logging.getLogger(__name__) + self._current_request_id: Optional[int] = None + self._current_odtc_protocol: Optional[ODTCProtocol] = None + self._last_target_temp_c: Optional[float] = None + self._timeout: float = 10800.0 # 3-hour fallback; overridden by mcDuration + + async def _on_setup(self, backend_params: Optional[BackendParams] = None) -> None: + self._current_request_id = None + self._current_odtc_protocol = None + self._last_target_temp_c = None + + def _clear_execution_state(self) -> None: + self._current_request_id = None + self._current_odtc_protocol = None + self._last_target_temp_c = None + + # ------------------------------------------------------------------ + # Protocol helpers + # ------------------------------------------------------------------ + + def _resolve_odtc_protocol( + self, + protocol: Protocol, + params: "ODTCThermocyclerBackend.RunProtocolParams", + ) -> ODTCProtocol: + """Return ODTCProtocol, compiling from generic Protocol if needed.""" + if isinstance(protocol, ODTCProtocol): + return protocol + return _from_protocol( + protocol, + variant=params.variant, + fluid_quantity=params.fluid_quantity, + plate_type=params.plate_type, + post_heating=params.post_heating, + pid_set=list(params.pid_set), + name=params.name, + default_heating_slope=params.default_heating_slope, + default_cooling_slope=params.default_cooling_slope, + apply_overshoot=params.apply_overshoot, + creator=params.creator, + ) + + async def _upload_method_set( + self, + method_set: ODTCMethodSet, + dynamic_pre_method_duration: bool = True, + ) -> None: + """Upload a MethodSet to the device via SetParameters.""" + method_set_xml = method_set_to_xml(method_set) + param_set = ET.Element("ParameterSet") + param = ET.SubElement(param_set, "Parameter", parameterType="String", name="MethodsXML") + ET.SubElement(param, "String").text = method_set_xml + dpm_param = ET.SubElement(param_set, "Parameter", parameterType="Boolean", name="DynamicPreMethodDuration") + ET.SubElement(dpm_param, "Boolean").text = "true" if dynamic_pre_method_duration else "false" + params_xml = ET.tostring(param_set, encoding="unicode", xml_declaration=False) + await self._driver.send_command("SetParameters", paramsXML=params_xml) + + # ------------------------------------------------------------------ + # ThermocyclerBackend abstract methods + # ------------------------------------------------------------------ + + async def run_protocol( + self, + protocol: Protocol, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Upload and start a protocol. Non-blocking (fire-and-forget).""" + if not isinstance(backend_params, ODTCThermocyclerBackend.RunProtocolParams): + backend_params = ODTCThermocyclerBackend.RunProtocolParams(variant=self._variant) + + odtc = self._resolve_odtc_protocol(protocol, backend_params) + + # Upload as method set + if odtc.kind == "method": + ms = ODTCMethodSet(methods=[odtc]) + else: + ms = ODTCMethodSet(premethods=[odtc]) + await self._upload_method_set(ms, dynamic_pre_method_duration=backend_params.dynamic_pre_method_duration) + + # Execute + self._clear_execution_state() + self._current_odtc_protocol = odtc + fut, request_id = await self._driver.send_command_async("ExecuteMethod", methodName=odtc.name) + self._current_request_id = request_id + fut.add_done_callback(lambda _: self._clear_execution_state()) + + async def set_block_temperature( + self, + temperature: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Set block temperature via a premethod protocol.""" + from .model import ODTCHardwareConstraints + params = ( + backend_params + if isinstance(backend_params, ODTCThermocyclerBackend.RunProtocolParams) + else ODTCThermocyclerBackend.RunProtocolParams(variant=self._variant) + ) + lid_temp = 110.0 # default + premethod = ODTCProtocol( + stages=[], + variant=params.variant, + plate_type=params.plate_type, + fluid_quantity=params.fluid_quantity, + post_heating=False, + start_block_temperature=0.0, + start_lid_temperature=lid_temp, + pid_set=list(params.pid_set), + kind="premethod", + target_block_temperature=temperature, + target_lid_temperature=lid_temp, + ) + ms = ODTCMethodSet(premethods=[premethod]) + await self._upload_method_set(ms) + fut, request_id = await self._driver.send_command_async( + "ExecuteMethod", methodName=premethod.name + ) + self._current_request_id = request_id + self._current_odtc_protocol = premethod + fut.add_done_callback(lambda _: self._clear_execution_state()) + + async def deactivate_block(self, backend_params: Optional[BackendParams] = None) -> None: + await self._driver.send_command("StopMethod") + self._clear_execution_state() + + async def request_block_temperature(self) -> float: + sensor_values = await self._request_temperatures() + return sensor_values.mount + + async def request_lid_temperature(self) -> float: + sensor_values = await self._request_temperatures() + return sensor_values.lid + + async def request_progress(self) -> Optional[Any]: + if self._current_request_id is None or self._current_odtc_protocol is None: + return None + events = self._driver.get_data_events(self._current_request_id) + if not events: + return None + progress = build_progress_from_data_event( + events[-1], + odtc_protocol=self._current_odtc_protocol, + last_target_temp_c=self._last_target_temp_c, + ) + if progress.target_temp_c is not None: + self._last_target_temp_c = progress.target_temp_c + return progress + + async def stop_protocol(self, backend_params: Optional[BackendParams] = None) -> None: + await self._driver.send_command("StopMethod") + self._clear_execution_state() + + # ------------------------------------------------------------------ + # ODTC-specific public methods (available on the backend directly) + # ------------------------------------------------------------------ + + async def _request_temperatures(self) -> ODTCSensorValues: + resp = await self._driver.send_command("ReadActualTemperature") + if resp is None: + raise RuntimeError("ReadActualTemperature returned no data") + if isinstance(resp, dict): + param = (resp.get("ReadActualTemperatureResponse", {}) + .get("ResponseData", {}) + .get("Parameter", {})) + if isinstance(param, list): + param = next((p for p in param if p.get("name") == "SensorValues"), {}) + xml_str = param.get("String", "") + else: + import xml.etree.ElementTree as ET2 + string_elem = resp.find(".//String") + xml_str = string_elem.text if string_elem is not None else "" + if not xml_str: + raise RuntimeError("Could not extract SensorValues from ReadActualTemperature response") + return parse_sensor_values(xml_str) + + async def request_status(self) -> SiLAState: + return await self._driver.request_status() + + async def get_method_set(self) -> ODTCMethodSet: + resp = await self._driver.send_command("GetParameters") + if resp is None: + raise RuntimeError("GetParameters returned no data") + if isinstance(resp, dict): + param = (resp.get("GetParametersResponse", {}) + .get("ResponseData", {}) + .get("Parameter", {})) + if isinstance(param, list): + param = next((p for p in param if p.get("name") == "MethodsXML"), {}) + xml_str = param.get("String", "") + else: + string_elem = resp.find(".//String") + xml_str = string_elem.text if string_elem is not None else "" + if not xml_str: + raise RuntimeError("Could not extract MethodsXML from GetParameters response") + return parse_method_set(xml_str) + + async def get_protocol(self, name: str) -> Optional[ODTCProtocol]: + """Fetch a stored runnable method by name. + + Returns None if the name does not exist or refers to a premethod. + + Args: + name: Protocol name to retrieve. + """ + method_set = await self.get_method_set() + resolved = method_set.get(name) + if resolved is None or resolved.kind == "premethod": + return None + return resolved + + async def upload_method_set( + self, + method_set: ODTCMethodSet, + allow_overwrite: bool = False, + dynamic_pre_method_duration: bool = True, + ) -> None: + """Upload a MethodSet to the device. + + Args: + method_set: The method set to upload. + allow_overwrite: If False (default), raises ValueError when any method + or premethod name already exists on the device. If True, overwrites. + dynamic_pre_method_duration: When True, device reports live pre-heat + remaining time. When False, uses the fixed 600 s estimate. + + Raises: + ValueError: On name conflicts when allow_overwrite=False. + """ + if not allow_overwrite: + existing = await self.get_method_set() + conflicts = [ + m.name for m in method_set.methods + method_set.premethods + if existing.get(m.name) is not None + ] + if conflicts: + raise ValueError( + f"Name conflicts on device: {conflicts}. " + f"Pass allow_overwrite=True to overwrite." + ) + await self._upload_method_set(method_set, dynamic_pre_method_duration) + + async def upload_protocol( + self, + odtc_protocol: ODTCProtocol, + allow_overwrite: bool = False, + dynamic_pre_method_duration: bool = True, + ) -> None: + """Upload a single ODTCProtocol to the device for persistent storage. + + Scratch protocols (is_scratch=True) bypass the conflict check. + Named protocols persist across device Reset and can be run later by + name via run_stored_protocol(). + + Typical workflow:: + + odtc_p = ODTCProtocol.from_protocol( + protocol, name="StandardPCR", + fluid_quantity=FluidQuantity.UL_30_TO_74, + ) + await odtc.tc.backend.upload_protocol(odtc_p) + # later: + await odtc.tc.backend.run_stored_protocol("StandardPCR") + + Args: + odtc_protocol: Compiled protocol to upload. + allow_overwrite: If False, raises ValueError when a protocol with + the same name already exists. Scratch methods always overwrite. + dynamic_pre_method_duration: Passed to the SetParameters command. + + Raises: + ValueError: On name conflict when allow_overwrite=False. + """ + if odtc_protocol.is_scratch: + allow_overwrite = True + if odtc_protocol.kind == "method": + ms = ODTCMethodSet(methods=[odtc_protocol]) + else: + ms = ODTCMethodSet(premethods=[odtc_protocol]) + await self.upload_method_set( + ms, + allow_overwrite=allow_overwrite, + dynamic_pre_method_duration=dynamic_pre_method_duration, + ) + + async def run_stored_protocol(self, name: str) -> None: + """Execute a named protocol already stored on the device. Fire-and-forget. + + The protocol must have been uploaded previously via upload_protocol() or + run_protocol() with a persistent name. Does not re-upload. + + Args: + name: Name of the stored protocol to execute. + + Raises: + ValueError: If no runnable protocol with the given name exists on the device. + """ + resolved = await self.get_protocol(name) + if resolved is None: + raise ValueError( + f"Protocol {name!r} not found on device. " + f"Upload it first with upload_protocol()." + ) + self._clear_execution_state() + self._current_odtc_protocol = resolved + fut, request_id = await self._driver.send_command_async("ExecuteMethod", methodName=name) + self._current_request_id = request_id + fut.add_done_callback(lambda _: self._clear_execution_state()) diff --git a/pylabrobot/inheco/odtc/door.py b/pylabrobot/inheco/odtc/door.py new file mode 100644 index 00000000000..046a0ef4088 --- /dev/null +++ b/pylabrobot/inheco/odtc/door.py @@ -0,0 +1,72 @@ +"""ODTC door (motorized lid) backend. + +The ODTC door is controlled via OpenDoor / CloseDoor SiLA commands. +The device firmware (IOdtcCommands) exposes no door-state query — only +the two actuator commands. State is therefore tracked locally. + +State model: +- None = unknown (initial, or after (re)connect — physical state may have changed) +- True = open (set after a successful open() call this session) +- False = closed (set after a successful close() call this session) + +Query odtc.door.backend.is_open to read state; raises DoorStateUnknownError +if neither open() nor close() has been called since the last setup(). +""" + +from __future__ import annotations + +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.loading_tray.backend import LoadingTrayBackend + +from .driver import ODTCDriver + + +class DoorStateUnknownError(RuntimeError): + """Door state is unknown: neither open() nor close() has been called this session. + + State is reset to unknown whenever the device connection is (re)established, + because the physical door position may have changed while disconnected. + Call ``odtc.door.open()`` or ``odtc.door.close()`` to establish known state. + """ + + +class ODTCDoorBackend(LoadingTrayBackend): + """LoadingTrayBackend for the ODTC motorized door. + + Wraps the OpenDoor / CloseDoor SiLA commands and tracks door state locally + (the ODTC firmware provides no state-query command). + """ + + def __init__(self, driver: ODTCDriver) -> None: + self._driver = driver + self._is_open: Optional[bool] = None + + async def _on_setup(self, backend_params: Optional[BackendParams] = None) -> None: + """Reset door state to unknown on every (re)connect.""" + self._is_open = None + + @property + def is_open(self) -> bool: + """Return True if door is open, False if closed. + + Raises: + DoorStateUnknownError: If neither open() nor close() has been called + since the last setup(). Call one of those first to establish state. + """ + if self._is_open is None: + raise DoorStateUnknownError( + "Door state is unknown. Call odtc.door.open() or odtc.door.close() first." + ) + return self._is_open + + async def open(self, backend_params: Optional[BackendParams] = None) -> None: + """Open the door. Updates tracked state to open on success.""" + await self._driver.send_command("OpenDoor") + self._is_open = True + + async def close(self, backend_params: Optional[BackendParams] = None) -> None: + """Close the door. Updates tracked state to closed on success.""" + await self._driver.send_command("CloseDoor") + self._is_open = False diff --git a/pylabrobot/inheco/odtc/driver.py b/pylabrobot/inheco/odtc/driver.py new file mode 100644 index 00000000000..f7f63b9bcf9 --- /dev/null +++ b/pylabrobot/inheco/odtc/driver.py @@ -0,0 +1,261 @@ +"""ODTCDriver — the ODTC SiLA communication layer and v1b1 Driver. + +Extends InhecoSiLAInterface (HTTP server, SOAP encode/decode, async command +queueing) with ODTC-specific event handling, then satisfies the v1b1 Driver +interface by mapping setup()/stop() to InhecoSiLAInterface.start()/close(). + +Three-channel error model: +- ResponseEvent (non-success code) → SiLAError +- ErrorEvent → SiLAError [was RuntimeError — fixed] +- StatusEvent(errorHandling/inError)→ SiLAError from structured firmware extensions + Extensions: [0]=ErrorClassification, [1]=InternalErrorCode, [2]=HexCode, + [3]=ErrorName, [4]=ErrorDescription + +mcDuration from SOAP responses drives per-command timeouts instead of a +fixed 3-hour ceiling. +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional, Set + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.device import Driver +from pylabrobot.inheco.scila.inheco_sila_interface import ( + InhecoSiLAInterface, + SiLAError, + SiLAState, +) + +from .protocol import build_progress_from_data_event + +# Minimum timeout for async commands regardless of mcDuration (seconds) +_MIN_COMMAND_TIMEOUT: float = 300.0 +# Safety multiplier applied to device-reported mcDuration +_MC_DURATION_SAFETY_MULTIPLIER: float = 1.5 + +# Device-specific return codes that put the device into InError state +DEVICE_ERROR_CODES: Set[int] = {1000, 2000, 2001, 2007} + +# States that indicate a device error in progress +_ERROR_STATES: set = {SiLAState.ERRORHANDLING, SiLAState.INERROR} + + +class ODTCDriver(InhecoSiLAInterface, Driver): + """ODTC SiLA communication layer, satisfying the v1b1 Driver interface. + + Inherits the full HTTP/SOAP/async-command infrastructure from + InhecoSiLAInterface and adds ODTC-specific event handling. The v1b1 + Driver contract (setup/stop) maps to InhecoSiLAInterface.start/close. + """ + + def __init__( + self, + machine_ip: str, + client_ip: Optional[str] = None, + logger: Optional[logging.Logger] = None, + ) -> None: + InhecoSiLAInterface.__init__(self, machine_ip=machine_ip, client_ip=client_ip, logger=logger) + Driver.__init__(self) + + # ------------------------------------------------------------------ + # v1b1 Driver lifecycle + # ------------------------------------------------------------------ + + async def setup(self, backend_params: Optional[BackendParams] = None) -> None: + """Start the SiLA HTTP event-receiver server.""" + await self.start() + + async def stop(self) -> None: + """Shut down the SiLA HTTP event-receiver server.""" + await self.close() + + # ------------------------------------------------------------------ + # Status event — primary error notification channel + # ------------------------------------------------------------------ + + def _on_status_event(self, status_event: dict) -> None: + """Handle StatusEvent: log state and reject pending futures on error states. + + The ODTC firmware uses StatusEvent as its primary error notification path. + The eventDescription may carry structured error fields in Extensions: + [0] = ErrorClassification (e.g. "DeviceError") + [1] = InternalErrorCode (int, e.g. 2001 = motor error) + [2] = InternalErrorCodeHex + [3] = ErrorName (e.g. "MotorError") + [4] = ErrorDescription (human-readable detail) + """ + import xml.etree.ElementTree as ET + + event_description = status_event.get("eventDescription", {}) + device_state: Optional[str] = None + + if isinstance(event_description, dict): + device_state = event_description.get("DeviceState") + extensions = event_description.get("Extensions") or event_description.get("extensions") or [] + elif isinstance(event_description, str) and "" in event_description: + root = ET.fromstring(event_description) + device_state = root.text if root.tag == "DeviceState" else root.findtext("DeviceState") + extensions = [] + else: + self._logger.warning(f"StatusEvent with unparsable eventDescription: {event_description!r}") + return + + if device_state: + self._logger.debug(f"StatusEvent device state: {device_state}") + + # Parse structured error extensions (ODTC firmware error fields) + error_classification = "" + internal_error_code = 0 + internal_error_code_hex = "" + error_name = "" + error_description = "" + if isinstance(extensions, (list, tuple)): + try: + error_classification = str(extensions[0]) if len(extensions) > 0 else "" + internal_error_code = int(extensions[1]) if len(extensions) > 1 else 0 + internal_error_code_hex = str(extensions[2]) if len(extensions) > 2 else "" + error_name = str(extensions[3]) if len(extensions) > 3 else "" + error_description = str(extensions[4]) if len(extensions) > 4 else "" + except (IndexError, ValueError, TypeError): + pass + + if error_name or error_description or error_classification: + self._logger.error( + "StatusEvent error: classification=%r code=%d (%s) name=%r desc=%r", + error_classification, internal_error_code, internal_error_code_hex, + error_name, error_description, + ) + + # Reject all pending futures when device enters an error state + try: + state = SiLAState(device_state) if device_state else None + except ValueError: + state = None + + if state in _ERROR_STATES and self._pending_by_id: + msg = ( + error_description or error_name + or f"Device entered {device_state} state" + ) + if internal_error_code: + msg = f"{msg} [code {internal_error_code}]" + if state == SiLAState.INERROR: + msg += ". Device requires a power cycle to recover." + + for request_id in list(self._pending_by_id.keys()): + pending = self._pending_by_id.get(request_id) + if pending and not pending.fut.done(): + self._complete_pending( + request_id, + exception=SiLAError(internal_error_code or 9, msg, pending.name), + ) + + # ------------------------------------------------------------------ + # Error event — secondary error notification channel + # ------------------------------------------------------------------ + + def _on_error_event(self, error_event: dict) -> None: + """Handle ErrorEvent with typed SiLAError (was RuntimeError in base — fixed).""" + req_id = error_event.get("requestId") + return_value = error_event.get("returnValue", {}) + return_code = return_value.get("returnCode") or 0 + message = return_value.get("message", "") + + self._logger.error( + "ErrorEvent for requestId %s: code %s, message: %s", + req_id, return_code, message, + ) + + err_msg = message.replace("\n", " ") if message else f"Error (code {return_code})" + if req_id is not None: + pending = self._pending_by_id.get(req_id) + if pending and not pending.fut.done(): + self._complete_pending( + req_id, + exception=SiLAError(return_code, err_msg, pending.name), + ) + + # ------------------------------------------------------------------ + # Response event — code 1 fix + # ------------------------------------------------------------------ + + def _on_response_event(self, response_event: dict) -> None: + """Handle ResponseEvent: code 1 = success (no data), code 3 = success (with data).""" + import xml.etree.ElementTree as ET + + request_id = response_event.get("requestId") + if request_id is None: + self._logger.warning("ResponseEvent missing requestId") + return + + pending = self._pending_by_id.get(request_id) + if pending is None: + self._logger.warning(f"ResponseEvent for unknown requestId: {request_id}") + return + if pending.fut.done(): + self._logger.warning(f"ResponseEvent for already-completed requestId: {request_id}") + return + + return_value = response_event.get("returnValue", {}) + return_code = return_value.get("returnCode") + + if return_code == 1: + self._complete_pending(request_id, result=None) + elif return_code == 3: + response_data = response_event.get("responseData", "") + if response_data and response_data.strip(): + try: + self._complete_pending(request_id, result=ET.fromstring(response_data)) + except ET.ParseError as e: + self._logger.error(f"Failed to parse ResponseEvent responseData: {e}") + self._complete_pending( + request_id, exception=RuntimeError(f"Failed to parse response data: {e}") + ) + else: + self._complete_pending(request_id, result=None) + else: + message = return_value.get("message", "") + err_msg = message.replace("\n", " ") if message else f"Unknown error (code {return_code})" + self._complete_pending( + request_id, + exception=SiLAError(return_code, err_msg, pending.name), + ) + + # ------------------------------------------------------------------ + # mcDuration-driven timeout + # ------------------------------------------------------------------ + + def _timeout_from_mc_duration(self, mc_duration_s: Optional[float]) -> float: + """Compute per-command timeout from device-reported mcDuration.""" + if mc_duration_s and mc_duration_s > 0: + return max(mc_duration_s * _MC_DURATION_SAFETY_MULTIPLIER, _MIN_COMMAND_TIMEOUT) + return _MIN_COMMAND_TIMEOUT + + # ------------------------------------------------------------------ + # Device-specific return codes (1000+) + # ------------------------------------------------------------------ + + def _handle_device_error_code(self, return_code: int, message: str, command_name: str) -> None: + """All ODTC device-specific codes (1000+) raise SiLAError.""" + raise SiLAError(return_code, f"Device error: {message}", command_name) + + # ------------------------------------------------------------------ + # DataEvent + # ------------------------------------------------------------------ + + def _on_data_event(self, data_event: dict) -> None: + super()._on_data_event(data_event) + try: + progress = build_progress_from_data_event(data_event, None) + self._logger.debug( + "DataEvent requestId %s: elapsed %.0fs, block %.1f°C, target %.1f°C, lid %.1f°C", + data_event.get("requestId"), + progress.elapsed_s, + progress.current_temp_c or 0.0, + progress.target_temp_c or 0.0, + progress.lid_temp_c or 0.0, + ) + except Exception: + pass # DataEvent parsing failures must not interrupt event handling diff --git a/pylabrobot/inheco/odtc/model.py b/pylabrobot/inheco/odtc/model.py new file mode 100644 index 00000000000..f9b1fd81d7d --- /dev/null +++ b/pylabrobot/inheco/odtc/model.py @@ -0,0 +1,449 @@ +"""ODTC domain types, hardware constants, and XML field metadata. + +This module defines: +- Hardware constraints and variant normalization +- XML field annotation helpers (used by odtc_xml.py) +- ODTCPID, ODTCMethodSet, ODTCSensorValues, ODTCProgress +- ODTCProtocol: device-native protocol (Protocol subclass) for upload/roundtrip + +XML serialization lives in odtc_xml.py. +Protocol conversion lives in odtc_protocol.py. +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Literal, Optional, Tuple + +from pylabrobot.capabilities.thermocycling.standard import Protocol, Stage, Step + +ODTCVariant = Literal[96, 384] + + +# ============================================================================= +# Timestamp +# ============================================================================= + + +def generate_odtc_timestamp() -> str: + """Generate ISO 8601 timestamp in ODTC format (microsecond precision).""" + return datetime.now().isoformat(timespec="microseconds") + + +# ============================================================================= +# Volume / fluid quantity +# ============================================================================= + + +class FluidQuantity(enum.IntEnum): + """ODTC fluid volume range for thermal compensation. + + Select based on the maximum sample volume in the wells. + VERIFICATION_TOOL disables volume-based overshoot calculation. + """ + + VERIFICATION_TOOL = -1 # calibration / dry run + UL_10_TO_29 = 0 # 10–29 µL + UL_30_TO_74 = 1 # 30–74 µL + UL_75_TO_100 = 2 # 75–100 µL + + +def volume_to_fluid_quantity(volume_ul: float) -> FluidQuantity: + """Map volume in µL to ODTC FluidQuantity. + + Args: + volume_ul: Volume in microliters (maximum 100 µL). + + Returns: + FluidQuantity matching the volume range. + """ + if volume_ul > 100: + raise ValueError( + f"Volume {volume_ul} µL exceeds ODTC maximum of 100 µL." + ) + if volume_ul <= 29: + return FluidQuantity.UL_10_TO_29 + if volume_ul <= 74: + return FluidQuantity.UL_30_TO_74 + return FluidQuantity.UL_75_TO_100 + + +# ============================================================================= +# Hardware Constraints +# ============================================================================= + + +@dataclass(frozen=True) +class ODTCDimensions: + """ODTC footprint dimensions (mm).""" + + x: float + y: float + z: float + + +ODTC_DIMENSIONS = ODTCDimensions(x=156.5, y=248.0, z=124.3) + +PREMETHOD_ESTIMATED_DURATION_SECONDS: float = 600.0 + + +@dataclass(frozen=True) +class ODTCHardwareConstraints: + """Hardware limits for ODTC variants.""" + + min_lid_temp: float = 30.0 + max_lid_temp: float = 115.0 + min_slope: float = 0.1 + max_heating_slope: float = 4.4 + max_cooling_slope: float = 2.2 + valid_fluid_quantities: Tuple[int, ...] = tuple(int(v) for v in FluidQuantity) + valid_plate_types: Tuple[int, ...] = (0,) + + +def get_constraints(variant: ODTCVariant) -> ODTCHardwareConstraints: + if variant == 96: + return ODTCHardwareConstraints(max_heating_slope=4.4, max_lid_temp=110.0) + if variant == 384: + return ODTCHardwareConstraints( + max_heating_slope=5.0, max_lid_temp=115.0, valid_plate_types=(0, 2) + ) + raise ValueError(f"Unknown variant {variant}. Valid: [96, 384]") + + +def normalize_variant(variant: int) -> ODTCVariant: + """Normalize variant to 96 or 384 (accepts device codes too).""" + if variant in (96, 960000): + return 96 + if variant in (384, 384000, 3840000): + return 384 + raise ValueError(f"Unknown variant {variant}. Expected 96, 384, 960000, 384000, or 3840000.") + + +def _variant_to_device_code(variant: ODTCVariant) -> int: + """Convert variant (96/384) to ODTC device code for XML serialization.""" + return {96: 960000, 384: 384000}[variant] + + +# ============================================================================= +# XML Field Metadata +# ============================================================================= + + +class XMLFieldType(Enum): + ELEMENT = "element" + ATTRIBUTE = "attribute" + CHILD_LIST = "child_list" + + +@dataclass(frozen=True) +class XMLField: + """Metadata for XML field mapping.""" + + tag: Optional[str] = None + field_type: XMLFieldType = XMLFieldType.ELEMENT + default: Any = None + scale: float = 1.0 + + +def xml_field( + tag: Optional[str] = None, + field_type: XMLFieldType = XMLFieldType.ELEMENT, + default: Any = None, + scale: float = 1.0, +) -> Any: + metadata = {"xml": XMLField(tag=tag, field_type=field_type, default=default, scale=scale)} + if default is None: + return field(default=None, metadata=metadata) + return field(default=default, metadata=metadata) + + +def xml_attr(tag: Optional[str] = None, default: Any = None) -> Any: + return xml_field(tag=tag, field_type=XMLFieldType.ATTRIBUTE, default=default) + + +def xml_child_list(tag: Optional[str] = None) -> Any: + metadata = {"xml": XMLField(tag=tag, field_type=XMLFieldType.CHILD_LIST, default=None)} + return field(default_factory=list, metadata=metadata) + + +# ============================================================================= +# ODTC Data Classes +# ============================================================================= + + +@dataclass +class ODTCPID: + """PID controller parameters.""" + + number: int = xml_attr(tag="number", default=1) + p_heating: float = xml_field(tag="PHeating", default=60.0) + p_cooling: float = xml_field(tag="PCooling", default=80.0) + i_heating: float = xml_field(tag="IHeating", default=250.0) + i_cooling: float = xml_field(tag="ICooling", default=100.0) + d_heating: float = xml_field(tag="DHeating", default=10.0) + d_cooling: float = xml_field(tag="DCooling", default=10.0) + p_lid: float = xml_field(tag="PLid", default=100.0) + i_lid: float = xml_field(tag="ILid", default=70.0) + + +@dataclass +class ODTCMethodSet: + """Container for all methods and premethods uploaded as a set.""" + + delete_all_methods: bool = False + premethods: List[ODTCProtocol] = field(default_factory=list) + methods: List[ODTCProtocol] = field(default_factory=list) + + def get(self, name: str) -> Optional[ODTCProtocol]: + return next((p for p in self.methods + self.premethods if p.name == name), None) + + def __str__(self) -> str: + lines: List[str] = ["Methods (runnable protocols):"] + if self.methods: + for m in self.methods: + lines.append(f" - {m.name} ({len(m.stages)} stage(s))") + else: + lines.append(" (none)") + lines.append("PreMethods (setup-only):") + if self.premethods: + for p in self.premethods: + lines.append( + f" - {p.name} (block={p.target_block_temperature:.1f}°C," + f" lid={p.target_lid_temperature:.1f}°C)" + ) + else: + lines.append(" (none)") + return "\n".join(lines) + + +@dataclass +class ODTCSensorValues: + """Temperature sensor readings from ODTC (values in °C).""" + + timestamp: Optional[str] = xml_attr(tag="timestamp", default=None) + mount: float = xml_field(tag="Mount", scale=0.01, default=0.0) + mount_monitor: float = xml_field(tag="Mount_Monitor", scale=0.01, default=0.0) + lid: float = xml_field(tag="Lid", scale=0.01, default=0.0) + lid_monitor: float = xml_field(tag="Lid_Monitor", scale=0.01, default=0.0) + ambient: float = xml_field(tag="Ambient", scale=0.01, default=0.0) + pcb: float = xml_field(tag="PCB", scale=0.01, default=0.0) + heatsink: float = xml_field(tag="Heatsink", scale=0.01, default=0.0) + heatsink_tec: float = xml_field(tag="Heatsink_TEC", scale=0.01, default=0.0) + + def __str__(self) -> str: + lines = [ + "ODTCSensorValues:", + f" Mount={self.mount:.1f}°C Mount_Monitor={self.mount_monitor:.1f}°C", + f" Lid={self.lid:.1f}°C Lid_Monitor={self.lid_monitor:.1f}°C", + f" Ambient={self.ambient:.1f}°C PCB={self.pcb:.1f}°C", + f" Heatsink={self.heatsink:.1f}°C Heatsink_TEC={self.heatsink_tec:.1f}°C", + ] + if self.timestamp: + lines.insert(1, f" timestamp={self.timestamp}") + return "\n".join(lines) + + def format_compact(self) -> str: + parts = [ + f"Mount={self.mount:.1f}°C", + f"Lid={self.lid:.1f}°C", + f"Ambient={self.ambient:.1f}°C", + f"Mount_Monitor={self.mount_monitor:.1f}°C", + f"Lid_Monitor={self.lid_monitor:.1f}°C", + f"PCB={self.pcb:.1f}°C", + f"Heatsink={self.heatsink:.1f}°C", + f"Heatsink_TEC={self.heatsink_tec:.1f}°C", + ] + line = " ".join(parts) + if self.timestamp: + return f"ODTCSensorValues({self.timestamp}) {line}" + return f"ODTCSensorValues {line}" + + +# ============================================================================= +# ODTCProtocol — Protocol subclass (device-native compiled form) +# ============================================================================= + + +@dataclass +class ODTCProtocol(Protocol): + """Device-native ODTC runnable unit (method or premethod). + + Extends Protocol with all fields required for ODTC XML upload and + roundtrip. This is both the compiled output of from_protocol() and + the parsed representation of device XML. + + Protocol fields inherited: stages, name, lid_temperature. + + Validation runs in __post_init__ (previously in ODTCConfig). + """ + + # Required device configuration fields (no defaults — must be explicit) + variant: ODTCVariant = field(default=96) + plate_type: int = field(default=0) + fluid_quantity: FluidQuantity = field(default=FluidQuantity.UL_30_TO_74) + post_heating: bool = field(default=True) + start_block_temperature: float = field(default=25.0) + start_lid_temperature: float = field(default=110.0) + pid_set: List[ODTCPID] = field(default_factory=lambda: [ODTCPID(number=1)]) + + # Identity / metadata + kind: Literal["method", "premethod"] = field(default="method") + is_scratch: bool = field(default=True) + creator: Optional[str] = field(default=None) + description: Optional[str] = field(default=None) + datetime: str = field(default_factory=generate_odtc_timestamp) + + # Premethod targets (only meaningful when kind="premethod") + target_block_temperature: float = field(default=0.0) + target_lid_temperature: float = field(default=0.0) + + def __post_init__(self) -> None: + errors: List[str] = [] + c = get_constraints(self.variant) + + if c.valid_fluid_quantities and self.fluid_quantity not in c.valid_fluid_quantities: + errors.append( + f"fluid_quantity={self.fluid_quantity} invalid for variant {self.variant}. " + f"Valid: {c.valid_fluid_quantities}" + ) + if self.plate_type not in c.valid_plate_types: + errors.append( + f"plate_type={self.plate_type} invalid for variant {self.variant}. " + f"Valid: {c.valid_plate_types}" + ) + lid_temp = self.lid_temperature + if lid_temp is not None and not (c.min_lid_temp <= lid_temp <= c.max_lid_temp): + errors.append( + f"lid_temperature={lid_temp}°C outside range " + f"[{c.min_lid_temp}, {c.max_lid_temp}] for variant {self.variant}" + ) + if errors: + raise ValueError("ODTCProtocol validation failed:\n - " + "\n - ".join(errors)) + + def __str__(self) -> str: + lines = [f"ODTCProtocol(name={self.name!r}, kind={self.kind!r})"] + if self.kind == "premethod": + lines.append(f" target_block_temperature={self.target_block_temperature:.1f}°C") + lines.append(f" target_lid_temperature={self.target_lid_temperature:.1f}°C") + else: + step_count = sum(len(s.steps) for s in self.stages) + lines.append(f" {len(self.stages)} stage(s), {step_count} step(s)") + lines.append(f" start_block_temperature={self.start_block_temperature:.1f}°C") + lines.append(f" start_lid_temperature={self.start_lid_temperature:.1f}°C") + lines.append(f" variant={self.variant}") + return "\n".join(lines) + + @classmethod + def from_protocol( + cls, + protocol: "Protocol", + variant: ODTCVariant = 96, + fluid_quantity: Optional["FluidQuantity"] = None, + plate_type: int = 0, + post_heating: bool = True, + pid_set: Optional[List["ODTCPID"]] = None, + name: Optional[str] = None, + lid_temperature: Optional[float] = None, + start_lid_temperature: Optional[float] = None, + default_heating_slope: Optional[float] = None, + default_cooling_slope: Optional[float] = None, + apply_overshoot: bool = True, + creator: Optional[str] = None, + description: Optional[str] = None, + datetime: Optional[str] = None, + ) -> "ODTCProtocol": + """Compile a Protocol into a device-ready ODTCProtocol. + + When ``name`` is provided, ``is_scratch=False`` and the method persists + on the device across sessions. Without a name it is uploaded as a + temporary scratch method (deleted on next Reset). + + Args: + protocol: Source protocol with stages/steps. + variant: ODTC variant (96 or 384). + fluid_quantity: Fluid volume range for thermal compensation. + Defaults to FluidQuantity.UL_30_TO_74. + plate_type: Plate type code. + post_heating: Whether to keep the block warm after the method. + pid_set: PID configurations. Defaults to [ODTCPID(number=1)]. + name: Method name stored on the device. None = scratch/unnamed. + lid_temperature: Default lid temperature (°C). None = hardware max. + start_lid_temperature: Lid temperature during preheat (if different). + default_heating_slope: Default heating rate (°C/s). None = hardware max. + default_cooling_slope: Default cooling rate (°C/s). None = hardware max. + apply_overshoot: If True (default), automatically compute overshoot for + steps that do not specify an explicit Ramp.overshoot. If False, no + overshoot is applied regardless of ramp rate or fluid quantity. + Explicit Ramp.overshoot values are always honoured either way. + creator: Author string for metadata. + description: Description for metadata. + datetime: ISO timestamp; generated if None. + + Returns: + ODTCProtocol ready for upload or direct execution. + """ + from .protocol import _from_protocol # lazy import — avoids circular dependency + return _from_protocol( + protocol, + variant=variant, + fluid_quantity=fluid_quantity, + plate_type=plate_type, + post_heating=post_heating, + pid_set=pid_set, + name=name, + lid_temperature=lid_temperature, + start_lid_temperature=start_lid_temperature, + default_heating_slope=default_heating_slope, + default_cooling_slope=default_cooling_slope, + apply_overshoot=apply_overshoot, + creator=creator, + description=description, + datetime=datetime, + ) + + +# ============================================================================= +# ODTCProgress +# ============================================================================= + + +@dataclass +class ODTCProgress: + """Progress for a run, built from DataEvent payload and optional protocol.""" + + elapsed_s: float + target_temp_c: Optional[float] = None + current_temp_c: Optional[float] = None + lid_temp_c: Optional[float] = None + current_step_index: int = 0 + total_step_count: int = 0 + current_cycle_index: int = 0 + total_cycle_count: int = 0 + remaining_hold_s: float = 0.0 + estimated_duration_s: Optional[float] = None + remaining_duration_s: Optional[float] = None + + def format_progress_log_message(self) -> str: + step_total = self.total_step_count + cycle_total = self.total_cycle_count + step_idx = self.current_step_index + cycle_idx = self.current_cycle_index + setpoint = self.target_temp_c if self.target_temp_c is not None else 0.0 + block = self.current_temp_c or 0.0 + lid = self.lid_temp_c or 0.0 + if step_total and cycle_total: + return ( + f"ODTC progress: elapsed {self.elapsed_s:.0f}s, step {step_idx + 1}/{step_total}, " + f"cycle {cycle_idx + 1}/{cycle_total}, setpoint {setpoint:.1f}°C, " + f"block {block:.1f}°C, lid {lid:.1f}°C" + ) + return ( + f"ODTC progress: elapsed {self.elapsed_s:.0f}s, block {block:.1f}°C " + f"(target {setpoint:.1f}°C), lid {lid:.1f}°C" + ) + + def __str__(self) -> str: + return self.format_progress_log_message() diff --git a/pylabrobot/inheco/odtc/odtc.py b/pylabrobot/inheco/odtc/odtc.py new file mode 100644 index 00000000000..f7b14539c2f --- /dev/null +++ b/pylabrobot/inheco/odtc/odtc.py @@ -0,0 +1,190 @@ +"""ODTC — v1b1 Device class for the Inheco ODTC thermocycler.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.loading_tray import LoadingTray +from pylabrobot.capabilities.thermocycling.thermocycler import Thermocycler +from pylabrobot.device import Device +from pylabrobot.inheco.scila.inheco_sila_interface import SiLAState +from pylabrobot.resources import Coordinate, Resource + +from .backend import ODTCThermocyclerBackend +from .door import ODTCDoorBackend +from .driver import ODTCDriver +from .model import ODTC_DIMENSIONS, ODTCDimensions, ODTCVariant, normalize_variant + + +class ODTC(Resource, Device): + """Inheco ODTC thermocycler device. + + Extends both ``Resource`` (physical footprint in the deck resource tree) + and ``Device`` (driver lifecycle). + + Capabilities: + - ``odtc.tc`` — ``Thermocycler`` capability for protocol execution and temperature control + - ``odtc.door`` — ``LoadingTray`` capability for the motorized door (plate access) + + Usage:: + + odtc = ODTC(odtc_ip="169.254.x.x", name="odtc") + await odtc.setup() + await odtc.door.open() + # load plate onto odtc.door ... + await odtc.door.close() + await odtc.tc.run_protocol( + protocol, + backend_params=ODTCThermocyclerBackend.RunProtocolParams(variant=96, fluid_quantity=1), + ) + await odtc.stop() + + Physical dimensions (mm): x=156.5, y=248.0, z=124.3. + SBS plate footprint on block: 127.76 × 85.48 mm. + """ + + DIMENSIONS: ODTCDimensions = ODTC_DIMENSIONS + + def __init__( + self, + odtc_ip: str, + variant: int = 96, + name: str = "odtc", + client_ip: Optional[str] = None, + logger: Optional[logging.Logger] = None, + ) -> None: + driver = ODTCDriver(machine_ip=odtc_ip, client_ip=client_ip, logger=logger) + variant_normalized: ODTCVariant = normalize_variant(variant) + + Resource.__init__( + self, + name=name, + size_x=ODTC_DIMENSIONS.x, + size_y=ODTC_DIMENSIONS.y, + size_z=ODTC_DIMENSIONS.z, + ) + Device.__init__(self, driver=driver) + + self.driver: ODTCDriver = driver # typed public reference (Device base stores self.driver) + self.logger = logger or logging.getLogger(__name__) + + self.tc = Thermocycler(backend=ODTCThermocyclerBackend(driver=driver, variant=variant_normalized)) + + self.door = LoadingTray( + backend=ODTCDoorBackend(driver=driver), + name=f"{name}_door", + size_x=127.76, # SBS 96-well plate footprint + size_y=85.48, + size_z=0.0, + child_location=Coordinate.zero(), + ) + self.assign_child_resource(self.door, location=Coordinate.zero()) + + self._capabilities = [self.tc, self.door] + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def odtc_ip(self) -> str: + """IP address of the ODTC device.""" + return self.driver._machine_ip # type: ignore[attr-defined] + + @property + def variant(self) -> ODTCVariant: + """ODTC variant (96 or 384).""" + return self.tc.backend._variant # type: ignore[attr-defined] + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def setup( + self, + full: bool = True, + simulation_mode: bool = False, + max_attempts: int = 10, + retry_backoff_base_seconds: float = 1.0, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Set up the ODTC connection. + + Args: + full: If True (default), runs the full SiLA lifecycle (event receiver, + Reset, Initialize, verify idle) with retry and exponential backoff. + If False, starts only the event receiver (reconnect after session loss + without aborting a running method). + simulation_mode: When full=True, passes True to the device so methods + execute in simulation mode (instant completion with estimated duration). + max_attempts: Number of full-path attempts before giving up. + retry_backoff_base_seconds: Base delay in seconds for exponential backoff. + """ + if not full: + await self.driver.setup(backend_params=backend_params) + await self.tc._on_setup(backend_params=backend_params) + await self.door._on_setup(backend_params=backend_params) + return + + last_error: Optional[Exception] = None + for attempt in range(max_attempts): + try: + await self._setup_full_path(simulation_mode) + await self.tc._on_setup(backend_params=backend_params) + await self.door._on_setup(backend_params=backend_params) + return + except Exception as e: # noqa: BLE001 + last_error = e + if attempt < max_attempts - 1: + wait_time = retry_backoff_base_seconds * (2 ** attempt) + self.logger.warning( + "Setup attempt %s/%s failed: %s. Retrying in %.1fs.", + attempt + 1, max_attempts, e, wait_time, + ) + await asyncio.sleep(wait_time) + else: + raise last_error from e + if last_error is not None: + raise last_error from last_error + + async def _setup_full_path(self, simulation_mode: bool) -> None: + await self.driver.setup() + await self.driver.send_command( + "Reset", + deviceId="ODTC", + eventReceiverURI=self.driver.event_receiver_uri, + simulationMode=simulation_mode, + ) + self.driver._lock_id = None # type: ignore[attr-defined] + + status = await self.driver.request_status() + self.logger.info("GetStatus returned state: %r", status.value) + + if status == SiLAState.STANDBY: + self.logger.info("Device is in standby, calling Initialize...") + await self.driver.send_command("Initialize") + status_after_init = await self.driver.request_status() + if status_after_init != SiLAState.IDLE: + raise RuntimeError( + f"Device not in idle after Initialize. Got {status_after_init.value!r}." + ) + self.logger.info("Device initialized and idle") + elif status == SiLAState.IDLE: + self.logger.info("Device already idle after Reset") + else: + raise RuntimeError( + f"Unexpected device state after Reset: {status.value!r}. " + f"Expected standby or idle." + ) + + async def stop(self) -> None: + """Deactivate block, close SiLA connection.""" + await self.tc._on_stop() + await self.door._on_stop() + await self.driver.stop() diff --git a/pylabrobot/inheco/odtc/protocol.py b/pylabrobot/inheco/odtc/protocol.py new file mode 100644 index 00000000000..8a4d9df441a --- /dev/null +++ b/pylabrobot/inheco/odtc/protocol.py @@ -0,0 +1,637 @@ +"""ODTC protocol conversion, duration estimation, and DataEvent progress parsing. + +Provides: +- ODTCProtocol.from_protocol() classmethod (replaces protocol_to_odtc_protocol + ODTCConfig) +- Duration estimation and timeline building +- DataEvent payload parsing and ODTCProgress construction +""" + +from __future__ import annotations + +import html +import logging +import math +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple + +from pylabrobot.capabilities.thermocycling.standard import ( + Overshoot, + Protocol, + Ramp, + Stage, + Step, +) + +from .model import ( + ODTCPID, + PREMETHOD_ESTIMATED_DURATION_SECONDS, + ODTCProgress, + ODTCProtocol, + ODTCVariant, + generate_odtc_timestamp, + get_constraints, +) +from .xml import _flatten_stages_for_xml + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Overshoot Calculation constants +# ============================================================================= + +_OVERSHOOT_HEAT_COEFFS: Dict[int, Tuple[float, float]] = { + 0: (5.1144, -6.6037), + 1: (19.469, -20.875), + 2: (22.829, -13.278), +} +_OVERSHOOT_COOL_COEFFS: Dict[int, Tuple[float, float]] = { + 0: (4.0941, -6.1247), + 1: (4.9773, -8.0866), + 2: (9.0513, -17.015), +} + + +def _calc_overshoot( + plateau_temp: float, + pre_temp: float, + slope: float, + hold_time: float, + fluid_quantity: int, +) -> Optional[Overshoot]: + """Calculate overshoot parameters for a temperature transition. + + Returns an Overshoot if overshoot is warranted, or None for no overshoot. + """ + os2_default = 2.2 if slope >= 1.0 else 0.1 + + if fluid_quantity not in (0, 1, 2) or hold_time == 0.0: + return None + + heating = plateau_temp > pre_temp + cooling = plateau_temp < pre_temp + if not heating and not cooling: + return None + + delta = (plateau_temp - pre_temp) if heating else (pre_temp - plateau_temp) + if heating and (delta <= 5.0 or plateau_temp <= 35.0): + return None + if cooling and (delta <= 10.0 or plateau_temp <= 35.0): + return None + if slope <= 0.5: + return None + + delta = min(delta, 60.0) + coeffs = _OVERSHOOT_HEAT_COEFFS if heating else _OVERSHOOT_COOL_COEFFS + a, b = coeffs[fluid_quantity] + energy = a * math.log(delta) + b + if energy <= 0.0: + return None + + os_temp = math.sqrt(2.0 * energy / (1.0 / slope + 1.0 / 2.2)) + + if heating and plateau_temp + os_temp > 102.0: + cap = 102.0 - plateau_temp + tri_time = cap / slope + cap / 2.2 + remaining = energy - 0.5 * tri_time * cap + return Overshoot( + target_temp=round(cap, 1), + hold_seconds=round(remaining / cap, 1), + return_rate=2.2, + ) + + return Overshoot(target_temp=round(os_temp, 1), hold_seconds=0.0, return_rate=2.2) + + +def _calculate_slope( + from_temp: float, + to_temp: float, + rate: float, + variant: ODTCVariant, + default_heating_slope: Optional[float], + default_cooling_slope: Optional[float], +) -> float: + """Validate and clamp ramp rate against hardware limits.""" + constraints = get_constraints(variant) + is_heating = to_temp > from_temp + max_slope = constraints.max_heating_slope if is_heating else constraints.max_cooling_slope + direction = "heating" if is_heating else "cooling" + + if not math.isinf(rate): + if rate > max_slope: + logger.warning( + "Requested %s rate %.2f °C/s exceeds hardware maximum %.2f °C/s. " + "Clamping to maximum. Transition: %.1f°C → %.1f°C", + direction, rate, max_slope, from_temp, to_temp, + ) + return max_slope + return rate + + # inf = full device speed → use defaults + if is_heating: + slope = default_heating_slope if default_heating_slope is not None else constraints.max_heating_slope + else: + slope = default_cooling_slope if default_cooling_slope is not None else constraints.max_cooling_slope + return min(slope, max_slope) + + +def _transform_step( + step: Step, + prev_temp: float, + variant: ODTCVariant, + fluid_quantity: int, + default_heating_slope: Optional[float], + default_cooling_slope: Optional[float], + default_lid_temp: float, + apply_overshoot: bool = True, +) -> Step: + """Transform a user Step into an ODTC-compiled Step with computed overshoot if needed.""" + slope = _calculate_slope( + prev_temp, step.temperature, step.ramp.rate, + variant, default_heating_slope, default_cooling_slope, + ) + # Honour explicit overshoot; compute automatically only when requested + overshoot = step.ramp.overshoot + if overshoot is None and apply_overshoot: + overshoot = _calc_overshoot(step.temperature, prev_temp, slope, step.hold_seconds, fluid_quantity) + + ramp = Ramp(rate=slope, overshoot=overshoot) + lid_temp = step.lid_temperature if step.lid_temperature is not None else default_lid_temp + return Step( + temperature=step.temperature, + hold_seconds=step.hold_seconds, + ramp=ramp, + lid_temperature=lid_temp, + backend_params=step.backend_params, + ) + + +def _transform_stages( + stages: List[Stage], + prev_temp_box: List[float], # mutable single-element list for shared state + variant: ODTCVariant, + fluid_quantity: int, + default_heating_slope: Optional[float], + default_cooling_slope: Optional[float], + default_lid_temp: float, + apply_overshoot: bool = True, +) -> List[Stage]: + """Recursively transform all steps in a stage tree, computing slopes and overshoot.""" + result = [] + for stage in stages: + new_inner = _transform_stages( + stage.inner_stages, prev_temp_box, variant, fluid_quantity, + default_heating_slope, default_cooling_slope, default_lid_temp, + apply_overshoot, + ) + new_steps = [] + for step in stage.steps: + new_step = _transform_step( + step, prev_temp_box[0], variant, fluid_quantity, + default_heating_slope, default_cooling_slope, default_lid_temp, + apply_overshoot, + ) + prev_temp_box[0] = step.temperature + new_steps.append(new_step) + result.append(Stage(steps=new_steps, repeats=stage.repeats, inner_stages=new_inner)) + return result + + +def _from_protocol( + protocol: Protocol, + variant: ODTCVariant = 96, + fluid_quantity: Optional["FluidQuantity"] = None, + plate_type: int = 0, + post_heating: bool = True, + pid_set: Optional[List[ODTCPID]] = None, + name: Optional[str] = None, + lid_temperature: Optional[float] = None, + start_lid_temperature: Optional[float] = None, + default_heating_slope: Optional[float] = None, + default_cooling_slope: Optional[float] = None, + apply_overshoot: bool = True, + creator: Optional[str] = None, + description: Optional[str] = None, + datetime: Optional[str] = None, +) -> ODTCProtocol: + """Private implementation of ODTCProtocol.from_protocol(). + + Use ODTCProtocol.from_protocol() as the public API. + """ + from .model import FluidQuantity as _FQ + if fluid_quantity is None: + fluid_quantity = _FQ.UL_30_TO_74 + + if pid_set is None: + pid_set = [ODTCPID(number=1)] + + constraints = get_constraints(variant) + effective_lid = lid_temperature if lid_temperature is not None else constraints.max_lid_temp + + prev_temp_box: List[float] = [25.0] # start from ambient + transformed_stages = _transform_stages( + protocol.stages, prev_temp_box, variant, fluid_quantity, + default_heating_slope, default_cooling_slope, effective_lid, + apply_overshoot, + ) + + # start_block_temperature = first step's target + if protocol.stages and protocol.stages[0].steps: + start_block_temp = protocol.stages[0].steps[0].temperature + else: + start_block_temp = 25.0 + + effective_start_lid = start_lid_temperature if start_lid_temperature is not None else effective_lid + resolved_datetime = datetime or generate_odtc_timestamp() + resolved_name = name or protocol.name or "plr_currentProtocol" + is_scratch = name is None + + return ODTCProtocol( + stages=transformed_stages, + name=resolved_name, + lid_temperature=effective_lid, + is_scratch=is_scratch, + variant=variant, + plate_type=plate_type, + fluid_quantity=fluid_quantity, + post_heating=post_heating, + start_block_temperature=start_block_temp, + start_lid_temperature=effective_start_lid, + pid_set=list(pid_set), + kind="method", + creator=creator, + description=description, + datetime=resolved_datetime, + ) + + + + +# ============================================================================= +# Flat step view for duration / timeline / progress computation +# ============================================================================= + + +@dataclass(frozen=True) +class _FlatStep: + """A step with its serialization metadata (number, goto, loop).""" + + step: Step + number: int + goto_number: int + loop_number: int # LoopNumber = additional iterations (total - 1) + + +def _get_flat_steps(odtc_protocol: ODTCProtocol) -> List[_FlatStep]: + """Flatten ODTCProtocol.stages to _FlatStep list with goto/loop derived from stage structure.""" + raw = _flatten_stages_for_xml(odtc_protocol.stages) + return [_FlatStep(step=s, number=n, goto_number=g, loop_number=l) for s, n, g, l in raw] + + +def _analyze_flat_loops(flat_steps: List[_FlatStep]) -> List[Tuple[int, int, int]]: + """Return (start_num, end_num, total_repeats) for each loop.""" + return sorted( + [ + (fs.goto_number, fs.number, fs.loop_number + 1) + for fs in flat_steps if fs.goto_number > 0 + ], + key=lambda x: x[1], + ) + + +def _expand_flat_sequence( + flat_steps: List[_FlatStep], + loops: List[Tuple[int, int, int]], +) -> List[int]: + """Return step numbers in execution order (loops expanded).""" + if not flat_steps: + return [] + by_num = {fs.number: fs for fs in flat_steps} + max_step = max(fs.number for fs in flat_steps) + loop_by_end = {end: (start, count) for start, end, count in loops} + expanded: List[int] = [] + i = 1 + while i <= max_step: + if i not in by_num: + i += 1 + continue + expanded.append(i) + if i in loop_by_end: + start, count = loop_by_end[i] + for _ in range(count - 1): + for j in range(start, i + 1): + if j in by_num: + expanded.append(j) + i += 1 + return expanded + + +def _expanded_step_count(odtc_protocol: ODTCProtocol) -> int: + flat = _get_flat_steps(odtc_protocol) + loops = _analyze_flat_loops(flat) + return len(_expand_flat_sequence(flat, loops)) + + +def _cycle_count(odtc_protocol: ODTCProtocol) -> int: + flat = _get_flat_steps(odtc_protocol) + if not flat: + return 0 + loops = _analyze_flat_loops(flat) + if not loops: + return 1 + top_level = [ + (s, e, c) for (s, e, c) in loops + if not any((s2, e2, _) != (s, e, c) and s2 <= s and e <= e2 for (s2, e2, _) in loops) + ] + if not top_level: + return 0 + main = max(top_level, key=lambda x: x[1] - x[0]) + return main[2] + + +def estimate_method_duration_seconds(odtc_protocol: ODTCProtocol) -> float: + """Estimate total method duration in seconds (ramp + overshoot + plateau per step × loops).""" + if odtc_protocol.kind == "premethod": + return PREMETHOD_ESTIMATED_DURATION_SECONDS + flat = _get_flat_steps(odtc_protocol) + if not flat: + return 0.0 + loops = _analyze_flat_loops(flat) + step_nums = _expand_flat_sequence(flat, loops) + by_num = {fs.number: fs for fs in flat} + + total = 0.0 + prev_temp = odtc_protocol.start_block_temperature + min_slope = 0.1 + + for step_num in step_nums: + fs = by_num[step_num] + step = fs.step + slope = max(step.ramp.rate if not math.isinf(step.ramp.rate) else 4.4, min_slope) + ramp_time = abs(step.temperature - prev_temp) / slope + if step.ramp.overshoot: + os = step.ramp.overshoot + os1 = max(slope, min_slope) + os2 = max(os.return_rate, min_slope) + os_total = os.target_temp / os1 + os.hold_seconds + os.target_temp / os2 + else: + os_total = 0.0 + total += ramp_time + os_total + step.hold_seconds + prev_temp = step.temperature + + return total + + +# ============================================================================= +# Protocol timeline and progress +# ============================================================================= + + +def _build_protocol_timeline( + odtc_protocol: ODTCProtocol, +) -> List[Tuple[float, float, int, int, float, float]]: + """Build timeline segments: (t_start, t_end, step_idx, cycle_idx, setpoint, plateau_end).""" + if odtc_protocol.kind == "premethod": + duration = PREMETHOD_ESTIMATED_DURATION_SECONDS + setpoint = odtc_protocol.target_block_temperature + return [(0.0, duration, 0, 0, setpoint, duration)] + + flat = _get_flat_steps(odtc_protocol) + if not flat: + return [] + + loops = _analyze_flat_loops(flat) + step_nums = _expand_flat_sequence(flat, loops) + by_num = {fs.number: fs for fs in flat} + total_expanded = len(step_nums) + total_cycles = _cycle_count(odtc_protocol) + steps_per_cycle = total_expanded // total_cycles if total_cycles > 0 else max(1, total_expanded) + + segments: List[Tuple[float, float, int, int, float, float]] = [] + t = 0.0 + prev_temp = odtc_protocol.start_block_temperature + min_slope = 0.1 + + for flat_index, step_num in enumerate(step_nums): + step = by_num[step_num].step + slope = max(step.ramp.rate if not math.isinf(step.ramp.rate) else 4.4, min_slope) + ramp_time = abs(step.temperature - prev_temp) / slope + if step.ramp.overshoot: + os = step.ramp.overshoot + os1 = max(slope, min_slope) + os2 = max(os.return_rate, min_slope) + os_total = os.target_temp / os1 + os.hold_seconds + os.target_temp / os2 + else: + os_total = 0.0 + plateau_end_t = t + ramp_time + os_total + step.hold_seconds + cycle_index = flat_index // steps_per_cycle + step_index = flat_index % steps_per_cycle + segments.append((t, plateau_end_t, step_index, cycle_index, step.temperature, plateau_end_t)) + t = plateau_end_t + prev_temp = step.temperature + + return segments + + +_SNAP_TEMP_TOLERANCE = 0.5 + + +def _snap_step_from_target_temp( + step_nums: List[int], + by_num: Dict[int, _FlatStep], + new_target_c: float, + current_flat_index: int, + total_cycles: int, +) -> Optional[Dict[str, Any]]: + n = len(step_nums) + if n == 0: + return None + steps_per_cycle = n // total_cycles if total_cycles > 0 else n + for offset in range(1, n + 1): + idx = (current_flat_index + offset) % n + fs = by_num.get(step_nums[idx]) + if fs is None: + continue + if abs(fs.step.temperature - new_target_c) <= _SNAP_TEMP_TOLERANCE: + return { + "step_index": idx % steps_per_cycle, + "cycle_index": idx // steps_per_cycle, + "setpoint_c": fs.step.temperature, + } + return None + + +def _protocol_position_from_elapsed( + odtc_protocol: ODTCProtocol, elapsed_s: float +) -> Dict[str, Any]: + if elapsed_s < 0: + elapsed_s = 0.0 + segments = _build_protocol_timeline(odtc_protocol) + if not segments: + flat = _get_flat_steps(odtc_protocol) + loops = _analyze_flat_loops(flat) + total_steps = len(_expand_flat_sequence(flat, loops)) if flat else 0 + total_cycles = _cycle_count(odtc_protocol) if flat else 1 + return { + "step_index": 0, "cycle_index": 0, + "setpoint_c": odtc_protocol.start_block_temperature, + "remaining_hold_s": 0.0, + "total_steps": total_steps, "total_cycles": total_cycles, + } + + flat = _get_flat_steps(odtc_protocol) if odtc_protocol.kind == "method" else [] + if flat: + loops = _analyze_flat_loops(flat) + step_nums = _expand_flat_sequence(flat, loops) + total_expanded = len(step_nums) + total_cycles = _cycle_count(odtc_protocol) + steps_per_cycle = total_expanded // total_cycles if total_cycles > 0 else total_expanded + else: + steps_per_cycle = 1 + total_cycles = 1 + + for t_start, t_end, step_index, cycle_index, setpoint_c, plateau_end_t in segments: + if elapsed_s <= t_end: + return { + "step_index": step_index, "cycle_index": cycle_index, + "setpoint_c": setpoint_c, + "remaining_hold_s": max(0.0, plateau_end_t - elapsed_s), + "total_steps": steps_per_cycle, "total_cycles": total_cycles, + } + + _, _, step_index, cycle_index, setpoint_c, _ = segments[-1] + return { + "step_index": step_index, "cycle_index": cycle_index, + "setpoint_c": setpoint_c, "remaining_hold_s": 0.0, + "total_steps": steps_per_cycle, "total_cycles": total_cycles, + } + + +# ============================================================================= +# DataEvent payload parsing +# ============================================================================= + + +def _parse_data_event_series_value(series_elem: Any) -> Optional[float]: + values = series_elem.findall(".//integerValue") + if not values: + return None + text = values[-1].text + if text is None: + return None + try: + return float(text) + except ValueError: + return None + + +def _parse_data_event_payload(payload: Dict[str, Any]) -> Dict[str, Any]: + """Parse a DataEvent payload; returns elapsed_s + optional temperatures. + + The ODTC device emits exactly four data series per DataEvent: + Elapsed time, Target temperature, Current temperature, LID temperature. + """ + data_value = payload.get("dataValue") + if not data_value or not isinstance(data_value, str): + raise ValueError(f"DataEvent missing dataValue: {payload}") + outer = ET.fromstring(data_value) + any_data = outer.find(".//{*}AnyData") + if any_data is None: + any_data = outer.find(".//AnyData") + if any_data is None or any_data.text is None: + raise ValueError(f"DataEvent missing AnyData: {data_value[:200]}") + inner_xml = any_data.text.strip() + if "<" in inner_xml or ">" in inner_xml: + inner_xml = html.unescape(inner_xml) + inner = ET.fromstring(inner_xml) + elapsed_s = 0.0 + target_temp_c: Optional[float] = None + current_temp_c: Optional[float] = None + lid_temp_c: Optional[float] = None + for elem in inner.iter(): + if not elem.tag.endswith("dataSeries"): + continue + name_id = elem.get("nameId") + unit = elem.get("unit") or "" + raw = _parse_data_event_series_value(elem) + if raw is None: + continue + if name_id == "Elapsed time" and unit == "ms": + elapsed_s = raw / 1000.0 + elif name_id == "Target temperature" and unit == "1/100°C": + target_temp_c = raw / 100.0 + elif name_id == "Current temperature" and unit == "1/100°C": + current_temp_c = raw / 100.0 + elif name_id == "LID temperature" and unit == "1/100°C": + lid_temp_c = raw / 100.0 + return { + "elapsed_s": elapsed_s, + "target_temp_c": target_temp_c, + "current_temp_c": current_temp_c, + "lid_temp_c": lid_temp_c, + } + + +def build_progress_from_data_event( + payload: Dict[str, Any], + odtc_protocol: Optional[ODTCProtocol] = None, + last_target_temp_c: Optional[float] = None, +) -> ODTCProgress: + """Build ODTCProgress from a raw DataEvent payload and optional protocol.""" + parsed = _parse_data_event_payload(payload) + elapsed_s = parsed["elapsed_s"] + target_temp_c = parsed.get("target_temp_c") + current_temp_c = parsed.get("current_temp_c") + lid_temp_c = parsed.get("lid_temp_c") + + if odtc_protocol is None: + return ODTCProgress( + elapsed_s=elapsed_s, target_temp_c=target_temp_c, + current_temp_c=current_temp_c, lid_temp_c=lid_temp_c, + estimated_duration_s=None, remaining_duration_s=0.0, + ) + + position = _protocol_position_from_elapsed(odtc_protocol, elapsed_s) + + if ( + odtc_protocol.kind == "method" + and odtc_protocol.stages + and target_temp_c is not None + and last_target_temp_c is not None + and abs(target_temp_c - last_target_temp_c) > _SNAP_TEMP_TOLERANCE + ): + flat = _get_flat_steps(odtc_protocol) + loops = _analyze_flat_loops(flat) + step_nums = _expand_flat_sequence(flat, loops) + by_num = {fs.number: fs for fs in flat} + total_cycles = _cycle_count(odtc_protocol) + steps_per_cycle = len(step_nums) // total_cycles if total_cycles > 0 else len(step_nums) + current_flat = position["step_index"] + position["cycle_index"] * steps_per_cycle + snapped = _snap_step_from_target_temp(step_nums, by_num, target_temp_c, current_flat, total_cycles) + if snapped is not None: + position = {**position, **snapped} + + target = target_temp_c + if odtc_protocol.kind == "premethod": + target = odtc_protocol.target_block_temperature + elif position.get("setpoint_c") is not None and target is None: + target = position["setpoint_c"] + + est_s: Optional[float] = ( + PREMETHOD_ESTIMATED_DURATION_SECONDS if odtc_protocol.kind == "premethod" + else estimate_method_duration_seconds(odtc_protocol) + ) + rem_s = max(0.0, (est_s or 0.0) - elapsed_s) + + return ODTCProgress( + elapsed_s=elapsed_s, target_temp_c=target, + current_temp_c=current_temp_c, lid_temp_c=lid_temp_c, + current_step_index=position["step_index"], + total_step_count=position.get("total_steps") or 0, + current_cycle_index=position["cycle_index"], + total_cycle_count=position.get("total_cycles") or 0, + remaining_hold_s=position.get("remaining_hold_s") or 0.0, + estimated_duration_s=est_s, + remaining_duration_s=rem_s, + ) diff --git a/pylabrobot/inheco/odtc/tests/__init__.py b/pylabrobot/inheco/odtc/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/inheco/odtc/tests/door_tests.py b/pylabrobot/inheco/odtc/tests/door_tests.py new file mode 100644 index 00000000000..4d0ba0a7b6f --- /dev/null +++ b/pylabrobot/inheco/odtc/tests/door_tests.py @@ -0,0 +1,76 @@ +"""Tests for ODTCDoorBackend state tracking.""" + +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.inheco.odtc.door import DoorStateUnknownError, ODTCDoorBackend +from pylabrobot.inheco.odtc.driver import ODTCDriver + + +def _make_door_backend() -> ODTCDoorBackend: + driver = MagicMock(spec=ODTCDriver) + driver.send_command = AsyncMock(return_value=None) + return ODTCDoorBackend(driver=driver) + + +class TestODTCDoorBackend(unittest.IsolatedAsyncioTestCase): + async def test_initial_state_is_unknown(self): + door = _make_door_backend() + with self.assertRaises(DoorStateUnknownError): + _ = door.is_open + + async def test_state_is_open_after_open(self): + door = _make_door_backend() + await door.open() + self.assertTrue(door.is_open) + + async def test_state_is_closed_after_close(self): + door = _make_door_backend() + await door.close() + self.assertFalse(door.is_open) + + async def test_open_calls_open_door_command(self): + door = _make_door_backend() + await door.open() + door._driver.send_command.assert_called_once_with("OpenDoor") + + async def test_close_calls_close_door_command(self): + door = _make_door_backend() + await door.close() + door._driver.send_command.assert_called_once_with("CloseDoor") + + async def test_state_toggles_correctly(self): + door = _make_door_backend() + await door.open() + self.assertTrue(door.is_open) + await door.close() + self.assertFalse(door.is_open) + await door.open() + self.assertTrue(door.is_open) + + async def test_on_setup_resets_state_to_unknown(self): + door = _make_door_backend() + await door.open() + self.assertTrue(door.is_open) + await door._on_setup() + with self.assertRaises(DoorStateUnknownError): + _ = door.is_open + + async def test_on_setup_resets_from_closed_to_unknown(self): + door = _make_door_backend() + await door.close() + self.assertFalse(door.is_open) + await door._on_setup() + with self.assertRaises(DoorStateUnknownError): + _ = door.is_open + + async def test_error_message_is_informative(self): + door = _make_door_backend() + with self.assertRaises(DoorStateUnknownError) as ctx: + _ = door.is_open + self.assertIn("odtc.door.open()", str(ctx.exception)) + self.assertIn("odtc.door.close()", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/inheco/odtc/tests/model_tests.py b/pylabrobot/inheco/odtc/tests/model_tests.py new file mode 100644 index 00000000000..943e1c73c5b --- /dev/null +++ b/pylabrobot/inheco/odtc/tests/model_tests.py @@ -0,0 +1,213 @@ +"""Tests for ODTC model — ODTCProtocol, constraints, helpers.""" + +import unittest + +from pylabrobot.capabilities.thermocycling.standard import Protocol, Ramp, Stage, Step +from pylabrobot.inheco.odtc.model import ( + FluidQuantity, + ODTCPID, + ODTCMethodSet, + ODTCProtocol, + get_constraints, + normalize_variant, + volume_to_fluid_quantity, +) + + +def _make_method(**kwargs) -> ODTCProtocol: + defaults = dict( + stages=[Stage(steps=[Step(temperature=95.0, hold_seconds=30.0)])], + name="TestMethod", + variant=96, + plate_type=0, + fluid_quantity=FluidQuantity.UL_30_TO_74, + post_heating=True, + start_block_temperature=25.0, + start_lid_temperature=110.0, + pid_set=[ODTCPID(number=1)], + kind="method", + ) + defaults.update(kwargs) + return ODTCProtocol(**defaults) + + +class TestODTCProtocolIsProtocol(unittest.TestCase): + def test_isinstance_protocol(self): + m = _make_method() + self.assertIsInstance(m, Protocol) + + def test_name_from_protocol(self): + m = _make_method(name="PCR_96") + self.assertEqual(m.name, "PCR_96") + + def test_lid_temperature_from_protocol(self): + m = _make_method(lid_temperature=105.0) + self.assertEqual(m.lid_temperature, 105.0) + + def test_stages_from_protocol(self): + stages = [Stage(steps=[Step(temperature=95.0, hold_seconds=30.0)], repeats=35)] + m = _make_method(stages=stages) + self.assertEqual(len(m.stages), 1) + self.assertEqual(m.stages[0].repeats, 35) + + def test_defaults(self): + m = _make_method() + self.assertEqual(m.kind, "method") + self.assertTrue(m.is_scratch) + self.assertIsNone(m.creator) + self.assertIsNone(m.description) + self.assertIsNotNone(m.datetime) + self.assertEqual(m.target_block_temperature, 0.0) + self.assertEqual(m.target_lid_temperature, 0.0) + + +class TestODTCProtocolValidation(unittest.TestCase): + def test_invalid_fluid_quantity_96(self): + with self.assertRaises(ValueError) as ctx: + _make_method(fluid_quantity=99) + self.assertIn("fluid_quantity", str(ctx.exception)) + + def test_invalid_plate_type_96(self): + with self.assertRaises(ValueError) as ctx: + _make_method(plate_type=2, variant=96) + self.assertIn("plate_type", str(ctx.exception)) + + def test_valid_plate_type_384(self): + m = _make_method(variant=384, plate_type=2, pid_set=[ODTCPID(number=1)]) + self.assertEqual(m.plate_type, 2) + + def test_invalid_plate_type_384(self): + with self.assertRaises(ValueError): + _make_method(variant=384, plate_type=99) + + def test_invalid_lid_temperature(self): + with self.assertRaises(ValueError) as ctx: + _make_method(lid_temperature=200.0) + self.assertIn("lid_temperature", str(ctx.exception)) + + def test_valid_lid_none(self): + m = _make_method(lid_temperature=None) + self.assertIsNone(m.lid_temperature) + + +class TestPremethod(unittest.TestCase): + def test_premethod_kind(self): + m = ODTCProtocol( + stages=[], + name="PreHeat", + variant=96, + plate_type=0, + fluid_quantity=1, + post_heating=False, + start_block_temperature=37.0, + start_lid_temperature=110.0, + pid_set=[ODTCPID(number=1)], + kind="premethod", + target_block_temperature=37.0, + target_lid_temperature=110.0, + ) + self.assertEqual(m.kind, "premethod") + self.assertAlmostEqual(m.target_block_temperature, 37.0) + + +class TestNormalizeVariant(unittest.TestCase): + def test_96(self): + self.assertEqual(normalize_variant(96), 96) + self.assertEqual(normalize_variant(960000), 96) + + def test_384(self): + self.assertEqual(normalize_variant(384), 384) + self.assertEqual(normalize_variant(384000), 384) + self.assertEqual(normalize_variant(3840000), 384) + + def test_invalid(self): + with self.assertRaises(ValueError): + normalize_variant(100) + + +class TestGetConstraints(unittest.TestCase): + def test_96_constraints(self): + c = get_constraints(96) + self.assertAlmostEqual(c.max_heating_slope, 4.4) + self.assertAlmostEqual(c.max_lid_temp, 110.0) + + def test_384_constraints(self): + c = get_constraints(384) + self.assertAlmostEqual(c.max_heating_slope, 5.0) + self.assertAlmostEqual(c.max_lid_temp, 115.0) + self.assertIn(2, c.valid_plate_types) + + +class TestVolumeToFluidQuantity(unittest.TestCase): + def test_small(self): + self.assertEqual(volume_to_fluid_quantity(20.0), 0) + + def test_medium(self): + self.assertEqual(volume_to_fluid_quantity(50.0), 1) + + def test_large(self): + self.assertEqual(volume_to_fluid_quantity(80.0), 2) + + def test_too_large(self): + with self.assertRaises(ValueError): + volume_to_fluid_quantity(101.0) + + +class TestODTCMethodSet(unittest.TestCase): + def test_get_by_name(self): + m = _make_method(name="PCR1") + ms = ODTCMethodSet(methods=[m]) + self.assertIs(ms.get("PCR1"), m) + self.assertIsNone(ms.get("Missing")) + + def test_get_premethod(self): + pm = ODTCProtocol( + stages=[], + name="PreHeat", + variant=96, + plate_type=0, + fluid_quantity=1, + post_heating=False, + start_block_temperature=37.0, + start_lid_temperature=110.0, + pid_set=[ODTCPID(number=1)], + kind="premethod", + ) + ms = ODTCMethodSet(premethods=[pm]) + self.assertIs(ms.get("PreHeat"), pm) + + +class TestFluidQuantity(unittest.TestCase): + def test_int_enum_equality(self): + """FluidQuantity is an IntEnum — integer comparisons still work.""" + self.assertEqual(FluidQuantity.UL_10_TO_29, 0) + self.assertEqual(FluidQuantity.UL_30_TO_74, 1) + self.assertEqual(FluidQuantity.UL_75_TO_100, 2) + self.assertEqual(FluidQuantity.VERIFICATION_TOOL, -1) + + def test_volume_to_fluid_quantity_returns_fluid_quantity(self): + result = volume_to_fluid_quantity(20.0) + self.assertIsInstance(result, FluidQuantity) + self.assertEqual(result, FluidQuantity.UL_10_TO_29) + + def test_volume_to_fluid_quantity_ranges(self): + self.assertEqual(volume_to_fluid_quantity(10.0), FluidQuantity.UL_10_TO_29) + self.assertEqual(volume_to_fluid_quantity(29.0), FluidQuantity.UL_10_TO_29) + self.assertEqual(volume_to_fluid_quantity(30.0), FluidQuantity.UL_30_TO_74) + self.assertEqual(volume_to_fluid_quantity(74.0), FluidQuantity.UL_30_TO_74) + self.assertEqual(volume_to_fluid_quantity(75.0), FluidQuantity.UL_75_TO_100) + self.assertEqual(volume_to_fluid_quantity(100.0), FluidQuantity.UL_75_TO_100) + + def test_volume_to_fluid_quantity_too_large(self): + with self.assertRaises(ValueError): + volume_to_fluid_quantity(101.0) + + def test_fluid_quantity_used_as_int_in_xml_validation(self): + """FluidQuantity can be used wherever int is expected.""" + m = _make_method(fluid_quantity=FluidQuantity.UL_30_TO_74) + self.assertEqual(m.fluid_quantity, 1) + self.assertIsInstance(m.fluid_quantity, FluidQuantity) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/inheco/odtc/tests/protocol_tests.py b/pylabrobot/inheco/odtc/tests/protocol_tests.py new file mode 100644 index 00000000000..65409f82792 --- /dev/null +++ b/pylabrobot/inheco/odtc/tests/protocol_tests.py @@ -0,0 +1,341 @@ +"""Tests for ODTC protocol conversion, duration estimation, and progress parsing.""" + +import math +import unittest +import xml.etree.ElementTree as ET + +from pylabrobot.capabilities.thermocycling.standard import ( + Overshoot, + Protocol, + Ramp, + Stage, + Step, +) +from pylabrobot.inheco.odtc.model import ODTCPID, ODTCProtocol +from pylabrobot.inheco.odtc.protocol import ( + _calc_overshoot, + _from_protocol, + _cycle_count, + _expanded_step_count, + estimate_method_duration_seconds, + build_progress_from_data_event, + _build_protocol_timeline, +) + + +def _pcr_protocol() -> Protocol: + """Standard PCR: 1 denaturation stage + 35-cycle PCR stage.""" + return Protocol( + stages=[ + Stage(steps=[Step(95.0, 120.0)], repeats=1), + Stage( + steps=[ + Step(95.0, 10.0, ramp=Ramp(rate=4.4)), + Step(55.0, 30.0, ramp=Ramp(rate=2.2)), + Step(72.0, 60.0, ramp=Ramp(rate=2.2)), + ], + repeats=35, + ), + Stage(steps=[Step(72.0, 600.0)], repeats=1), + ], + name="PCR_Test", + ) + + +class TestCalcOvershoot(unittest.TestCase): + def test_no_overshoot_small_delta(self): + os = _calc_overshoot(30.0, 25.0, 4.4, 30.0, 1) + self.assertIsNone(os) + + def test_no_overshoot_no_hold(self): + os = _calc_overshoot(95.0, 25.0, 4.4, 0.0, 1) + self.assertIsNone(os) + + def test_no_overshoot_invalid_fluid(self): + os = _calc_overshoot(95.0, 25.0, 4.4, 30.0, -1) + self.assertIsNone(os) + + def test_heating_overshoot_present(self): + os = _calc_overshoot(95.0, 25.0, 4.4, 30.0, 1) + self.assertIsNotNone(os) + self.assertGreater(os.target_temp, 0.0) + self.assertAlmostEqual(os.return_rate, 2.2) + + def test_cooling_overshoot_present(self): + # plateau_temp must be > 35°C for cooling overshoot to trigger + os = _calc_overshoot(40.0, 95.0, 2.2, 30.0, 1) + self.assertIsNotNone(os) + self.assertGreater(os.target_temp, 0.0) + + def test_overshoot_capped_at_102(self): + # Ramp to 99°C from 0°C should be capped + os = _calc_overshoot(99.0, 0.0, 4.4, 30.0, 2) + if os is not None: + # If overshoot computed, target_temp + plateau_temp <= 102 + self.assertLessEqual(os.target_temp + 99.0, 102.1) + + +class TestFromProtocol(unittest.TestCase): + def test_produces_odtc_protocol(self): + p = _pcr_protocol() + odtc = _from_protocol(p, variant=96, fluid_quantity=1) + self.assertIsInstance(odtc, ODTCProtocol) + self.assertIsInstance(odtc, Protocol) + + def test_stage_count_preserved(self): + p = _pcr_protocol() + odtc = _from_protocol(p, variant=96, fluid_quantity=1) + self.assertEqual(len(odtc.stages), 3) + self.assertEqual(odtc.stages[1].repeats, 35) + + def test_step_count_preserved(self): + p = _pcr_protocol() + odtc = _from_protocol(p, variant=96, fluid_quantity=1) + self.assertEqual(len(odtc.stages[1].steps), 3) + + def test_slope_clamped_to_hardware_max(self): + """Slope exceeding hardware max is clamped to max.""" + p = Protocol( + stages=[Stage(steps=[Step(95.0, 30.0, ramp=Ramp(rate=99.9))], repeats=1)], + ) + odtc = _from_protocol(p, variant=96) + step = odtc.stages[0].steps[0] + self.assertLessEqual(step.ramp.rate, 4.4 + 0.01) + + def test_overshoot_computed_for_valid_step(self): + """Step with large temperature delta should have computed overshoot.""" + p = Protocol( + stages=[Stage(steps=[Step(95.0, 30.0, ramp=Ramp(rate=4.4))], repeats=1)], + ) + odtc = _from_protocol(p, variant=96, fluid_quantity=1) + step = odtc.stages[0].steps[0] + # Not necessarily has overshoot (depends on prev_temp=25, delta=70 > 5 and target > 35) + # So we just check it's a Ramp object with rate set + self.assertIsNotNone(step.ramp) + self.assertAlmostEqual(step.ramp.rate, 4.4) + + def test_user_overshoot_honoured(self): + """If user specifies overshoot on step, it's preserved.""" + user_os = Overshoot(target_temp=3.0, hold_seconds=1.0, return_rate=1.5) + p = Protocol( + stages=[Stage(steps=[Step(95.0, 30.0, ramp=Ramp(rate=4.4, overshoot=user_os))], repeats=1)], + ) + odtc = _from_protocol(p, variant=96, fluid_quantity=1) + step = odtc.stages[0].steps[0] + self.assertIsNotNone(step.ramp.overshoot) + self.assertAlmostEqual(step.ramp.overshoot.target_temp, 3.0) + self.assertAlmostEqual(step.ramp.overshoot.return_rate, 1.5) + + def test_lid_temperature_applied(self): + p = Protocol(stages=[Stage(steps=[Step(95.0, 30.0)], repeats=1)]) + odtc = _from_protocol(p, variant=96, lid_temperature=105.0) + step = odtc.stages[0].steps[0] + self.assertAlmostEqual(step.lid_temperature, 105.0) + + def test_name_sets_is_scratch_false(self): + p = Protocol(stages=[], name="MyPCR") + odtc = _from_protocol(p, variant=96, name="MyPCR") + self.assertFalse(odtc.is_scratch) + self.assertEqual(odtc.name, "MyPCR") + + def test_no_name_sets_is_scratch_true(self): + p = Protocol(stages=[]) + odtc = _from_protocol(p, variant=96) + self.assertTrue(odtc.is_scratch) + + def test_start_block_temperature_is_first_step(self): + p = _pcr_protocol() + odtc = _from_protocol(p, variant=96) + self.assertAlmostEqual(odtc.start_block_temperature, 95.0) + + def test_inner_stages_preserved(self): + inner = Stage(steps=[Step(55.0, 30.0), Step(72.0, 60.0)], repeats=35) + outer = Stage(steps=[Step(95.0, 10.0)], repeats=1, inner_stages=[inner]) + p = Protocol(stages=[outer]) + odtc = _from_protocol(p, variant=96, fluid_quantity=1) + self.assertEqual(len(odtc.stages[0].inner_stages), 1) + self.assertEqual(odtc.stages[0].inner_stages[0].repeats, 35) + + +class TestExpandedStepCount(unittest.TestCase): + def _make_pcr_odtc(self) -> ODTCProtocol: + p = _pcr_protocol() + return _from_protocol(p, variant=96, fluid_quantity=1) + + def test_step_count_with_loops(self): + odtc = self._make_pcr_odtc() + # 1 + (35 * 3) + 1 = 107 + count = _expanded_step_count(odtc) + self.assertEqual(count, 107) + + def test_cycle_count(self): + odtc = self._make_pcr_odtc() + self.assertEqual(_cycle_count(odtc), 35) + + +class TestEstimateDuration(unittest.TestCase): + def test_duration_positive(self): + p = _pcr_protocol() + odtc = _from_protocol(p, variant=96, fluid_quantity=1) + dur = estimate_method_duration_seconds(odtc) + self.assertGreater(dur, 0) + + def test_premethod_duration(self): + odtc = ODTCProtocol( + stages=[], variant=96, plate_type=0, fluid_quantity=0, + post_heating=False, start_block_temperature=37.0, start_lid_temperature=110.0, + pid_set=[ODTCPID(number=1)], kind="premethod", + ) + dur = estimate_method_duration_seconds(odtc) + self.assertAlmostEqual(dur, 600.0) + + +class TestProgressFromDataEvent(unittest.TestCase): + def _make_payload(self, elapsed_s: float, request_id: int = 12345) -> dict: + import html as html_mod + ms = int(elapsed_s * 1000) + inner = ( + f'' + f"{ms}" + f'' + f"9500" + ) + inner_escaped = html_mod.escape(inner) + outer = f'{inner_escaped}' + return {"requestId": request_id, "dataValue": outer} + + def test_no_protocol_returns_basic_progress(self): + payload = self._make_payload(100.0) + progress = build_progress_from_data_event(payload) + self.assertAlmostEqual(progress.elapsed_s, 100.0) + self.assertIsNone(progress.estimated_duration_s) + + def test_with_protocol_returns_enriched_progress(self): + p = _pcr_protocol() + odtc = _from_protocol(p, variant=96, fluid_quantity=1) + payload = self._make_payload(150.0) + progress = build_progress_from_data_event(payload, odtc_protocol=odtc) + self.assertAlmostEqual(progress.elapsed_s, 150.0) + self.assertGreater(progress.estimated_duration_s, 0) + self.assertGreaterEqual(progress.total_step_count, 1) + self.assertGreaterEqual(progress.total_cycle_count, 1) + + def test_str_format(self): + p = _pcr_protocol() + odtc = _from_protocol(p, variant=96, fluid_quantity=1) + payload = self._make_payload(5.0) + progress = build_progress_from_data_event(payload, odtc_protocol=odtc) + msg = str(progress) + self.assertIn("elapsed", msg) + + +class TestApplyOvershoot(unittest.TestCase): + def test_apply_overshoot_false_produces_no_auto_overshoot(self): + """apply_overshoot=False: steps with large temp delta get no auto-computed overshoot.""" + p = Protocol(stages=[Stage(steps=[Step(temperature=95.0, hold_seconds=30.0)], repeats=1)]) + odtc = _from_protocol(p, variant=96, fluid_quantity=1, apply_overshoot=False) + step = odtc.stages[0].steps[0] + self.assertIsNone(step.ramp.overshoot) + + def test_apply_overshoot_true_computes_for_large_delta(self): + """apply_overshoot=True (default): large delta step gets overshoot computed.""" + p = Protocol(stages=[Stage(steps=[Step(temperature=95.0, hold_seconds=30.0, ramp=Ramp(rate=4.4))], repeats=1)]) + odtc = _from_protocol(p, variant=96, fluid_quantity=1, apply_overshoot=True) + step = odtc.stages[0].steps[0] + # delta 95-25=70 > threshold; fluid_quantity=1 → overshoot should be computed + self.assertIsNotNone(step.ramp.overshoot) + + def test_explicit_overshoot_honoured_when_apply_false(self): + """Explicit Ramp.overshoot is always preserved even when apply_overshoot=False.""" + from pylabrobot.capabilities.thermocycling.standard import Overshoot + user_os = Overshoot(target_temp=3.0, hold_seconds=1.0, return_rate=1.5) + p = Protocol(stages=[Stage( + steps=[Step(temperature=95.0, hold_seconds=30.0, ramp=Ramp(rate=4.4, overshoot=user_os))], + repeats=1, + )]) + odtc = _from_protocol(p, variant=96, fluid_quantity=1, apply_overshoot=False) + step = odtc.stages[0].steps[0] + self.assertIsNotNone(step.ramp.overshoot) + self.assertAlmostEqual(step.ramp.overshoot.target_temp, 3.0) + + def test_from_protocol_classmethod_is_proper_classmethod(self): + """ODTCProtocol.from_protocol is a proper classmethod, not a monkey-patch.""" + from pylabrobot.inheco.odtc.model import ODTCProtocol, FluidQuantity + p = _pcr_protocol() + odtc = ODTCProtocol.from_protocol( + p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, name="TestPCR" + ) + self.assertIsInstance(odtc, ODTCProtocol) + self.assertEqual(odtc.name, "TestPCR") + self.assertFalse(odtc.is_scratch) + self.assertEqual(odtc.fluid_quantity, FluidQuantity.UL_30_TO_74) + + def test_from_protocol_classmethod_apply_overshoot_false(self): + """ODTCProtocol.from_protocol apply_overshoot=False works via classmethod.""" + from pylabrobot.inheco.odtc.model import ODTCProtocol + p = Protocol(stages=[Stage(steps=[Step(temperature=95.0, hold_seconds=30.0, ramp=Ramp(rate=4.4))], repeats=1)]) + odtc = ODTCProtocol.from_protocol(p, variant=96, fluid_quantity=1, apply_overshoot=False) + step = odtc.stages[0].steps[0] + self.assertIsNone(step.ramp.overshoot) + + +class TestBackendMethods(unittest.IsolatedAsyncioTestCase): + """Tests for restored backend methods (using mock driver).""" + + def _make_backend(self): + from unittest.mock import AsyncMock, MagicMock + from pylabrobot.inheco.odtc.backend import ODTCThermocyclerBackend + from pylabrobot.inheco.odtc.driver import ODTCDriver + from pylabrobot.inheco.odtc.model import ODTCMethodSet, ODTCProtocol, ODTCPID, FluidQuantity + driver = MagicMock(spec=ODTCDriver) + driver.send_command = AsyncMock(return_value=None) + driver.send_command_async = AsyncMock(return_value=(AsyncMock(), 12345)) + backend = ODTCThermocyclerBackend(driver=driver, variant=96) + backend.get_method_set = AsyncMock(return_value=ODTCMethodSet(methods=[])) + return backend + + async def test_run_stored_protocol_raises_for_missing_name(self): + backend = self._make_backend() + with self.assertRaises(ValueError) as ctx: + await backend.run_stored_protocol("NonExistent") + self.assertIn("NonExistent", str(ctx.exception)) + self.assertIn("upload", str(ctx.exception).lower()) + + async def test_upload_protocol_raises_on_conflict_without_overwrite(self): + from unittest.mock import AsyncMock + from pylabrobot.inheco.odtc.model import ODTCMethodSet, ODTCProtocol, ODTCPID, FluidQuantity + backend = self._make_backend() + existing_method = ODTCProtocol( + stages=[], name="PCR1", variant=96, plate_type=0, + fluid_quantity=FluidQuantity.UL_30_TO_74, post_heating=True, + start_block_temperature=25.0, start_lid_temperature=110.0, + pid_set=[ODTCPID(number=1)], kind="method", is_scratch=False, + ) + backend.get_method_set = AsyncMock(return_value=ODTCMethodSet(methods=[existing_method])) + new_method = ODTCProtocol( + stages=[], name="PCR1", variant=96, plate_type=0, + fluid_quantity=FluidQuantity.UL_30_TO_74, post_heating=True, + start_block_temperature=25.0, start_lid_temperature=110.0, + pid_set=[ODTCPID(number=1)], kind="method", is_scratch=False, + ) + with self.assertRaises(ValueError) as ctx: + await backend.upload_protocol(new_method, allow_overwrite=False) + self.assertIn("PCR1", str(ctx.exception)) + + async def test_upload_protocol_scratch_bypasses_conflict_check(self): + from unittest.mock import AsyncMock + from pylabrobot.inheco.odtc.model import ODTCMethodSet, ODTCProtocol, ODTCPID, FluidQuantity + backend = self._make_backend() + scratch_method = ODTCProtocol( + stages=[], name="plr_currentProtocol", variant=96, plate_type=0, + fluid_quantity=FluidQuantity.UL_30_TO_74, post_heating=True, + start_block_temperature=25.0, start_lid_temperature=110.0, + pid_set=[ODTCPID(number=1)], kind="method", is_scratch=True, + ) + backend._upload_method_set = AsyncMock() + await backend.upload_protocol(scratch_method, allow_overwrite=False) + backend._upload_method_set.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/inheco/odtc/tests/sila_interface_tests.py b/pylabrobot/inheco/odtc/tests/sila_interface_tests.py new file mode 100644 index 00000000000..efb651908b2 --- /dev/null +++ b/pylabrobot/inheco/odtc/tests/sila_interface_tests.py @@ -0,0 +1,223 @@ +"""Tests for ODTCDriver event handling and ODTCThermocyclerBackend.""" + +import asyncio +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from pylabrobot.capabilities.thermocycling.standard import Protocol, Ramp, Stage, Step +from pylabrobot.inheco.odtc.backend import ODTCThermocyclerBackend +from pylabrobot.inheco.odtc.driver import ODTCDriver +from pylabrobot.inheco.odtc.model import ODTCPID, ODTCProtocol +from pylabrobot.inheco.scila.inheco_sila_interface import SiLAError, SiLAState + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_interface() -> ODTCDriver: + """Create ODTCDriver without starting the HTTP server.""" + iface = ODTCDriver.__new__(ODTCDriver) + iface._machine_ip = "127.0.0.1" + iface._client_ip = "127.0.0.1" + import logging + iface._logger = logging.getLogger("test") + iface._pending_by_id = {} + iface._data_events_by_request_id = {} + iface._loop = None + iface._httpd = None + iface._server_task = None + iface._closed = False + iface._lock_id = None + return iface + + +def _add_pending(iface: ODTCDriver, command: str, request_id: int): + """Register a pending async command future on the interface.""" + from pylabrobot.inheco.scila.inheco_sila_interface import InhecoSiLAInterface + fut = asyncio.get_event_loop().create_future() + iface._pending_by_id[request_id] = InhecoSiLAInterface._SiLACommand( + name=command, request_id=request_id, fut=fut + ) + return fut + + +# --------------------------------------------------------------------------- +# Event handling tests +# --------------------------------------------------------------------------- + + +class TestODTCDriverEvents(unittest.TestCase): + def setUp(self): + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + self.iface = _make_interface() + + def tearDown(self): + self.loop.close() + + def test_error_event_raises_sila_error_not_runtime_error(self): + """ErrorEvent must complete pending future with SiLAError, not RuntimeError.""" + fut = _add_pending(self.iface, "ExecuteMethod", 42) + self.iface._on_error_event({ + "requestId": 42, + "returnValue": {"returnCode": 9, "message": "Device error"}, + }) + self.assertTrue(fut.done()) + exc = fut.exception() + self.assertIsInstance(exc, SiLAError, f"Expected SiLAError, got {type(exc)}") + self.assertEqual(exc.code, 9) + + def test_error_event_no_pending_does_not_crash(self): + """ErrorEvent for unknown requestId should log and not raise.""" + self.iface._on_error_event({ + "requestId": 999, + "returnValue": {"returnCode": 9, "message": "Unknown"}, + }) + + def test_status_event_error_handling_state_rejects_pending(self): + """StatusEvent with errorHandling state should reject pending ExecuteMethod future.""" + fut = _add_pending(self.iface, "ExecuteMethod", 100) + self.iface._on_status_event({ + "eventDescription": { + "DeviceState": "errorHandling", + "Extensions": ["DeviceError", 2001, "0x7D1", "MotorError", "Motor fault detected"], + } + }) + self.assertTrue(fut.done()) + exc = fut.exception() + self.assertIsInstance(exc, SiLAError) + self.assertEqual(exc.code, 2001) + self.assertIn("Motor fault", exc.message) + + def test_status_event_in_error_includes_recovery_hint(self): + """StatusEvent with inError should include power-cycle hint in message.""" + fut = _add_pending(self.iface, "ExecuteMethod", 101) + self.iface._on_status_event({ + "eventDescription": { + "DeviceState": "inError", + "Extensions": ["DeviceError", 1000, "0x3E8", "ThermalRunaway", "Block overheated"], + } + }) + exc = fut.exception() + self.assertIsInstance(exc, SiLAError) + self.assertIn("power cycle", exc.message.lower()) + + def test_status_event_idle_does_not_reject_pending(self): + """StatusEvent with IDLE state should NOT reject pending futures.""" + fut = _add_pending(self.iface, "ExecuteMethod", 102) + self.iface._on_status_event({ + "eventDescription": {"DeviceState": "idle", "Extensions": []} + }) + self.assertFalse(fut.done()) + + def test_response_event_code_1_completes_normally(self): + """ResponseEvent with code 1 (success no data) should complete future with None.""" + fut = _add_pending(self.iface, "StopMethod", 200) + self.iface._on_response_event({ + "requestId": 200, + "returnValue": {"returnCode": 1, "message": "Success"}, + }) + self.assertTrue(fut.done()) + self.assertIsNone(fut.result()) + + def test_response_event_code_3_with_data_completes(self): + """ResponseEvent with code 3 and responseData should complete future with parsed XML.""" + import xml.etree.ElementTree as ET + fut = _add_pending(self.iface, "GetParameters", 201) + xml_data = "test" + self.iface._on_response_event({ + "requestId": 201, + "returnValue": {"returnCode": 3, "message": ""}, + "responseData": xml_data, + }) + self.assertTrue(fut.done()) + result = fut.result() + self.assertIsNotNone(result) + self.assertIsInstance(result, ET.Element) + + def test_response_event_non_success_raises_sila_error(self): + """ResponseEvent with code != 1 or 3 should reject with SiLAError.""" + fut = _add_pending(self.iface, "SetParameters", 202) + self.iface._on_response_event({ + "requestId": 202, + "returnValue": {"returnCode": 9, "message": "Invalid state"}, + }) + self.assertTrue(fut.done()) + exc = fut.exception() + self.assertIsInstance(exc, SiLAError) + self.assertEqual(exc.code, 9) + + def test_device_error_code_raises_sila_error(self): + """_handle_device_error_code (1000+ codes) must raise SiLAError.""" + with self.assertRaises(SiLAError) as ctx: + self.iface._handle_device_error_code(2001, "Motor fault", "ExecuteMethod") + self.assertEqual(ctx.exception.code, 2001) + + def test_unknown_device_error_code_also_raises(self): + """Unknown 1000+ codes (not in original whitelist) must also raise SiLAError.""" + with self.assertRaises(SiLAError): + self.iface._handle_device_error_code(2010, "Unknown error", "ExecuteMethod") + + +# --------------------------------------------------------------------------- +# Backend params tests +# --------------------------------------------------------------------------- + + +class TestRunProtocolParams(unittest.TestCase): + def test_default_params(self): + p = ODTCThermocyclerBackend.RunProtocolParams() + self.assertEqual(p.variant, 96) + self.assertEqual(p.fluid_quantity, 1) + self.assertTrue(p.post_heating) + self.assertTrue(p.dynamic_pre_method_duration) + self.assertIsNone(p.name) + + def test_custom_params(self): + p = ODTCThermocyclerBackend.RunProtocolParams( + variant=384, fluid_quantity=2, name="PCR_384" + ) + self.assertEqual(p.variant, 384) + self.assertEqual(p.fluid_quantity, 2) + self.assertEqual(p.name, "PCR_384") + + def test_step_params_default(self): + sp = ODTCThermocyclerBackend.StepParams() + self.assertEqual(sp.pid_number, 1) + + +class TestBackendResolvesProtocol(unittest.TestCase): + def _make_backend(self) -> ODTCThermocyclerBackend: + driver = MagicMock(spec=ODTCDriver) + backend = ODTCThermocyclerBackend(driver=driver, variant=96) + return backend + + def test_plain_protocol_compiles_to_odtc_protocol(self): + backend = self._make_backend() + protocol = Protocol( + stages=[Stage(steps=[Step(95.0, 30.0)], repeats=1)], + name="TestPCR", + ) + params = ODTCThermocyclerBackend.RunProtocolParams(variant=96, fluid_quantity=1) + odtc = backend._resolve_odtc_protocol(protocol, params) + self.assertIsInstance(odtc, ODTCProtocol) + self.assertEqual(len(odtc.stages), 1) + + def test_odtc_protocol_used_directly(self): + backend = self._make_backend() + odtc = ODTCProtocol( + stages=[Stage(steps=[Step(95.0, 30.0)], repeats=1)], + name="DirectPCR", + variant=96, plate_type=0, fluid_quantity=1, post_heating=True, + start_block_temperature=25.0, start_lid_temperature=110.0, + pid_set=[ODTCPID(number=1)], + ) + params = ODTCThermocyclerBackend.RunProtocolParams() + resolved = backend._resolve_odtc_protocol(odtc, params) + self.assertIs(resolved, odtc) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/inheco/odtc/tests/xml_tests.py b/pylabrobot/inheco/odtc/tests/xml_tests.py new file mode 100644 index 00000000000..4ff23efac92 --- /dev/null +++ b/pylabrobot/inheco/odtc/tests/xml_tests.py @@ -0,0 +1,267 @@ +"""Tests for ODTC XML parsing and serialization — roundtrip fidelity.""" + +import unittest +import xml.etree.ElementTree as ET + +from pylabrobot.capabilities.thermocycling.standard import Overshoot, Ramp, Stage, Step +from pylabrobot.inheco.odtc.model import ODTCPID, ODTCMethodSet, ODTCProtocol +from pylabrobot.inheco.odtc.xml import ( + build_stages_from_parsed_steps, + method_set_to_xml, + parse_method_set, + parse_method_set_file, + _flatten_stages_for_xml, + _parse_step_element, + _ParsedStep, +) + + +def _flat_loop_xml() -> str: + return """ + + false + + 96000000 + false + 25 + 110 + 14.495100.1000.1001110 + 22.255100.1000.1121110 + 6080250100101010070 + +""" + + +def _nested_loop_xml() -> str: + return """ + + false + + 96000000 + false + 25 + 110 + 14.495100.1000.1001110 + 22.255100.1000.1001110 + 34.472100.1000.1001110 + 44.495100.1000.1241110 + 52.250200.1000.11291110 + 6080250100101010070 + +""" + + +def _overshoot_xml() -> str: + return """ + + false + + 96000001 + true + 25 + 110 + 14.495304.45.202.2001110 + 6080250100101010070 + +""" + + +class TestParsedStepToStep(unittest.TestCase): + def test_no_overshoot(self): + ps = _ParsedStep( + number=1, slope=4.4, plateau_temperature=95.0, plateau_time=30.0, + overshoot_slope1=4.4, overshoot_temperature=0.0, overshoot_time=0.0, + overshoot_slope2=2.2, goto_number=0, loop_number=0, lid_temp=110.0, + ) + step = ps.to_step() + self.assertEqual(step.temperature, 95.0) + self.assertEqual(step.hold_seconds, 30.0) + self.assertAlmostEqual(step.ramp.rate, 4.4) + self.assertIsNone(step.ramp.overshoot) + self.assertAlmostEqual(step.lid_temperature, 110.0) + + def test_with_overshoot(self): + ps = _ParsedStep( + number=1, slope=4.4, plateau_temperature=95.0, plateau_time=30.0, + overshoot_slope1=4.4, overshoot_temperature=5.2, overshoot_time=0.0, + overshoot_slope2=2.2, goto_number=0, loop_number=0, lid_temp=110.0, + ) + step = ps.to_step() + self.assertIsNotNone(step.ramp.overshoot) + self.assertAlmostEqual(step.ramp.overshoot.target_temp, 5.2) + self.assertAlmostEqual(step.ramp.overshoot.return_rate, 2.2) + + +class TestBuildStagesFromParsedSteps(unittest.TestCase): + def test_flat_no_loop(self): + steps = [ + _ParsedStep(1, 4.4, 95.0, 30.0, 4.4, 0, 0, 2.2, 0, 0, 110.0), + _ParsedStep(2, 2.2, 55.0, 30.0, 2.2, 0, 0, 2.2, 0, 0, 110.0), + ] + stages = build_stages_from_parsed_steps(steps) + self.assertEqual(len(stages), 1) + self.assertEqual(stages[0].repeats, 1) + self.assertEqual(len(stages[0].steps), 2) + self.assertEqual(stages[0].inner_stages, []) + + def test_flat_loop(self): + # step 2 has goto=1, loop=2 → 3 total repeats + steps = [ + _ParsedStep(1, 4.4, 95.0, 10.0, 4.4, 0, 0, 2.2, 0, 0, 110.0), + _ParsedStep(2, 2.2, 55.0, 10.0, 2.2, 0, 0, 2.2, 1, 2, 110.0), + ] + stages = build_stages_from_parsed_steps(steps) + self.assertEqual(len(stages), 1) + self.assertEqual(stages[0].repeats, 3) + self.assertEqual(len(stages[0].steps), 2) + + def test_nested_loop(self): + # Steps 1-5: inner 2-4 x 5, outer 1-5 x 30 + steps = [ + _ParsedStep(1, 4.4, 95.0, 10.0, 4.4, 0, 0, 2.2, 0, 0, 110.0), + _ParsedStep(2, 2.2, 55.0, 10.0, 2.2, 0, 0, 2.2, 0, 0, 110.0), + _ParsedStep(3, 4.4, 72.0, 10.0, 4.4, 0, 0, 2.2, 0, 0, 110.0), + _ParsedStep(4, 4.4, 95.0, 10.0, 4.4, 0, 0, 2.2, 2, 4, 110.0), + _ParsedStep(5, 2.2, 50.0, 20.0, 2.2, 0, 0, 2.2, 1, 29, 110.0), + ] + stages = build_stages_from_parsed_steps(steps) + outer = next((s for s in stages if s.repeats == 30), None) + self.assertIsNotNone(outer) + self.assertEqual(len(outer.inner_stages), 1) + self.assertEqual(outer.inner_stages[0].repeats, 5) + + +class TestFlattenStagesForXml(unittest.TestCase): + def test_flat_no_repeat(self): + stage = Stage( + steps=[Step(95.0, 30.0), Step(55.0, 30.0)], + repeats=1, + ) + flat = _flatten_stages_for_xml([stage]) + self.assertEqual(len(flat), 2) + self.assertEqual(flat[0][1], 1) # number + self.assertEqual(flat[0][2], 0) # goto + self.assertEqual(flat[1][1], 2) + self.assertEqual(flat[1][2], 0) + + def test_stage_with_repeats(self): + stage = Stage( + steps=[Step(95.0, 10.0), Step(55.0, 10.0)], + repeats=35, + ) + flat = _flatten_stages_for_xml([stage]) + self.assertEqual(len(flat), 2) + # last step should have goto=1, loop=34 + last_step, num, goto, loop = flat[-1] + self.assertEqual(goto, 1) + self.assertEqual(loop, 34) + + def test_sequential_stages(self): + s1 = Stage(steps=[Step(95.0, 10.0)], repeats=1) + s2 = Stage(steps=[Step(55.0, 30.0), Step(72.0, 60.0)], repeats=35) + flat = _flatten_stages_for_xml([s1, s2]) + self.assertEqual(len(flat), 3) + # s1 step: number=1, no goto + self.assertEqual(flat[0][1], 1) + self.assertEqual(flat[0][2], 0) + # s2 last step: number=3, goto=2 + _, num, goto, loop = flat[2] + self.assertEqual(num, 3) + self.assertEqual(goto, 2) + self.assertEqual(loop, 34) + + +class TestXmlRoundtrip(unittest.TestCase): + def test_flat_loop_roundtrip(self): + ms = parse_method_set(_flat_loop_xml()) + self.assertEqual(len(ms.methods), 1) + odtc = ms.methods[0] + self.assertEqual(odtc.name, "FlatLoop") + # Should produce 1 stage with 2 steps and repeats=3 + self.assertEqual(len(odtc.stages), 1) + self.assertEqual(odtc.stages[0].repeats, 3) + self.assertEqual(len(odtc.stages[0].steps), 2) + # Roundtrip + xml_out = method_set_to_xml(ODTCMethodSet(methods=[odtc])) + ms2 = parse_method_set(xml_out) + odtc2 = ms2.methods[0] + self.assertEqual(len(odtc2.stages), 1) + self.assertEqual(odtc2.stages[0].repeats, 3) + + def test_nested_loop_roundtrip(self): + ms = parse_method_set(_nested_loop_xml()) + odtc = ms.methods[0] + # Parse re-serializes and parses again; stage structure preserved + xml_out = method_set_to_xml(ODTCMethodSet(methods=[odtc])) + ms2 = parse_method_set(xml_out) + odtc2 = ms2.methods[0] + # Find outer stage with repeats=30 and inner stage with repeats=5 + outer = next((s for s in odtc2.stages if s.repeats == 30), None) + self.assertIsNotNone(outer, "Expected stage with repeats=30") + self.assertEqual(len(outer.inner_stages), 1) + self.assertEqual(outer.inner_stages[0].repeats, 5) + + def test_overshoot_roundtrip(self): + ms = parse_method_set(_overshoot_xml()) + odtc = ms.methods[0] + step = odtc.stages[0].steps[0] + self.assertIsNotNone(step.ramp.overshoot) + self.assertAlmostEqual(step.ramp.overshoot.target_temp, 5.2, places=1) + # Roundtrip + xml_out = method_set_to_xml(ODTCMethodSet(methods=[odtc])) + ms2 = parse_method_set(xml_out) + step2 = ms2.methods[0].stages[0].steps[0] + self.assertIsNotNone(step2.ramp.overshoot) + self.assertAlmostEqual(step2.ramp.overshoot.target_temp, 5.2, places=1) + self.assertAlmostEqual(step2.ramp.overshoot.return_rate, 2.2, places=1) + + def test_premethod_roundtrip(self): + xml = """ +false + + 37 + 110 + +""" + ms = parse_method_set(xml) + self.assertEqual(len(ms.premethods), 1) + pm = ms.premethods[0] + self.assertEqual(pm.name, "PreHeat37") + self.assertAlmostEqual(pm.target_block_temperature, 37.0) + # Roundtrip + xml_out = method_set_to_xml(ODTCMethodSet(premethods=[pm])) + ms2 = parse_method_set(xml_out) + pm2 = ms2.premethods[0] + self.assertEqual(pm2.name, "PreHeat37") + self.assertAlmostEqual(pm2.target_block_temperature, 37.0) + + def test_step_temperatures_preserved(self): + xml = _flat_loop_xml() + ms = parse_method_set(xml) + steps = ms.methods[0].stages[0].steps + self.assertAlmostEqual(steps[0].temperature, 95.0) + self.assertAlmostEqual(steps[1].temperature, 55.0) + self.assertAlmostEqual(steps[0].ramp.rate, 4.4) + self.assertAlmostEqual(steps[1].ramp.rate, 2.2) + self.assertAlmostEqual(steps[0].lid_temperature, 110.0) + + def test_pid_set_preserved(self): + ms = parse_method_set(_flat_loop_xml()) + odtc = ms.methods[0] + self.assertEqual(len(odtc.pid_set), 1) + self.assertEqual(odtc.pid_set[0].number, 1) + self.assertEqual(odtc.pid_set[0].p_heating, 60.0) + + def test_method_metadata_preserved(self): + ms = parse_method_set(_flat_loop_xml()) + odtc = ms.methods[0] + self.assertEqual(odtc.creator, "test") + self.assertEqual(odtc.datetime, "2025-01-01T00:00:00") + self.assertFalse(odtc.post_heating) + self.assertEqual(odtc.variant, 96) + self.assertEqual(odtc.plate_type, 0) + self.assertEqual(odtc.fluid_quantity, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/inheco/odtc/xml.py b/pylabrobot/inheco/odtc/xml.py new file mode 100644 index 00000000000..9fa9305971c --- /dev/null +++ b/pylabrobot/inheco/odtc/xml.py @@ -0,0 +1,625 @@ +"""ODTC XML serialization and parsing. + +Key differences from the original: +- No ODTCStep subclass: steps parse to/from standard Step(Ramp, lid_temperature) +- Stage tree (Stage.inner_stages) is the canonical representation +- Step Number/GotoNumber/LoopNumber are computed from stage position at serialize time +- PIDNumber is always serialized as 1 (or from StepParams.backend_params if set) +- Loop analysis (_analyze_loop_structure, _build_stages_from_parsed_steps) lives here + and is imported by protocol.py for duration/progress computation +""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET +from dataclasses import dataclass, fields +from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, Union, cast, get_args, get_origin, get_type_hints + +from pylabrobot.capabilities.thermocycling.standard import Overshoot, Protocol, Ramp, Stage, Step + +from .model import ( + ODTCPID, + ODTCMethodSet, + ODTCProtocol, + ODTCSensorValues, + XMLField, + XMLFieldType, + _variant_to_device_code, + normalize_variant, +) + +T = TypeVar("T") + + +# ============================================================================= +# Generic dataclass XML helpers (for ODTCPID, ODTCSensorValues) +# ============================================================================= + + +def _get_xml_meta(f) -> XMLField: + if "xml" in f.metadata: + return cast(XMLField, f.metadata["xml"]) + return XMLField(tag=None, field_type=XMLFieldType.ELEMENT) + + +def _get_tag(f, meta: XMLField) -> str: + return meta.tag if meta.tag else f.name + + +def _get_inner_type(type_hint) -> Optional[Type[Any]]: + origin = get_origin(type_hint) + args = get_args(type_hint) + if origin is list and args: + return cast(Type[Any], args[0]) + if origin is Union and type(None) in args: + result = next((a for a in args if a is not type(None)), None) + return cast(Type[Any], result) if result is not None else None + return None + + +def _is_dataclass_type(tp: Type) -> bool: + return hasattr(tp, "__dataclass_fields__") + + +def _parse_value(text: Optional[str], field_type: Type, scale: float = 1.0) -> Any: + if text is None: + return None + text = text.strip() + if field_type is bool: + return text.lower() == "true" + if field_type is int: + return int(float(text) * scale) + if field_type is float: + return float(text) * scale + return text + + +def _format_value(value: Any, scale: float = 1.0) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, float): + scaled = value / scale if scale != 1.0 else value + if scaled == int(scaled): + return str(int(scaled)) + return str(scaled) + if isinstance(value, int): + return str(int(value / scale) if scale != 1.0 else value) + return str(value) + + +def from_xml(elem: ET.Element, cls: Type[T]) -> T: + """Deserialize an XML element to a dataclass (for ODTCPID, ODTCSensorValues).""" + if not _is_dataclass_type(cls): + raise TypeError(f"{cls} is not a dataclass") + kwargs: Dict[str, Any] = {} + type_hints = get_type_hints(cls) + for f in fields(cls): # type: ignore[arg-type] + meta = _get_xml_meta(f) + tag = _get_tag(f, meta) + field_type = type_hints.get(f.name, f.type) + inner_type = _get_inner_type(field_type) + actual_type = inner_type if inner_type and get_origin(field_type) is Union else field_type + if meta.field_type == XMLFieldType.ATTRIBUTE: + raw = elem.attrib.get(tag) + if raw is not None: + kwargs[f.name] = _parse_value(raw, actual_type, meta.scale) + elif meta.default is not None: + kwargs[f.name] = meta.default + elif meta.field_type == XMLFieldType.ELEMENT: + child = elem.find(tag) + if child is not None and child.text: + kwargs[f.name] = _parse_value(child.text, actual_type, meta.scale) + elif meta.default is not None: + kwargs[f.name] = meta.default + elif meta.field_type == XMLFieldType.CHILD_LIST: + list_type = _get_inner_type(field_type) + if list_type and _is_dataclass_type(list_type): + children = elem.findall(tag) + kwargs[f.name] = [from_xml(c, list_type) for c in children] + else: + kwargs[f.name] = [] + return cls(**kwargs) + + +def to_xml(obj: Any, tag_name: Optional[str] = None, parent: Optional[ET.Element] = None) -> ET.Element: + """Serialize a dataclass to XML (for ODTCPID, ODTCSensorValues).""" + if not _is_dataclass_type(type(obj)): + raise TypeError(f"{type(obj)} is not a dataclass") + if tag_name is None: + tag_name = type(obj).__name__ + elem = ET.SubElement(parent, tag_name) if parent is not None else ET.Element(tag_name) + for f in fields(type(obj)): + meta = _get_xml_meta(f) + tag = _get_tag(f, meta) + value = getattr(obj, f.name) + if value is None: + continue + if meta.field_type == XMLFieldType.ATTRIBUTE: + elem.set(tag, _format_value(value, meta.scale)) + elif meta.field_type == XMLFieldType.ELEMENT: + child = ET.SubElement(elem, tag) + child.text = _format_value(value, meta.scale) + elif meta.field_type == XMLFieldType.CHILD_LIST: + for item in value: + if _is_dataclass_type(type(item)): + to_xml(item, tag, elem) + return elem + + +# ============================================================================= +# Step XML parsing — flat elements → Step(Ramp, lid_temperature) +# ============================================================================= + + +@dataclass +class _ParsedStep: + """Intermediate representation of a raw XML element. + + Carries all XML fields including goto/loop/number used for stage + reconstruction. Converted to Step after stage structure is resolved. + """ + + number: int + slope: float + plateau_temperature: float + plateau_time: float + overshoot_slope1: float + overshoot_temperature: float + overshoot_time: float + overshoot_slope2: float + goto_number: int + loop_number: int + lid_temp: float + pid_number: int = 1 + + def to_step(self) -> Step: + """Convert to a standard Step, encoding overshoot/ramp/lid.""" + overshoot = ( + Overshoot( + target_temp=self.overshoot_temperature, + hold_seconds=self.overshoot_time, + return_rate=self.overshoot_slope2, + ) + if self.overshoot_temperature > 0 + else None + ) + ramp = Ramp(rate=self.slope, overshoot=overshoot) + return Step( + temperature=self.plateau_temperature, + hold_seconds=self.plateau_time, + ramp=ramp, + lid_temperature=self.lid_temp, + ) + + +def _read_opt_elem(elem: ET.Element, tag: str, default: Any = None, parse_float: bool = False) -> Any: + child = elem.find(tag) + if child is None or child.text is None: + return default + text = child.text.strip() + if not text: + return default + if parse_float: + return float(text) + return text + + +def _parse_step_element(elem: ET.Element) -> _ParsedStep: + """Parse a single XML element to a _ParsedStep.""" + def f(tag: str, default: float = 0.0) -> float: + return float(_read_opt_elem(elem, tag, default, parse_float=True)) + + def i(tag: str, default: int = 0) -> int: + return int(float(_read_opt_elem(elem, tag, default) or default)) + + return _ParsedStep( + number=i("Number"), + slope=f("Slope"), + plateau_temperature=f("PlateauTemperature"), + plateau_time=f("PlateauTime"), + overshoot_slope1=f("OverShootSlope1"), + overshoot_temperature=f("OverShootTemperature"), + overshoot_time=f("OverShootTime"), + overshoot_slope2=f("OverShootSlope2"), + goto_number=i("GotoNumber"), + loop_number=i("LoopNumber"), + lid_temp=f("LidTemp", default=110.0), + pid_number=i("PIDNumber", default=1), + ) + + +# ============================================================================= +# Loop analysis — rebuild Stage tree from flat parsed steps +# (also imported by odtc_protocol.py for duration/progress computation) +# ============================================================================= + + +def analyze_loop_structure(parsed_steps: List[_ParsedStep]) -> List[Tuple[int, int, int]]: + """Identify loops from goto/loop numbers. + + Returns list of (start_step_number, end_step_number, total_repeats) sorted by end position. + total_repeats = LoopNumber + 1 (LoopNumber is "additional" iterations per firmware). + """ + loops = [] + for s in parsed_steps: + if s.goto_number > 0: + loops.append((s.goto_number, s.number, s.loop_number + 1)) + return sorted(loops, key=lambda x: x[1]) + + +def _build_one_stage_for_range( + steps_by_num: Dict[int, _ParsedStep], + loops: List[Tuple[int, int, int]], + start: int, + end: int, + repeats: int, +) -> Stage: + """Recursively build a Stage for the step range [start, end] with given repeats.""" + inner_loops = [ + (s, e, r) for (s, e, r) in loops if start <= s and e <= end and (start, end) != (s, e) + ] + inner_loops_sorted = sorted(inner_loops, key=lambda x: x[0]) + + if not inner_loops_sorted: + steps = [steps_by_num[n].to_step() for n in range(start, end + 1) if n in steps_by_num] + return Stage(steps=steps, repeats=repeats, inner_stages=[]) + + # Partition range into step segments and inner loops + step_nums_in_range = set(range(start, end + 1)) + for is_, ie, _ in inner_loops_sorted: + for n in range(is_, ie + 1): + step_nums_in_range.discard(n) + + step_groups: List[List[int]] = [] + pos = start + for is_, ie, ir in inner_loops_sorted: + group = [n for n in range(pos, is_) if n in steps_by_num] + if group: + step_groups.append(group) + pos = ie + 1 + if pos <= end: + group = [n for n in range(pos, end + 1) if n in steps_by_num] + if group: + step_groups.append(group) + + steps_list: List[Step] = [] + inner_stages_list: List[Stage] = [] + for gi, (is_, ie, ir) in enumerate(inner_loops_sorted): + if gi < len(step_groups): + steps_list.extend(steps_by_num[n].to_step() for n in step_groups[gi]) + inner_stages_list.append(_build_one_stage_for_range(steps_by_num, loops, is_, ie, ir)) + if len(step_groups) > len(inner_loops_sorted): + steps_list.extend(steps_by_num[n].to_step() for n in step_groups[len(inner_loops_sorted)]) + + return Stage(steps=steps_list, repeats=repeats, inner_stages=inner_stages_list) + + +def build_stages_from_parsed_steps(parsed_steps: List[_ParsedStep]) -> List[Stage]: + """Build a Stage tree from flat parsed steps using goto/loop structure.""" + if not parsed_steps: + return [] + steps_by_num = {s.number: s for s in parsed_steps} + loops = analyze_loop_structure(parsed_steps) + max_step = max(s.number for s in parsed_steps) + + if not loops: + flat = [steps_by_num[n].to_step() for n in range(1, max_step + 1) if n in steps_by_num] + return [Stage(steps=flat, repeats=1, inner_stages=[])] + + def contains(outer: Tuple[int, int, int], inner: Tuple[int, int, int]) -> bool: + (s, e, _), (s2, e2, _) = outer, inner + return s <= s2 and e2 <= e and (s, e) != (s2, e2) + + top_level = [L for L in loops if not any(contains(M, L) for M in loops if M != L)] + top_level.sort(key=lambda x: (x[0], x[1])) + step_nums_in_top_level: set = set() + for s, e, _ in top_level: + for n in range(s, e + 1): + step_nums_in_top_level.add(n) + + stages: List[Stage] = [] + i = 1 + while i <= max_step: + if i not in steps_by_num: + i += 1 + continue + if i not in step_nums_in_top_level: + flat_steps: List[Step] = [] + while i <= max_step and i in steps_by_num and i not in step_nums_in_top_level: + flat_steps.append(steps_by_num[i].to_step()) + i += 1 + if flat_steps: + stages.append(Stage(steps=flat_steps, repeats=1, inner_stages=[])) + continue + for start, end, repeats in top_level: + if start <= i <= end: + stages.append(_build_one_stage_for_range(steps_by_num, loops, start, end, repeats)) + i = end + 1 + break + else: + i += 1 + + return stages + + +# ============================================================================= +# Stage tree → flat XML steps (serialization) +# ============================================================================= + + +def _flatten_stages_for_xml( + stages: List[Stage], +) -> List[Tuple[Step, int, int, int]]: + """Walk the stage tree and produce (step, number, goto_number, loop_number) tuples. + + Step numbers are assigned sequentially starting from 1. + GotoNumber/LoopNumber are derived from Stage.repeats and Stage.inner_stages. + """ + result: List[Tuple[Step, int, int, int]] = [] + _flatten_stage_list(stages, result, [1]) + return result + + +def _flatten_stage_list( + stages: List[Stage], + result: List[Tuple[Step, int, int, int]], + counter: List[int], +) -> None: + for stage in stages: + _flatten_one_stage(stage, result, counter) + + +def _flatten_one_stage( + stage: Stage, + result: List[Tuple[Step, int, int, int]], + counter: List[int], +) -> None: + """Recursively flatten one Stage into (step, number, goto, loop) tuples.""" + first_num = counter[0] + inner_stages = stage.inner_stages or [] + steps = stage.steps + + # Interleave steps and inner_stages (steps[0], inner_stages[0], steps[1], ...) + for gi, inner in enumerate(inner_stages): + if gi < len(steps): + step = steps[gi] + result.append((step, counter[0], 0, 0)) + counter[0] += 1 + _flatten_one_stage(inner, result, counter) + if len(steps) > len(inner_stages): + for step in steps[len(inner_stages):]: + result.append((step, counter[0], 0, 0)) + counter[0] += 1 + elif not inner_stages: + for step in steps: + result.append((step, counter[0], 0, 0)) + counter[0] += 1 + + # Set goto/loop on last produced item for this stage if repeats > 1 + if stage.repeats > 1 and result: + last_idx = _find_last_in_range(result, first_num, counter[0] - 1) + if last_idx >= 0: + step, num, _, _ = result[last_idx] + result[last_idx] = (step, num, first_num, stage.repeats - 1) + + +def _find_last_in_range( + result: List[Tuple[Step, int, int, int]], + first_num: int, + last_num: int, +) -> int: + """Find the index of the last entry with step number <= last_num and >= first_num.""" + for i in range(len(result) - 1, -1, -1): + _, num, _, _ = result[i] + if first_num <= num <= last_num: + return i + return -1 + + +def _step_to_xml_element( + step: Step, + number: int, + goto_number: int, + loop_number: int, + parent: ET.Element, + pid_number: int = 1, +) -> None: + """Write a single element from a Step and its positional metadata.""" + elem = ET.SubElement(parent, "Step") + ramp = step.ramp + slope = ramp.rate if ramp.rate != float("inf") else 4.4 + os_temp = ramp.overshoot.target_temp if ramp.overshoot else 0.0 + os_time = ramp.overshoot.hold_seconds if ramp.overshoot else 0.0 + os2 = ramp.overshoot.return_rate if ramp.overshoot else 2.2 + os1 = slope # OverShootSlope1 == Slope (approach rate equals ramp rate) + lid_temp = step.lid_temperature if step.lid_temperature is not None else 110.0 + + # Check for per-step backend_params PIDNumber override + from pylabrobot.capabilities.capability import BackendParams # noqa: F401 (lazy import) + try: + # Avoid circular import; just try attribute access + bp = step.backend_params + if bp is not None and hasattr(bp, "pid_number"): + pid_number = bp.pid_number # type: ignore[union-attr] + except Exception: + pass + + ET.SubElement(elem, "Number").text = str(number) + ET.SubElement(elem, "Slope").text = _format_value(slope) + ET.SubElement(elem, "PlateauTemperature").text = _format_value(step.temperature) + ET.SubElement(elem, "PlateauTime").text = _format_value(step.hold_seconds) + ET.SubElement(elem, "OverShootSlope1").text = _format_value(os1) + ET.SubElement(elem, "OverShootTemperature").text = _format_value(os_temp) + ET.SubElement(elem, "OverShootTime").text = _format_value(os_time) + ET.SubElement(elem, "OverShootSlope2").text = _format_value(os2) + ET.SubElement(elem, "GotoNumber").text = str(goto_number) + ET.SubElement(elem, "LoopNumber").text = str(loop_number) + ET.SubElement(elem, "PIDNumber").text = str(pid_number) + ET.SubElement(elem, "LidTemp").text = _format_value(lid_temp) + + +# ============================================================================= +# ODTCProtocol ↔ XML +# ============================================================================= + + +def _parse_method_element_to_odtc_protocol(elem: ET.Element) -> ODTCProtocol: + """Parse a element into ODTCProtocol with Stage tree.""" + name = elem.attrib["methodName"] + creator = elem.attrib.get("creator") + description = elem.attrib.get("description") + datetime_ = elem.attrib["dateTime"] + variant = normalize_variant(int(float(_read_opt_elem(elem, "Variant") or 960000))) + plate_type = int(float(_read_opt_elem(elem, "PlateType") or 0)) + fluid_quantity = int(float(_read_opt_elem(elem, "FluidQuantity") or 0)) + post_heating = (_read_opt_elem(elem, "PostHeating") or "false").lower() == "true" + start_block_temperature = float(_read_opt_elem(elem, "StartBlockTemperature") or 0.0) + start_lid_temperature = float(_read_opt_elem(elem, "StartLidTemperature") or 0.0) + + parsed_steps = [_parse_step_element(step_elem) for step_elem in elem.findall("Step")] + stages = build_stages_from_parsed_steps(parsed_steps) + + pid_set: List[ODTCPID] = [] + pid_set_elem = elem.find("PIDSet") + if pid_set_elem is not None: + pid_set = [from_xml(pid_elem, ODTCPID) for pid_elem in pid_set_elem.findall("PID")] + if not pid_set: + pid_set = [ODTCPID(number=1)] + + return ODTCProtocol( + kind="method", + stages=stages, + name=name, + is_scratch=False, + creator=creator, + description=description, + datetime=datetime_, + variant=variant, + plate_type=plate_type, + fluid_quantity=fluid_quantity, + post_heating=post_heating, + start_block_temperature=start_block_temperature, + start_lid_temperature=start_lid_temperature, + pid_set=pid_set, + ) + + +def _parse_premethod_element_to_odtc_protocol(elem: ET.Element) -> ODTCProtocol: + """Parse a element into ODTCProtocol (kind='premethod').""" + name = elem.attrib.get("methodName") or "" + creator = elem.attrib.get("creator") + description = elem.attrib.get("description") + datetime_ = elem.attrib.get("dateTime") + target_block_temperature = float(_read_opt_elem(elem, "TargetBlockTemperature") or 0.0) + target_lid_temperature = float(_read_opt_elem(elem, "TargetLidTemp") or 0.0) + return ODTCProtocol( + variant=96, + plate_type=0, + fluid_quantity=0, + post_heating=False, + start_block_temperature=0.0, + start_lid_temperature=0.0, + stages=[], + pid_set=[ODTCPID(number=1)], + kind="premethod", + name=name, + is_scratch=False, + creator=creator, + description=description, + datetime=datetime_, + target_block_temperature=target_block_temperature, + target_lid_temperature=target_lid_temperature, + ) + + +def _odtc_protocol_to_method_xml(odtc_protocol: ODTCProtocol, parent: ET.Element) -> ET.Element: + """Serialize ODTCProtocol (kind='method') to XML element.""" + if odtc_protocol.kind != "method": + raise ValueError("ODTCProtocol must have kind='method' to serialize as Method") + + elem = ET.SubElement(parent, "Method") + elem.set("methodName", odtc_protocol.name) + if odtc_protocol.creator: + elem.set("creator", odtc_protocol.creator) + if odtc_protocol.description: + elem.set("description", odtc_protocol.description) + if odtc_protocol.datetime: + elem.set("dateTime", odtc_protocol.datetime) + + ET.SubElement(elem, "Variant").text = str(_variant_to_device_code(odtc_protocol.variant)) + ET.SubElement(elem, "PlateType").text = str(odtc_protocol.plate_type) + ET.SubElement(elem, "FluidQuantity").text = str(odtc_protocol.fluid_quantity) + ET.SubElement(elem, "PostHeating").text = "true" if odtc_protocol.post_heating else "false" + ET.SubElement(elem, "StartBlockTemperature").text = _format_value(odtc_protocol.start_block_temperature) + ET.SubElement(elem, "StartLidTemperature").text = _format_value(odtc_protocol.start_lid_temperature) + + flat = _flatten_stages_for_xml(odtc_protocol.stages) + for step, number, goto, loop in flat: + _step_to_xml_element(step, number, goto, loop, elem) + + if odtc_protocol.pid_set: + pid_set_elem = ET.SubElement(elem, "PIDSet") + for pid in odtc_protocol.pid_set: + to_xml(pid, "PID", pid_set_elem) + + return elem + + +def _odtc_protocol_to_premethod_xml(odtc_protocol: ODTCProtocol, parent: ET.Element) -> ET.Element: + """Serialize ODTCProtocol (kind='premethod') to XML element.""" + if odtc_protocol.kind != "premethod": + raise ValueError("ODTCProtocol must have kind='premethod' to serialize as PreMethod") + elem = ET.SubElement(parent, "PreMethod") + elem.set("methodName", odtc_protocol.name) + if odtc_protocol.creator: + elem.set("creator", odtc_protocol.creator) + if odtc_protocol.description: + elem.set("description", odtc_protocol.description) + if odtc_protocol.datetime: + elem.set("dateTime", odtc_protocol.datetime) + ET.SubElement(elem, "TargetBlockTemperature").text = _format_value(odtc_protocol.target_block_temperature) + ET.SubElement(elem, "TargetLidTemp").text = _format_value(odtc_protocol.target_lid_temperature) + return elem + + +# ============================================================================= +# Convenience functions +# ============================================================================= + + +def parse_method_set_from_root(root: ET.Element) -> ODTCMethodSet: + """Parse a MethodSet XML root element into ODTCMethodSet.""" + delete_elem = root.find("DeleteAllMethods") + delete_all = delete_elem is not None and (delete_elem.text or "").lower() == "true" + premethods = [_parse_premethod_element_to_odtc_protocol(pm) for pm in root.findall("PreMethod")] + methods = [_parse_method_element_to_odtc_protocol(m) for m in root.findall("Method")] + return ODTCMethodSet(delete_all_methods=delete_all, premethods=premethods, methods=methods) + + +def parse_method_set(xml_str: str) -> ODTCMethodSet: + """Parse a MethodSet XML string.""" + root = ET.fromstring(xml_str) + return parse_method_set_from_root(root) + + +def parse_method_set_file(filepath: str) -> ODTCMethodSet: + """Parse a MethodSet XML file.""" + tree = ET.parse(filepath) + return parse_method_set_from_root(tree.getroot()) + + +def method_set_to_xml(method_set: ODTCMethodSet) -> str: + """Serialize a MethodSet to XML string.""" + root = ET.Element("MethodSet") + ET.SubElement(root, "DeleteAllMethods").text = "true" if method_set.delete_all_methods else "false" + for pm in method_set.premethods: + _odtc_protocol_to_premethod_xml(pm, root) + for m in method_set.methods: + _odtc_protocol_to_method_xml(m, root) + return ET.tostring(root, encoding="unicode", xml_declaration=True) + + +def parse_sensor_values(xml_str: str) -> ODTCSensorValues: + """Parse SensorValues XML string.""" + root = ET.fromstring(xml_str) + return from_xml(root, ODTCSensorValues) diff --git a/pylabrobot/inheco/scila/inheco_sila_interface.py b/pylabrobot/inheco/scila/inheco_sila_interface.py index db663ed57cf..6fd2978f4e4 100644 --- a/pylabrobot/inheco/scila/inheco_sila_interface.py +++ b/pylabrobot/inheco/scila/inheco_sila_interface.py @@ -10,9 +10,14 @@ import urllib.request import xml.etree.ElementTree as ET from dataclasses import dataclass -from typing import Any, Optional, Tuple +from enum import Enum +from typing import Any, Dict, List, Optional, Tuple -from pylabrobot.inheco.scila.soap import XSI, soap_decode, soap_encode +from pylabrobot.inheco.scila.soap import ( + XSI, + soap_decode, + soap_encode, +) SOAP_RESPONSE_ResponseEventResponse = """ """ +SOAP_RESPONSE_DataEventResponse = """ + + + + 1 + Success + PT0S + 0 + + + +""" + + +SOAP_RESPONSE_ErrorEventResponse = """ + + + + 1 + Success + PT0.0005967S + 0 + + + +""" + + def _get_local_ip(machine_ip: str) -> str: + from pylabrobot.io.sila.discovery import _get_link_local_interfaces + + # Link-local (169.254.x.x): the UDP routing trick picks the wrong interface + # on multi-homed hosts. Enumerate local link-local addresses instead. + if machine_ip.startswith("169.254."): + interfaces = _get_link_local_interfaces() + if interfaces: + return interfaces[0] + raise RuntimeError(f"No link-local interface found for device at {machine_ip}") + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: - # Doesn't actually connect, just determines the route s.connect((machine_ip, 1)) local_ip: str = s.getsockname()[0] # type: ignore if local_ip is None or local_ip.startswith("127."): @@ -57,12 +101,33 @@ def _get_local_ip(machine_ip: str) -> str: return local_ip +class SiLAState(str, Enum): + """SiLA device states per specification.""" + + STARTUP = "startup" + STANDBY = "standby" + INITIALIZING = "initializing" + IDLE = "idle" + BUSY = "busy" + PAUSED = "paused" + ERRORHANDLING = "errorHandling" + INERROR = "inError" + + class SiLAError(RuntimeError): def __init__(self, code: int, message: str, command: str, details: Optional[dict] = None): self.code = code self.message = message self.command = command self.details = details or {} + super().__init__(f"Command {command} failed with code {code}: '{message}'") + + +class SiLATimeoutError(SiLAError): + """Command timed out: lifetime_of_execution exceeded or ResponseEvent not received.""" + + def __init__(self, message: str, command: str = ""): + super().__init__(code=0, message=message, command=command) class InhecoSiLAInterface: @@ -90,11 +155,14 @@ def __init__( self._machine_ip = machine_ip self._logger = logger or logging.getLogger(__name__) - # single "in-flight token" - self._making_request = asyncio.Lock() + # pending commands by request_id (supports multiple in-flight) + self._pending_by_id: Dict[int, InhecoSiLAInterface._SiLACommand] = {} - # pending command information - self._pending: Optional[InhecoSiLAInterface._SiLACommand] = None + # lock state + self._lock_id: Optional[str] = None + + # DataEvent storage by request_id + self._data_events_by_request_id: Dict[int, List[Dict[str, Any]]] = {} # server plumbing self._loop: Optional[asyncio.AbstractEventLoop] = None @@ -150,12 +218,10 @@ def _do(self) -> None: fut = asyncio.run_coroutine_threadsafe(outer._on_http(req), outer._loop) try: resp_body = fut.result() - status = 200 - except Exception as e: - resp_body = f"Internal Server Error: {type(e).__name__}: {e}\n".encode() - status = 500 + except Exception: + resp_body = SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") - self.send_response(status) + self.send_response(200) self.send_header("Content-Type", "text/xml; charset=utf-8") self.send_header("Content-Length", str(len(resp_body))) self.end_headers() @@ -196,41 +262,120 @@ async def close(self) -> None: self._httpd = None self._server_task = None - async def _on_http(self, req: _HTTPRequest) -> bytes: - """ - Called on the asyncio loop for every incoming HTTP request. - If there's a pending command, try to match and resolve it. - """ - - cmd = self._pending - - if cmd is not None and not cmd.fut.done(): - response_event = soap_decode(req.body.decode("utf-8")) - if "ResponseEvent" in response_event: - request_id = response_event["ResponseEvent"].get("requestId") - if request_id != cmd.request_id: - self._logger.warning("Request ID does not match pending command.") - else: - return_value = response_event["ResponseEvent"].get("returnValue", {}) - return_code = return_value.get("returnCode") - if return_code != 3: # error - err_msg = return_value.get("message", "Unknown error").replace("\n", " ") - cmd.fut.set_exception( - RuntimeError(f"Command {cmd.name} failed with code {return_code}: '{err_msg}'") - ) - else: - response_data = response_event["ResponseEvent"].get("responseData", "") - root = ET.fromstring(response_data) - cmd.fut.set_result(root) + def _complete_pending( + self, + request_id: int, + result: Any = None, + exception: Optional[BaseException] = None, + ) -> None: + """Pop pending command by request_id and resolve its future.""" + pending = self._pending_by_id.pop(request_id, None) + if pending is None or pending.fut.done(): + return + if exception is not None: + pending.fut.set_exception(exception) else: - self._logger.warning("No pending command to match response to.") + pending.fut.set_result(result) - if "ResponseEvent" in req.body.decode("utf-8"): + async def _on_http(self, req: _HTTPRequest) -> bytes: + """Dispatch incoming device events to handler methods.""" + try: + decoded = soap_decode(req.body.decode("utf-8")) + for event_type, handler, response in ( + ("ResponseEvent", self._on_response_event, SOAP_RESPONSE_ResponseEventResponse), + ("StatusEvent", self._on_status_event, SOAP_RESPONSE_StatusEventResponse), + ("DataEvent", self._on_data_event, SOAP_RESPONSE_DataEventResponse), + ("ErrorEvent", self._on_error_event, SOAP_RESPONSE_ErrorEventResponse), + ): + if event_type in decoded: + handler(decoded[event_type]) + return response.encode("utf-8") + + self._logger.warning("Unknown event type received") + return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") + + except Exception as e: + self._logger.error(f"Error handling event: {e}\nRaw body: {req.body[:500]}") return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") - if "StatusEvent" in req.body.decode("utf-8"): - return SOAP_RESPONSE_StatusEventResponse.encode("utf-8") - self._logger.warning("Unknown event type received.") - return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") + + def _on_response_event(self, response_event: dict) -> None: + request_id = response_event.get("requestId") + if request_id is None: + self._logger.warning("ResponseEvent missing requestId") + return + + pending = self._pending_by_id.get(request_id) + if pending is None: + self._logger.warning(f"ResponseEvent for unknown requestId: {request_id}") + return + if pending.fut.done(): + self._logger.warning(f"ResponseEvent for already-completed requestId: {request_id}") + return + + return_value = response_event.get("returnValue", {}) + return_code = return_value.get("returnCode") + + if return_code == 3: + response_data = response_event.get("responseData", "") + if response_data and response_data.strip(): + try: + self._complete_pending(request_id, result=ET.fromstring(response_data)) + except ET.ParseError as e: + self._logger.error(f"Failed to parse ResponseEvent responseData: {e}") + self._complete_pending( + request_id, exception=RuntimeError(f"Failed to parse response data: {e}") + ) + else: + self._complete_pending(request_id, result=None) + else: + message = return_value.get("message", "") + err_msg = message.replace("\n", " ") if message else f"Unknown error (code {return_code})" + self._complete_pending( + request_id, + exception=SiLAError(return_code, err_msg, pending.name), + ) + + def _on_status_event(self, status_event: dict) -> None: + event_description = status_event.get("eventDescription", {}) + if isinstance(event_description, dict): + device_state = event_description.get("DeviceState") + elif isinstance(event_description, str) and "" in event_description: + root = ET.fromstring(event_description) + device_state = root.text if root.tag == "DeviceState" else root.findtext("DeviceState") + else: + self._logger.warning(f"StatusEvent with unparsable eventDescription: {event_description!r}") + return + if device_state: + self._logger.debug(f"StatusEvent device state: {device_state}") + + def _on_data_event(self, data_event: dict) -> None: + """Store DataEvent. Override in subclasses for additional processing.""" + request_id = data_event.get("requestId") + if request_id is None: + return + if request_id not in self._data_events_by_request_id: + self._data_events_by_request_id[request_id] = [] + self._data_events_by_request_id[request_id].append(data_event) + + def get_data_events(self, request_id: int) -> List[Dict[str, Any]]: + """Get collected DataEvents for a request_id.""" + return self._data_events_by_request_id.get(request_id, []) + + def _on_error_event(self, error_event: dict) -> None: + req_id = error_event.get("requestId") + return_value = error_event.get("returnValue", {}) + return_code = return_value.get("returnCode") + message = return_value.get("message", "") + + self._logger.error(f"ErrorEvent for requestId {req_id}: code {return_code}, message: {message}") + + err_msg = message.replace("\n", " ") if message else f"Error (code {return_code})" + if req_id is not None: + pending = self._pending_by_id.get(req_id) + if pending and not pending.fut.done(): + self._complete_pending( + req_id, exception=RuntimeError(f"Command {pending.name} error: '{err_msg}'") + ) def _get_return_code_and_message(self, command_name: str, response: Any) -> Tuple[int, str]: resp_level = response.get(f"{command_name}Response", {}) # first level @@ -240,21 +385,65 @@ def _get_return_code_and_message(self, command_name: str, response: Any) -> Tupl raise ValueError(f"returnCode not found in response for {command_name}") return return_code, result_level.get("message", "") + async def request_status(self) -> SiLAState: + """Query the device for its current state via GetStatus.""" + decoded = await self.send_command("GetStatus") + state_str = decoded.get("GetStatusResponse", {}).get("state", "") + try: + return SiLAState(state_str) + except ValueError: + for s in SiLAState: + if s.value.lower() == state_str.lower(): + return s + raise ValueError(f"Unknown device state: {state_str!r}") + + async def _handle_error_code( + self, return_code: int, message: str, command_name: str, request_id: int + ) -> None: + """Handle error return codes (called by send_command for codes other than 1, 2, 3).""" + if return_code == 4: + raise SiLAError(4, "Device is busy", command_name) + if return_code == 5: + raise SiLAError(5, "LockId mismatch", command_name) + if return_code == 6: + raise SiLAError(6, "Invalid or duplicate requestId", command_name) + if return_code == 9: + try: + state = await self.request_status() + except Exception: + state = None + msg = f"{message} (state: {state.value})" if state else message + if state == SiLAState.INERROR: + msg += ". Device requires a power cycle to recover." + raise SiLAError(9, msg, command_name) + if return_code == 11: + raise SiLAError(11, f"Invalid parameter: {message}", command_name) + if return_code == 12: + self._logger.warning(f"Command {command_name} finished with warning: {message}") + return + if return_code >= 1000: + self._handle_device_error_code(return_code, message, command_name) + return + raise SiLAError(return_code, message, command_name) + + def _handle_device_error_code(self, return_code: int, message: str, command_name: str) -> None: + """Handle device-specific return codes (1000+). Override in subclasses.""" + raise SiLAError(return_code, f"Device error: {message}", command_name) + async def setup(self) -> None: await self.start() def _make_request_id(self): return random.randint(1, 2**31 - 1) - async def send_command( - self, - command: str, - **kwargs, - ) -> Any: - if self._closed: - raise RuntimeError("Bridge is closed") + @property + def event_receiver_uri(self) -> str: + return f"http://{self._client_ip}:{self.bound_port}/" - request_id = self._make_request_id() + async def _post_command( + self, command: str, request_id: int, **kwargs: Any + ) -> Tuple[Any, int, str]: + """POST a SOAP command to the device. Returns (decoded_response, return_code, message).""" cmd_xml = soap_encode( command, {"requestId": request_id, **kwargs}, @@ -262,7 +451,6 @@ async def send_command( extra_method_xmlns={"i": XSI}, ) - # make POST request to machine url = f"http://{self._machine_ip}:8080/" req = urllib.request.Request( url=url, @@ -277,29 +465,72 @@ async def send_command( }, ) - if self._making_request.locked(): - raise RuntimeError("can't send multiple commands at the same time") + def _do_request() -> bytes: + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.read() # type: ignore - async with self._making_request: - try: + body = await asyncio.to_thread(_do_request) + decoded = soap_decode(body.decode("utf-8")) + return_code, message = self._get_return_code_and_message(command, decoded) + return decoded, return_code, message - def _do_request() -> bytes: - with urllib.request.urlopen(req) as resp: - return resp.read() # type: ignore + async def send_command_async( + self, + command: str, + **kwargs, + ) -> Tuple[asyncio.Future[Any], int]: + """Send command, return (future, request_id). Future is already resolved for sync commands.""" + if self._closed: + raise RuntimeError("Bridge is closed") - body = await asyncio.to_thread(_do_request) - return_code, message = self._get_return_code_and_message( - command, soap_decode(body.decode("utf-8")) - ) - if return_code == 1: # success - return soap_decode(body.decode("utf-8")) - elif return_code == 2: # concurrent command - fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future() - self._pending = InhecoSiLAInterface._SiLACommand( - name=command, request_id=request_id, fut=fut - ) - return await fut # wait for response to be handled in _on_http - else: - raise RuntimeError(f"command {command} failed: {return_code} {message}") - finally: - self._pending = None + if self._lock_id is not None and "lockId" not in kwargs: + kwargs["lockId"] = self._lock_id + + request_id = self._make_request_id() + decoded, return_code, message = await self._post_command(command, request_id, **kwargs) + + if return_code in (1, 3): + if return_code == 3: + self._logger.warning(f"Command {command} accepted with warning: {message}") + fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + fut.set_result(decoded) + return fut, request_id + + if return_code == 2: + fut = asyncio.get_running_loop().create_future() + self._pending_by_id[request_id] = InhecoSiLAInterface._SiLACommand( + name=command, request_id=request_id, fut=fut + ) + return fut, request_id + + await self._handle_error_code(return_code, message, command, request_id) + raise RuntimeError(f"command {command} failed: {return_code}") + + async def send_command( + self, + command: str, + timeout: Optional[float] = None, + **kwargs, + ) -> Any: + """Send command and wait for completion.""" + fut, _ = await self.send_command_async(command, **kwargs) + if timeout is None: + timeout = 60.0 + try: + return await asyncio.wait_for(fut, timeout=timeout) + except asyncio.TimeoutError: + raise SiLATimeoutError( + f"Timed out after {timeout}s waiting for ResponseEvent", command=command + ) from None + + async def lock_device(self, lock_id: str, **kwargs: Any) -> None: + """Lock the device for exclusive access.""" + await self.send_command("LockDevice", lockId=lock_id, **kwargs) + self._lock_id = lock_id + + async def unlock_device(self) -> None: + """Unlock the device.""" + if self._lock_id is None: + raise RuntimeError("Device is not locked") + await self.send_command("UnlockDevice") + self._lock_id = None From 4ad96a543f13bf39801b3809650f975c87715d6c Mon Sep 17 00:00:00 2001 From: Cody Moore <46687103+cmoscy@users.noreply.github.com> Date: Sun, 3 May 2026 21:42:31 -0700 Subject: [PATCH 2/5] formatting --- pylabrobot/inheco/odtc/protocol.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pylabrobot/inheco/odtc/protocol.py b/pylabrobot/inheco/odtc/protocol.py index 8a4d9df441a..c2e469b2d46 100644 --- a/pylabrobot/inheco/odtc/protocol.py +++ b/pylabrobot/inheco/odtc/protocol.py @@ -13,7 +13,10 @@ import math import xml.etree.ElementTree as ET from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple + +if TYPE_CHECKING: + from .model import FluidQuantity from pylabrobot.capabilities.thermocycling.standard import ( Overshoot, From 9028b5ba443d50955e17ca576ef9c9cd8f6f5736 Mon Sep 17 00:00:00 2001 From: Cody Moore <46687103+cmoscy@users.noreply.github.com> Date: Sun, 3 May 2026 22:33:04 -0700 Subject: [PATCH 3/5] Scratch method naming fix --- docs/user_guide/inheco/odtc/hello-world.ipynb | 194 ++++++++++++++---- .../capabilities/thermocycling/standard.py | 7 +- pylabrobot/inheco/odtc/backend.py | 62 +++--- pylabrobot/inheco/odtc/model.py | 6 +- pylabrobot/inheco/odtc/protocol.py | 41 +++- pylabrobot/inheco/odtc/xml.py | 2 +- 6 files changed, 232 insertions(+), 80 deletions(-) diff --git a/docs/user_guide/inheco/odtc/hello-world.ipynb b/docs/user_guide/inheco/odtc/hello-world.ipynb index 06a9c2ae72e..00aadf5956a 100644 --- a/docs/user_guide/inheco/odtc/hello-world.ipynb +++ b/docs/user_guide/inheco/odtc/hello-world.ipynb @@ -37,9 +37,19 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2026-05-03 21:56:25,826 - pylabrobot.inheco.odtc.odtc - INFO - GetStatus returned state: 'standby'\n", + "2026-05-03 21:56:25,826 - pylabrobot.inheco.odtc.odtc - INFO - Device is in standby, calling Initialize...\n", + "2026-05-03 21:56:34,275 - pylabrobot.inheco.odtc.odtc - INFO - Device initialized and idle\n" + ] + } + ], "source": [ "from pylabrobot.inheco.odtc import ODTC, DoorStateUnknownError, FluidQuantity\n", "from pylabrobot.inheco.odtc.backend import ODTCThermocyclerBackend\n", @@ -90,7 +100,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -99,9 +109,17 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Door is open: False\n" + ] + } + ], "source": [ "# Check door state (raises DoorStateUnknownError if neither open() nor close() has been called)\n", "try:\n", @@ -112,7 +130,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "metadata": {}, "outputs": [], "source": [ @@ -130,9 +148,21 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "ename": "AttributeError", + "evalue": "'ODTCThermocyclerBackend' object has no attribute 'request_temperatures'", + "output_type": "error", + "traceback": [ + "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[1;31mAttributeError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[1;32mIn[6], line 1\u001b[0m\n\u001b[1;32m----> 1\u001b[0m sensors \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m odtc\u001b[38;5;241m.\u001b[39mtc\u001b[38;5;241m.\u001b[39mbackend\u001b[38;5;241m.\u001b[39mrequest_temperatures()\n\u001b[0;32m 2\u001b[0m \u001b[38;5;28mprint\u001b[39m(sensors)\n", + "\u001b[1;31mAttributeError\u001b[0m: 'ODTCThermocyclerBackend' object has no attribute 'request_temperatures'" + ] + } + ], "source": [ "sensors = await odtc.tc.backend.request_temperatures()\n", "print(sensors)" @@ -147,9 +177,17 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Block: 22.2 °C Lid: 22.8 °C\n" + ] + } + ], "source": [ "block_temp = await odtc.tc.request_block_temperature()\n", "lid_temp = await odtc.tc.request_lid_temperature()\n", @@ -169,18 +207,44 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "ename": "SiLAError", + "evalue": "Command SetParameters failed with code 2003: 'MethodSet error 262: VALIDATION_FAILED'", + "output_type": "error", + "traceback": [ + "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[1;31mSiLAError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[1;32mIn[8], line 1\u001b[0m\n\u001b[1;32m----> 1\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m odtc\u001b[38;5;241m.\u001b[39mtc\u001b[38;5;241m.\u001b[39mset_block_temperature(\u001b[38;5;241m37.0\u001b[39m)\n", + "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\capabilities\\capability.py:46\u001b[0m, in \u001b[0;36mneed_capability_ready..wrapper\u001b[1;34m(*args, **kwargs)\u001b[0m\n\u001b[0;32m 44\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39msetup_finished:\n\u001b[0;32m 45\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mRuntimeError\u001b[39;00m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mThe capability has not been set up. Call setup() on the parent device.\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m---> 46\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m func(\u001b[38;5;241m*\u001b[39margs, \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs)\n", + "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\capabilities\\thermocycling\\thermocycler.py:69\u001b[0m, in \u001b[0;36mThermocycler.set_block_temperature\u001b[1;34m(self, temperature, backend_params)\u001b[0m\n\u001b[0;32m 57\u001b[0m \u001b[38;5;129m@need_capability_ready\u001b[39m\n\u001b[0;32m 58\u001b[0m \u001b[38;5;28;01masync\u001b[39;00m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;21mset_block_temperature\u001b[39m(\n\u001b[0;32m 59\u001b[0m \u001b[38;5;28mself\u001b[39m,\n\u001b[0;32m 60\u001b[0m temperature: \u001b[38;5;28mfloat\u001b[39m,\n\u001b[0;32m 61\u001b[0m backend_params: Optional[BackendParams] \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mNone\u001b[39;00m,\n\u001b[0;32m 62\u001b[0m ) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[0;32m 63\u001b[0m \u001b[38;5;250m \u001b[39m\u001b[38;5;124;03m\"\"\"Set block temperature and hold.\u001b[39;00m\n\u001b[0;32m 64\u001b[0m \n\u001b[0;32m 65\u001b[0m \u001b[38;5;124;03m Args:\u001b[39;00m\n\u001b[0;32m 66\u001b[0m \u001b[38;5;124;03m temperature: Target block temperature in °C.\u001b[39;00m\n\u001b[0;32m 67\u001b[0m \u001b[38;5;124;03m backend_params: Optional backend-specific parameters.\u001b[39;00m\n\u001b[0;32m 68\u001b[0m \u001b[38;5;124;03m \"\"\"\u001b[39;00m\n\u001b[1;32m---> 69\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mbackend\u001b[38;5;241m.\u001b[39mset_block_temperature(temperature, backend_params\u001b[38;5;241m=\u001b[39mbackend_params)\n", + "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\backend.py:196\u001b[0m, in \u001b[0;36mODTCThermocyclerBackend.set_block_temperature\u001b[1;34m(self, temperature, backend_params)\u001b[0m\n\u001b[0;32m 182\u001b[0m premethod \u001b[38;5;241m=\u001b[39m ODTCProtocol(\n\u001b[0;32m 183\u001b[0m stages\u001b[38;5;241m=\u001b[39m[],\n\u001b[0;32m 184\u001b[0m variant\u001b[38;5;241m=\u001b[39mparams\u001b[38;5;241m.\u001b[39mvariant,\n\u001b[1;32m (...)\u001b[0m\n\u001b[0;32m 193\u001b[0m target_lid_temperature\u001b[38;5;241m=\u001b[39mlid_temp,\n\u001b[0;32m 194\u001b[0m )\n\u001b[0;32m 195\u001b[0m ms \u001b[38;5;241m=\u001b[39m ODTCMethodSet(premethods\u001b[38;5;241m=\u001b[39m[premethod])\n\u001b[1;32m--> 196\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_upload_method_set(ms)\n\u001b[0;32m 197\u001b[0m fut, request_id \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_driver\u001b[38;5;241m.\u001b[39msend_command_async(\n\u001b[0;32m 198\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mExecuteMethod\u001b[39m\u001b[38;5;124m\"\u001b[39m, methodName\u001b[38;5;241m=\u001b[39mpremethod\u001b[38;5;241m.\u001b[39mname\n\u001b[0;32m 199\u001b[0m )\n\u001b[0;32m 200\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_current_request_id \u001b[38;5;241m=\u001b[39m request_id\n", + "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\backend.py:138\u001b[0m, in \u001b[0;36mODTCThermocyclerBackend._upload_method_set\u001b[1;34m(self, method_set, dynamic_pre_method_duration)\u001b[0m\n\u001b[0;32m 136\u001b[0m ET\u001b[38;5;241m.\u001b[39mSubElement(dpm_param, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mBoolean\u001b[39m\u001b[38;5;124m\"\u001b[39m)\u001b[38;5;241m.\u001b[39mtext \u001b[38;5;241m=\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mtrue\u001b[39m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m dynamic_pre_method_duration \u001b[38;5;28;01melse\u001b[39;00m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mfalse\u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[0;32m 137\u001b[0m params_xml \u001b[38;5;241m=\u001b[39m ET\u001b[38;5;241m.\u001b[39mtostring(param_set, encoding\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124municode\u001b[39m\u001b[38;5;124m\"\u001b[39m, xml_declaration\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mFalse\u001b[39;00m)\n\u001b[1;32m--> 138\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_driver\u001b[38;5;241m.\u001b[39msend_command(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mSetParameters\u001b[39m\u001b[38;5;124m\"\u001b[39m, paramsXML\u001b[38;5;241m=\u001b[39mparams_xml)\n", + "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\scila\\inheco_sila_interface.py:520\u001b[0m, in \u001b[0;36mInhecoSiLAInterface.send_command\u001b[1;34m(self, command, timeout, **kwargs)\u001b[0m\n\u001b[0;32m 518\u001b[0m timeout \u001b[38;5;241m=\u001b[39m \u001b[38;5;241m60.0\u001b[39m\n\u001b[0;32m 519\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[1;32m--> 520\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m asyncio\u001b[38;5;241m.\u001b[39mwait_for(fut, timeout\u001b[38;5;241m=\u001b[39mtimeout)\n\u001b[0;32m 521\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m asyncio\u001b[38;5;241m.\u001b[39mTimeoutError:\n\u001b[0;32m 522\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m SiLATimeoutError(\n\u001b[0;32m 523\u001b[0m \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mTimed out after \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mtimeout\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124ms waiting for ResponseEvent\u001b[39m\u001b[38;5;124m\"\u001b[39m, command\u001b[38;5;241m=\u001b[39mcommand\n\u001b[0;32m 524\u001b[0m ) \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mNone\u001b[39;00m\n", + "File \u001b[1;32m~\\AppData\\Roaming\\uv\\python\\cpython-3.10.19-windows-x86_64-none\\lib\\asyncio\\tasks.py:445\u001b[0m, in \u001b[0;36mwait_for\u001b[1;34m(fut, timeout)\u001b[0m\n\u001b[0;32m 442\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m\n\u001b[0;32m 444\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m fut\u001b[38;5;241m.\u001b[39mdone():\n\u001b[1;32m--> 445\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mfut\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mresult\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 446\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[0;32m 447\u001b[0m fut\u001b[38;5;241m.\u001b[39mremove_done_callback(cb)\n", + "\u001b[1;31mSiLAError\u001b[0m: Command SetParameters failed with code 2003: 'MethodSet error 262: VALIDATION_FAILED'" + ] + } + ], "source": [ "await odtc.tc.set_block_temperature(37.0)" ] }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Block: 22.2 °C\n" + ] + } + ], "source": [ "temp = await odtc.tc.request_block_temperature()\n", "print(f\"Block: {temp:.1f} °C\")" @@ -188,7 +252,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "metadata": {}, "outputs": [], "source": [ @@ -217,7 +281,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "metadata": {}, "outputs": [], "source": [ @@ -244,9 +308,10 @@ " steps=[Step(temperature=72.0, hold_seconds=600)],\n", " repeats=1,\n", " ),\n", - " # Hold — 4 °C indefinitely\n", + " # Hold at 4 °C — post_heating=True (default) keeps the block here\n", + " # after the method completes. No explicit infinite-hold step needed.\n", " Stage(\n", - " steps=[Step(temperature=4.0, hold_seconds=float(\"inf\"))],\n", + " steps=[Step(temperature=4.0, hold_seconds=30)],\n", " repeats=1,\n", " ),\n", " ],\n", @@ -273,7 +338,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, "metadata": {}, "outputs": [], "source": [ @@ -293,9 +358,29 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "ename": "OverflowError", + "evalue": "cannot convert float infinity to integer", + "output_type": "error", + "traceback": [ + "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[1;31mOverflowError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[1;32mIn[13], line 1\u001b[0m\n\u001b[1;32m----> 1\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m odtc\u001b[38;5;241m.\u001b[39mtc\u001b[38;5;241m.\u001b[39mrun_protocol(pcr_protocol, backend_params\u001b[38;5;241m=\u001b[39mparams)\n", + "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\capabilities\\capability.py:46\u001b[0m, in \u001b[0;36mneed_capability_ready..wrapper\u001b[1;34m(*args, **kwargs)\u001b[0m\n\u001b[0;32m 44\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39msetup_finished:\n\u001b[0;32m 45\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mRuntimeError\u001b[39;00m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mThe capability has not been set up. Call setup() on the parent device.\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m---> 46\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m func(\u001b[38;5;241m*\u001b[39margs, \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs)\n", + "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\capabilities\\thermocycling\\thermocycler.py:45\u001b[0m, in \u001b[0;36mThermocycler.run_protocol\u001b[1;34m(self, protocol, backend_params)\u001b[0m\n\u001b[0;32m 37\u001b[0m \u001b[38;5;250m\u001b[39m\u001b[38;5;124;03m\"\"\"Execute a thermocycler protocol.\u001b[39;00m\n\u001b[0;32m 38\u001b[0m \n\u001b[0;32m 39\u001b[0m \u001b[38;5;124;03mArgs:\u001b[39;00m\n\u001b[1;32m (...)\u001b[0m\n\u001b[0;32m 42\u001b[0m \u001b[38;5;124;03m fluid_quantity for ODTC).\u001b[39;00m\n\u001b[0;32m 43\u001b[0m \u001b[38;5;124;03m\"\"\"\u001b[39;00m\n\u001b[0;32m 44\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_current_protocol \u001b[38;5;241m=\u001b[39m protocol\n\u001b[1;32m---> 45\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mbackend\u001b[38;5;241m.\u001b[39mrun_protocol(protocol, backend_params\u001b[38;5;241m=\u001b[39mbackend_params)\n", + "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\backend.py:160\u001b[0m, in \u001b[0;36mODTCThermocyclerBackend.run_protocol\u001b[1;34m(self, protocol, backend_params)\u001b[0m\n\u001b[0;32m 158\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[0;32m 159\u001b[0m ms \u001b[38;5;241m=\u001b[39m ODTCMethodSet(premethods\u001b[38;5;241m=\u001b[39m[odtc])\n\u001b[1;32m--> 160\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_upload_method_set(ms, dynamic_pre_method_duration\u001b[38;5;241m=\u001b[39mbackend_params\u001b[38;5;241m.\u001b[39mdynamic_pre_method_duration)\n\u001b[0;32m 162\u001b[0m \u001b[38;5;66;03m# Execute\u001b[39;00m\n\u001b[0;32m 163\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_clear_execution_state()\n", + "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\backend.py:131\u001b[0m, in \u001b[0;36mODTCThermocyclerBackend._upload_method_set\u001b[1;34m(self, method_set, dynamic_pre_method_duration)\u001b[0m\n\u001b[0;32m 125\u001b[0m \u001b[38;5;28;01masync\u001b[39;00m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;21m_upload_method_set\u001b[39m(\n\u001b[0;32m 126\u001b[0m \u001b[38;5;28mself\u001b[39m,\n\u001b[0;32m 127\u001b[0m method_set: ODTCMethodSet,\n\u001b[0;32m 128\u001b[0m dynamic_pre_method_duration: \u001b[38;5;28mbool\u001b[39m \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mTrue\u001b[39;00m,\n\u001b[0;32m 129\u001b[0m ) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[0;32m 130\u001b[0m \u001b[38;5;250m \u001b[39m\u001b[38;5;124;03m\"\"\"Upload a MethodSet to the device via SetParameters.\"\"\"\u001b[39;00m\n\u001b[1;32m--> 131\u001b[0m method_set_xml \u001b[38;5;241m=\u001b[39m \u001b[43mmethod_set_to_xml\u001b[49m\u001b[43m(\u001b[49m\u001b[43mmethod_set\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 132\u001b[0m param_set \u001b[38;5;241m=\u001b[39m ET\u001b[38;5;241m.\u001b[39mElement(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mParameterSet\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[0;32m 133\u001b[0m param \u001b[38;5;241m=\u001b[39m ET\u001b[38;5;241m.\u001b[39mSubElement(param_set, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mParameter\u001b[39m\u001b[38;5;124m\"\u001b[39m, parameterType\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mString\u001b[39m\u001b[38;5;124m\"\u001b[39m, name\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mMethodsXML\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n", + "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\xml.py:618\u001b[0m, in \u001b[0;36mmethod_set_to_xml\u001b[1;34m(method_set)\u001b[0m\n\u001b[0;32m 616\u001b[0m _odtc_protocol_to_premethod_xml(pm, root)\n\u001b[0;32m 617\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m m \u001b[38;5;129;01min\u001b[39;00m method_set\u001b[38;5;241m.\u001b[39mmethods:\n\u001b[1;32m--> 618\u001b[0m \u001b[43m_odtc_protocol_to_method_xml\u001b[49m\u001b[43m(\u001b[49m\u001b[43mm\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mroot\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 619\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m ET\u001b[38;5;241m.\u001b[39mtostring(root, encoding\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124municode\u001b[39m\u001b[38;5;124m\"\u001b[39m, xml_declaration\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mTrue\u001b[39;00m)\n", + "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\xml.py:558\u001b[0m, in \u001b[0;36m_odtc_protocol_to_method_xml\u001b[1;34m(odtc_protocol, parent)\u001b[0m\n\u001b[0;32m 556\u001b[0m flat \u001b[38;5;241m=\u001b[39m _flatten_stages_for_xml(odtc_protocol\u001b[38;5;241m.\u001b[39mstages)\n\u001b[0;32m 557\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m step, number, goto, loop \u001b[38;5;129;01min\u001b[39;00m flat:\n\u001b[1;32m--> 558\u001b[0m \u001b[43m_step_to_xml_element\u001b[49m\u001b[43m(\u001b[49m\u001b[43mstep\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mnumber\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mgoto\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mloop\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43melem\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 560\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m odtc_protocol\u001b[38;5;241m.\u001b[39mpid_set:\n\u001b[0;32m 561\u001b[0m pid_set_elem \u001b[38;5;241m=\u001b[39m ET\u001b[38;5;241m.\u001b[39mSubElement(elem, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mPIDSet\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n", + "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\xml.py:450\u001b[0m, in \u001b[0;36m_step_to_xml_element\u001b[1;34m(step, number, goto_number, loop_number, parent, pid_number)\u001b[0m\n\u001b[0;32m 448\u001b[0m ET\u001b[38;5;241m.\u001b[39mSubElement(elem, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mSlope\u001b[39m\u001b[38;5;124m\"\u001b[39m)\u001b[38;5;241m.\u001b[39mtext \u001b[38;5;241m=\u001b[39m _format_value(slope)\n\u001b[0;32m 449\u001b[0m ET\u001b[38;5;241m.\u001b[39mSubElement(elem, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mPlateauTemperature\u001b[39m\u001b[38;5;124m\"\u001b[39m)\u001b[38;5;241m.\u001b[39mtext \u001b[38;5;241m=\u001b[39m _format_value(step\u001b[38;5;241m.\u001b[39mtemperature)\n\u001b[1;32m--> 450\u001b[0m ET\u001b[38;5;241m.\u001b[39mSubElement(elem, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mPlateauTime\u001b[39m\u001b[38;5;124m\"\u001b[39m)\u001b[38;5;241m.\u001b[39mtext \u001b[38;5;241m=\u001b[39m \u001b[43m_format_value\u001b[49m\u001b[43m(\u001b[49m\u001b[43mstep\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mhold_seconds\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 451\u001b[0m ET\u001b[38;5;241m.\u001b[39mSubElement(elem, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mOverShootSlope1\u001b[39m\u001b[38;5;124m\"\u001b[39m)\u001b[38;5;241m.\u001b[39mtext \u001b[38;5;241m=\u001b[39m _format_value(os1)\n\u001b[0;32m 452\u001b[0m ET\u001b[38;5;241m.\u001b[39mSubElement(elem, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mOverShootTemperature\u001b[39m\u001b[38;5;124m\"\u001b[39m)\u001b[38;5;241m.\u001b[39mtext \u001b[38;5;241m=\u001b[39m _format_value(os_temp)\n", + "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\xml.py:82\u001b[0m, in \u001b[0;36m_format_value\u001b[1;34m(value, scale)\u001b[0m\n\u001b[0;32m 80\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28misinstance\u001b[39m(value, \u001b[38;5;28mfloat\u001b[39m):\n\u001b[0;32m 81\u001b[0m scaled \u001b[38;5;241m=\u001b[39m value \u001b[38;5;241m/\u001b[39m scale \u001b[38;5;28;01mif\u001b[39;00m scale \u001b[38;5;241m!=\u001b[39m \u001b[38;5;241m1.0\u001b[39m \u001b[38;5;28;01melse\u001b[39;00m value\n\u001b[1;32m---> 82\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m scaled \u001b[38;5;241m==\u001b[39m \u001b[38;5;28;43mint\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43mscaled\u001b[49m\u001b[43m)\u001b[49m:\n\u001b[0;32m 83\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28mstr\u001b[39m(\u001b[38;5;28mint\u001b[39m(scaled))\n\u001b[0;32m 84\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28mstr\u001b[39m(scaled)\n", + "\u001b[1;31mOverflowError\u001b[0m: cannot convert float infinity to integer" + ] + } + ], "source": [ "await odtc.tc.run_protocol(pcr_protocol, backend_params=params)" ] @@ -311,9 +396,23 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "ename": "CancelledError", + "evalue": "", + "output_type": "error", + "traceback": [ + "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[1;31mCancelledError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[1;32mIn[14], line 4\u001b[0m\n\u001b[0;32m 1\u001b[0m \u001b[38;5;28;01mimport\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;21;01masyncio\u001b[39;00m\n\u001b[0;32m 3\u001b[0m \u001b[38;5;66;03m# Wait for the first DataEvent to confirm the method actually started\u001b[39;00m\n\u001b[1;32m----> 4\u001b[0m progress \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m odtc\u001b[38;5;241m.\u001b[39mtc\u001b[38;5;241m.\u001b[39mwait_for_first_progress(timeout\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m60.0\u001b[39m)\n\u001b[0;32m 5\u001b[0m \u001b[38;5;28mprint\u001b[39m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mProtocol started:\u001b[39m\u001b[38;5;124m\"\u001b[39m, progress)\n", + "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\capabilities\\thermocycling\\thermocycler.py:117\u001b[0m, in \u001b[0;36mThermocycler.wait_for_first_progress\u001b[1;34m(self, timeout)\u001b[0m\n\u001b[0;32m 115\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m progress \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[0;32m 116\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m progress\n\u001b[1;32m--> 117\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m asyncio\u001b[38;5;241m.\u001b[39msleep(\u001b[38;5;241m0.5\u001b[39m)\n\u001b[0;32m 118\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mTimeoutError\u001b[39;00m(\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mNo protocol progress received within \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mtimeout\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124ms.\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n", + "File \u001b[1;32m~\\AppData\\Roaming\\uv\\python\\cpython-3.10.19-windows-x86_64-none\\lib\\asyncio\\tasks.py:605\u001b[0m, in \u001b[0;36msleep\u001b[1;34m(delay, result)\u001b[0m\n\u001b[0;32m 601\u001b[0m h \u001b[38;5;241m=\u001b[39m loop\u001b[38;5;241m.\u001b[39mcall_later(delay,\n\u001b[0;32m 602\u001b[0m futures\u001b[38;5;241m.\u001b[39m_set_result_unless_cancelled,\n\u001b[0;32m 603\u001b[0m future, result)\n\u001b[0;32m 604\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[1;32m--> 605\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m future\n\u001b[0;32m 606\u001b[0m \u001b[38;5;28;01mfinally\u001b[39;00m:\n\u001b[0;32m 607\u001b[0m h\u001b[38;5;241m.\u001b[39mcancel()\n", + "\u001b[1;31mCancelledError\u001b[0m: " + ] + } + ], "source": [ "import asyncio\n", "\n", @@ -324,9 +423,22 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "ename": "CancelledError", + "evalue": "", + "output_type": "error", + "traceback": [ + "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[1;31mCancelledError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[1;32mIn[15], line 6\u001b[0m\n\u001b[0;32m 4\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m progress \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[0;32m 5\u001b[0m \u001b[38;5;28mprint\u001b[39m(progress)\n\u001b[1;32m----> 6\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m asyncio\u001b[38;5;241m.\u001b[39msleep(\u001b[38;5;241m5\u001b[39m)\n", + "File \u001b[1;32m~\\AppData\\Roaming\\uv\\python\\cpython-3.10.19-windows-x86_64-none\\lib\\asyncio\\tasks.py:605\u001b[0m, in \u001b[0;36msleep\u001b[1;34m(delay, result)\u001b[0m\n\u001b[0;32m 601\u001b[0m h \u001b[38;5;241m=\u001b[39m loop\u001b[38;5;241m.\u001b[39mcall_later(delay,\n\u001b[0;32m 602\u001b[0m futures\u001b[38;5;241m.\u001b[39m_set_result_unless_cancelled,\n\u001b[0;32m 603\u001b[0m future, result)\n\u001b[0;32m 604\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[1;32m--> 605\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m future\n\u001b[0;32m 606\u001b[0m \u001b[38;5;28;01mfinally\u001b[39;00m:\n\u001b[0;32m 607\u001b[0m h\u001b[38;5;241m.\u001b[39mcancel()\n", + "\u001b[1;31mCancelledError\u001b[0m: " + ] + } + ], "source": [ "# Poll progress every 5 seconds\n", "for _ in range(6):\n", @@ -345,7 +457,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 16, "metadata": {}, "outputs": [], "source": [ @@ -630,13 +742,21 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": ".venv", "language": "python", "name": "python3" }, "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", "name": "python", - "version": "3.11.0" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.19" } }, "nbformat": 4, diff --git a/pylabrobot/capabilities/thermocycling/standard.py b/pylabrobot/capabilities/thermocycling/standard.py index 96d083f3f08..47ed7477f82 100644 --- a/pylabrobot/capabilities/thermocycling/standard.py +++ b/pylabrobot/capabilities/thermocycling/standard.py @@ -71,8 +71,11 @@ class Step(SerializableMixin): Args: temperature: Target block temperature in °C. - hold_seconds: Seconds to hold at target. Use ``float('inf')`` for - an indefinite hold (wait until continued). + hold_seconds: Finite positive number of seconds to hold at the target + temperature. Must be a real number (e.g. 30, 300). For an indefinite + hold after a protocol completes, use the device's post-heating + mechanism instead (e.g. ``post_heating=True`` on ``RunProtocolParams`` + or ``ODTCProtocol`` for the ODTC). ramp: Transition profile into this step's temperature. Defaults to FULL_SPEED (full device speed, no overshoot). lid_temperature: Optional lid/cover target temperature in °C. diff --git a/pylabrobot/inheco/odtc/backend.py b/pylabrobot/inheco/odtc/backend.py index 508972400b5..190e595095f 100644 --- a/pylabrobot/inheco/odtc/backend.py +++ b/pylabrobot/inheco/odtc/backend.py @@ -137,6 +137,25 @@ async def _upload_method_set( params_xml = ET.tostring(param_set, encoding="unicode", xml_declaration=False) await self._driver.send_command("SetParameters", paramsXML=params_xml) + # ------------------------------------------------------------------ + # Execution helper + # ------------------------------------------------------------------ + + async def _execute_method(self, odtc_protocol: ODTCProtocol) -> None: + """Clear state, start ExecuteMethod, and register tracking callbacks. + + All fire-and-forget execution paths funnel through here so that + _clear_execution_state, protocol/request-id recording, and the done + callback are always applied in the correct order. + """ + self._clear_execution_state() + self._current_odtc_protocol = odtc_protocol + fut, request_id = await self._driver.send_command_async( + "ExecuteMethod", methodName=odtc_protocol.name + ) + self._current_request_id = request_id + fut.add_done_callback(lambda _: self._clear_execution_state()) + # ------------------------------------------------------------------ # ThermocyclerBackend abstract methods # ------------------------------------------------------------------ @@ -151,20 +170,11 @@ async def run_protocol( backend_params = ODTCThermocyclerBackend.RunProtocolParams(variant=self._variant) odtc = self._resolve_odtc_protocol(protocol, backend_params) - - # Upload as method set - if odtc.kind == "method": - ms = ODTCMethodSet(methods=[odtc]) - else: - ms = ODTCMethodSet(premethods=[odtc]) - await self._upload_method_set(ms, dynamic_pre_method_duration=backend_params.dynamic_pre_method_duration) - - # Execute - self._clear_execution_state() - self._current_odtc_protocol = odtc - fut, request_id = await self._driver.send_command_async("ExecuteMethod", methodName=odtc.name) - self._current_request_id = request_id - fut.add_done_callback(lambda _: self._clear_execution_state()) + await self.upload_protocol( + odtc, + dynamic_pre_method_duration=backend_params.dynamic_pre_method_duration, + ) + await self._execute_method(odtc) async def set_block_temperature( self, @@ -172,7 +182,6 @@ async def set_block_temperature( backend_params: Optional[BackendParams] = None, ) -> None: """Set block temperature via a premethod protocol.""" - from .model import ODTCHardwareConstraints params = ( backend_params if isinstance(backend_params, ODTCThermocyclerBackend.RunProtocolParams) @@ -189,28 +198,23 @@ async def set_block_temperature( start_lid_temperature=lid_temp, pid_set=list(params.pid_set), kind="premethod", + is_scratch=True, target_block_temperature=temperature, target_lid_temperature=lid_temp, ) - ms = ODTCMethodSet(premethods=[premethod]) - await self._upload_method_set(ms) - fut, request_id = await self._driver.send_command_async( - "ExecuteMethod", methodName=premethod.name - ) - self._current_request_id = request_id - self._current_odtc_protocol = premethod - fut.add_done_callback(lambda _: self._clear_execution_state()) + await self.upload_protocol(premethod) + await self._execute_method(premethod) async def deactivate_block(self, backend_params: Optional[BackendParams] = None) -> None: await self._driver.send_command("StopMethod") self._clear_execution_state() async def request_block_temperature(self) -> float: - sensor_values = await self._request_temperatures() + sensor_values = await self.request_temperatures() return sensor_values.mount async def request_lid_temperature(self) -> float: - sensor_values = await self._request_temperatures() + sensor_values = await self.request_temperatures() return sensor_values.lid async def request_progress(self) -> Optional[Any]: @@ -236,7 +240,7 @@ async def stop_protocol(self, backend_params: Optional[BackendParams] = None) -> # ODTC-specific public methods (available on the backend directly) # ------------------------------------------------------------------ - async def _request_temperatures(self) -> ODTCSensorValues: + async def request_temperatures(self) -> ODTCSensorValues: resp = await self._driver.send_command("ReadActualTemperature") if resp is None: raise RuntimeError("ReadActualTemperature returned no data") @@ -382,8 +386,4 @@ async def run_stored_protocol(self, name: str) -> None: f"Protocol {name!r} not found on device. " f"Upload it first with upload_protocol()." ) - self._clear_execution_state() - self._current_odtc_protocol = resolved - fut, request_id = await self._driver.send_command_async("ExecuteMethod", methodName=name) - self._current_request_id = request_id - fut.add_done_callback(lambda _: self._clear_execution_state()) + await self._execute_method(resolved) diff --git a/pylabrobot/inheco/odtc/model.py b/pylabrobot/inheco/odtc/model.py index f9b1fd81d7d..fdfaaa5c9c6 100644 --- a/pylabrobot/inheco/odtc/model.py +++ b/pylabrobot/inheco/odtc/model.py @@ -275,7 +275,8 @@ class ODTCProtocol(Protocol): roundtrip. This is both the compiled output of from_protocol() and the parsed representation of device XML. - Protocol fields inherited: stages, name, lid_temperature. + Protocol fields inherited: stages, lid_temperature. + name is overridden below with a non-empty default. Validation runs in __post_init__ (previously in ODTCConfig). """ @@ -290,6 +291,9 @@ class ODTCProtocol(Protocol): pid_set: List[ODTCPID] = field(default_factory=lambda: [ODTCPID(number=1)]) # Identity / metadata + # Override Protocol.name default ("") so directly-constructed ODTCProtocols always + # have a valid non-empty name for SetParameters / ExecuteMethod. + name: str = field(default="plr_currentProtocol") kind: Literal["method", "premethod"] = field(default="method") is_scratch: bool = field(default=True) creator: Optional[str] = field(default=None) diff --git a/pylabrobot/inheco/odtc/protocol.py b/pylabrobot/inheco/odtc/protocol.py index c2e469b2d46..3d97e85e0ea 100644 --- a/pylabrobot/inheco/odtc/protocol.py +++ b/pylabrobot/inheco/odtc/protocol.py @@ -180,16 +180,40 @@ def _transform_stages( default_lid_temp: float, apply_overshoot: bool = True, ) -> List[Stage]: - """Recursively transform all steps in a stage tree, computing slopes and overshoot.""" + """Recursively transform all steps in a stage tree, computing slopes and overshoot. + + Steps and inner_stages are processed in the same interleaved execution order + used by ``_flatten_one_stage``: steps[0], inner[0], steps[1], inner[1], …, + then any remaining steps. This ensures each overshoot is computed against the + actual preceding temperature the device will see. + """ result = [] for stage in stages: - new_inner = _transform_stages( - stage.inner_stages, prev_temp_box, variant, fluid_quantity, - default_heating_slope, default_cooling_slope, default_lid_temp, - apply_overshoot, - ) - new_steps = [] - for step in stage.steps: + steps = stage.steps + inner_stages = stage.inner_stages or [] + + new_steps: List[Step] = [] + new_inner: List[Stage] = [] + + # Interleave: steps[gi] → inner[gi] → steps[gi+1] → inner[gi+1] → … + for gi, inner in enumerate(inner_stages): + if gi < len(steps): + new_step = _transform_step( + steps[gi], prev_temp_box[0], variant, fluid_quantity, + default_heating_slope, default_cooling_slope, default_lid_temp, + apply_overshoot, + ) + prev_temp_box[0] = steps[gi].temperature + new_steps.append(new_step) + transformed_inner = _transform_stages( + [inner], prev_temp_box, variant, fluid_quantity, + default_heating_slope, default_cooling_slope, default_lid_temp, + apply_overshoot, + ) + new_inner.extend(transformed_inner) + + # Remaining steps after all inner stages (or all steps when no inner_stages) + for step in steps[len(inner_stages):]: new_step = _transform_step( step, prev_temp_box[0], variant, fluid_quantity, default_heating_slope, default_cooling_slope, default_lid_temp, @@ -197,6 +221,7 @@ def _transform_stages( ) prev_temp_box[0] = step.temperature new_steps.append(new_step) + result.append(Stage(steps=new_steps, repeats=stage.repeats, inner_stages=new_inner)) return result diff --git a/pylabrobot/inheco/odtc/xml.py b/pylabrobot/inheco/odtc/xml.py index 9fa9305971c..e82c1fa8d13 100644 --- a/pylabrobot/inheco/odtc/xml.py +++ b/pylabrobot/inheco/odtc/xml.py @@ -548,7 +548,7 @@ def _odtc_protocol_to_method_xml(odtc_protocol: ODTCProtocol, parent: ET.Element ET.SubElement(elem, "Variant").text = str(_variant_to_device_code(odtc_protocol.variant)) ET.SubElement(elem, "PlateType").text = str(odtc_protocol.plate_type) - ET.SubElement(elem, "FluidQuantity").text = str(odtc_protocol.fluid_quantity) + ET.SubElement(elem, "FluidQuantity").text = str(int(odtc_protocol.fluid_quantity)) ET.SubElement(elem, "PostHeating").text = "true" if odtc_protocol.post_heating else "false" ET.SubElement(elem, "StartBlockTemperature").text = _format_value(odtc_protocol.start_block_temperature) ET.SubElement(elem, "StartLidTemperature").text = _format_value(odtc_protocol.start_lid_temperature) From 538138c0a469adb016197a738632334b9556f16b Mon Sep 17 00:00:00 2001 From: Cody Moore <46687103+cmoscy@users.noreply.github.com> Date: Sun, 3 May 2026 23:50:40 -0700 Subject: [PATCH 4/5] wait_for_completion, atomic BackendParams, volume_ul on run_protocol --- docs/user_guide/inheco/odtc/hello-world.ipynb | 27 +++-- .../capabilities/thermocycling/backend.py | 13 ++- .../capabilities/thermocycling/chatterbox.py | 1 + .../thermocycling/thermocycler.py | 29 ++++- pylabrobot/inheco/odtc/backend.py | 103 ++++++++++++++---- pylabrobot/inheco/odtc/model.py | 9 +- pylabrobot/inheco/odtc/odtc.py | 12 +- 7 files changed, 156 insertions(+), 38 deletions(-) diff --git a/docs/user_guide/inheco/odtc/hello-world.ipynb b/docs/user_guide/inheco/odtc/hello-world.ipynb index 00aadf5956a..2283f61a5ef 100644 --- a/docs/user_guide/inheco/odtc/hello-world.ipynb +++ b/docs/user_guide/inheco/odtc/hello-world.ipynb @@ -324,12 +324,17 @@ "source": [ "### ODTC-specific run parameters\n", "\n", - "`ODTCThermocyclerBackend.RunProtocolParams` carries device config:\n", + "The simplest way to specify sample volume is via the `volume_ul` argument on `run_protocol()` — the backend automatically selects the appropriate `FluidQuantity` for overshoot compensation:\n", + "\n", + "```python\n", + "await odtc.tc.run_protocol(pcr_protocol, volume_ul=50.0)\n", + "```\n", + "\n", + "For explicit control, pass `ODTCThermocyclerBackend.RunProtocolParams` as `backend_params`. An explicit `fluid_quantity` always overrides `volume_ul`. Device variant is set at construction time via `ODTC(variant=...)`.\n", "\n", "| Parameter | Meaning | Default |\n", "|-----------|---------|--------|\n", - "| `variant` | 96 or 384 | 96 |\n", - "| `fluid_quantity` | `FluidQuantity.UL_10_TO_29` / `UL_30_TO_74` / `UL_75_TO_100` | `UL_30_TO_74` |\n", + "| `fluid_quantity` | `FluidQuantity.UL_10_TO_29` / `UL_30_TO_74` / `UL_75_TO_100` — overrides `volume_ul` | derived from `volume_ul`, or `UL_30_TO_74` |\n", "| `post_heating` | Keep block warm after method | True |\n", "| `dynamic_pre_method_duration` | Device reports live pre-heat time | True |\n", "| `apply_overshoot` | Auto-compute overshoot for steps without explicit `Ramp.overshoot` | True |\n", @@ -342,8 +347,11 @@ "metadata": {}, "outputs": [], "source": [ - "params = ODTCThermocyclerBackend.RunProtocolParams(\n", - " variant=96,\n", + "# Option A: pass volume_ul directly — fluid_quantity auto-derived\n", + "params_a = None # no backend_params needed; just pass volume_ul to run_protocol\n", + "\n", + "# Option B: explicit fluid_quantity in RunProtocolParams (overrides volume_ul)\n", + "params_b = ODTCThermocyclerBackend.RunProtocolParams(\n", " fluid_quantity=FluidQuantity.UL_30_TO_74, # 30–74 µL samples\n", " post_heating=True,\n", ")" @@ -382,7 +390,11 @@ } ], "source": [ - "await odtc.tc.run_protocol(pcr_protocol, backend_params=params)" + "# Option A: volume_ul derives FluidQuantity automatically\n", + "await odtc.tc.run_protocol(pcr_protocol, volume_ul=50.0)\n", + "\n", + "# Option B: explicit fluid_quantity in RunProtocolParams (overrides volume_ul)\n", + "await odtc.tc.run_protocol(pcr_protocol, backend_params=params_b)" ] }, { @@ -716,8 +728,7 @@ "# await odtc_384.tc.run_protocol(\n", "# pcr_protocol,\n", "# backend_params=ODTCThermocyclerBackend.RunProtocolParams(\n", - "# variant=384,\n", - "# fluid_quantity=2, # 75–100 µL\n", + "# fluid_quantity=FluidQuantity.UL_75_TO_100,\n", "# plate_type=0,\n", "# ),\n", "# )" diff --git a/pylabrobot/capabilities/thermocycling/backend.py b/pylabrobot/capabilities/thermocycling/backend.py index 42352ac48c7..ee6c6c94acb 100644 --- a/pylabrobot/capabilities/thermocycling/backend.py +++ b/pylabrobot/capabilities/thermocycling/backend.py @@ -17,10 +17,21 @@ class ThermocyclerBackend(CapabilityBackend, metaclass=ABCMeta): async def run_protocol( self, protocol: Protocol, + volume_ul: Optional[float] = None, backend_params: Optional[BackendParams] = None, ) -> None: """Execute a thermocycler protocol. Fire-and-forget by default; backends - may support a ``wait`` flag via ``backend_params``.""" + may support a ``wait`` flag via ``backend_params``. + + Args: + protocol: The protocol to run. + volume_ul: Maximum sample volume in wells (µL). Backends that apply + volume-dependent thermal compensation (e.g. ODTC overshoot) use this + to select the appropriate compensation mode. Ignored by backends that + do not support it. Overridden by an explicit ``fluid_quantity`` (or + equivalent) in ``backend_params`` when provided. + backend_params: Backend-specific per-call parameters. + """ @abstractmethod async def set_block_temperature( diff --git a/pylabrobot/capabilities/thermocycling/chatterbox.py b/pylabrobot/capabilities/thermocycling/chatterbox.py index fc4d0c3819d..48ce004552e 100644 --- a/pylabrobot/capabilities/thermocycling/chatterbox.py +++ b/pylabrobot/capabilities/thermocycling/chatterbox.py @@ -33,6 +33,7 @@ async def _on_setup(self, backend_params: Optional[BackendParams] = None) -> Non async def run_protocol( self, protocol: Protocol, + volume_ul: Optional[float] = None, backend_params: Optional[BackendParams] = None, ) -> None: logger.info("ThermocyclerChatterbox: run_protocol name=%r", protocol.name) diff --git a/pylabrobot/capabilities/thermocycling/thermocycler.py b/pylabrobot/capabilities/thermocycling/thermocycler.py index dd88fdb661f..f5526fef33f 100644 --- a/pylabrobot/capabilities/thermocycling/thermocycler.py +++ b/pylabrobot/capabilities/thermocycling/thermocycler.py @@ -32,17 +32,21 @@ def __init__(self, backend: ThermocyclerBackend) -> None: async def run_protocol( self, protocol: Protocol, + volume_ul: Optional[float] = None, backend_params: Optional[BackendParams] = None, ) -> None: """Execute a thermocycler protocol. Args: protocol: The protocol to run. - backend_params: Optional backend-specific parameters (e.g. variant, - fluid_quantity for ODTC). + volume_ul: Maximum sample volume in wells (µL). Backends that apply + volume-dependent thermal compensation (e.g. ODTC overshoot) use this + to select the appropriate mode. Ignored by backends that do not support + it. Overridden by an explicit fluid_quantity in backend_params. + backend_params: Optional backend-specific parameters. """ self._current_protocol = protocol - await self.backend.run_protocol(protocol, backend_params=backend_params) + await self.backend.run_protocol(protocol, volume_ul=volume_ul, backend_params=backend_params) @need_capability_ready async def stop_protocol(self, backend_params: Optional[BackendParams] = None) -> None: @@ -92,6 +96,25 @@ async def request_progress(self) -> Optional[Any]: """Return backend-specific progress for the running protocol, or None.""" return await self.backend.request_progress() + async def wait_for_completion(self, timeout: Optional[float] = None) -> None: + """Block until the running protocol completes, or timeout expires. + + Returns immediately if no protocol is running. Delegates to the backend's + ``wait_for_completion`` if it exists (e.g. ``ODTCThermocyclerBackend``). + + Args: + timeout: Maximum seconds to wait. None means the backend's own default + (typically 3 hours for ODTC). Pass a smaller value to fail faster. + + Raises: + RuntimeError: If capability is not set up. + asyncio.TimeoutError: If the protocol does not complete within timeout. + """ + if not self._setup_finished: + raise RuntimeError("Thermocycler capability is not set up.") + if hasattr(self.backend, "wait_for_completion"): + await self.backend.wait_for_completion(timeout=timeout) + async def wait_for_first_progress(self, timeout: float = 60.0) -> Any: """Block until the backend reports non-None progress, or raise TimeoutError. diff --git a/pylabrobot/inheco/odtc/backend.py b/pylabrobot/inheco/odtc/backend.py index 190e595095f..d09eebb0d1d 100644 --- a/pylabrobot/inheco/odtc/backend.py +++ b/pylabrobot/inheco/odtc/backend.py @@ -42,18 +42,24 @@ class ODTCThermocyclerBackend(ThermocyclerBackend): Uses ODTCDriver for SiLA communication. Accepts plain Protocol (compiles via ODTCProtocol.from_protocol) or ODTCProtocol directly (used as-is). - Device config is passed via RunProtocolParams; per-step overrides via - StepParams attached to Step.backend_params. + Device variant is fixed at construction time (via ODTC(variant=...)). + Per-call config uses atomic params classes: + - RunProtocolParams → run_protocol() + - SetBlockTempParams → set_block_temperature() + - StepParams → Step.backend_params (per-step PID override) """ @dataclass class RunProtocolParams(BackendParams): - """ODTC-specific parameters for run_protocol / execute_method. + """Per-call params for run_protocol(). Controls protocol compilation and execution. - Replaces the old ODTCConfig. Pass as backend_params to run_protocol(). + Pass as backend_params to run_protocol(). Device variant is taken from the + backend's construction-time variant (ODTC(variant=...)) and is not per-call. + + fluid_quantity takes precedence over the volume_ul arg on run_protocol() when + both are provided. If neither is set, defaults to FluidQuantity.UL_30_TO_74. """ - variant: ODTCVariant = 96 - fluid_quantity: FluidQuantity = field(default=FluidQuantity.UL_30_TO_74) + fluid_quantity: Optional[FluidQuantity] = None plate_type: int = 0 post_heating: bool = True pid_set: List[ODTCPID] = field(default_factory=lambda: [ODTCPID(number=1)]) @@ -67,6 +73,17 @@ class RunProtocolParams(BackendParams): If False, no overshoot is applied regardless of ramp rate or fluid quantity. Explicit Ramp.overshoot values are always honoured either way.""" + @dataclass + class SetBlockTempParams(BackendParams): + """Per-call params for set_block_temperature(). Controls premethod compilation. + + Pass as backend_params to set_block_temperature(). Device variant is taken + from the backend's construction-time variant (ODTC(variant=...)). + """ + fluid_quantity: FluidQuantity = field(default=FluidQuantity.UL_30_TO_74) + plate_type: int = 0 + pid_set: List[ODTCPID] = field(default_factory=lambda: [ODTCPID(number=1)]) + @dataclass class StepParams(BackendParams): """Per-step ODTC overrides. Attach to Step.backend_params.""" @@ -83,17 +100,20 @@ def __init__( self.logger = logger or logging.getLogger(__name__) self._current_request_id: Optional[int] = None self._current_odtc_protocol: Optional[ODTCProtocol] = None + self._current_fut: Optional[asyncio.Future] = None self._last_target_temp_c: Optional[float] = None self._timeout: float = 10800.0 # 3-hour fallback; overridden by mcDuration async def _on_setup(self, backend_params: Optional[BackendParams] = None) -> None: self._current_request_id = None self._current_odtc_protocol = None + self._current_fut = None self._last_target_temp_c = None def _clear_execution_state(self) -> None: self._current_request_id = None self._current_odtc_protocol = None + self._current_fut = None self._last_target_temp_c = None # ------------------------------------------------------------------ @@ -104,14 +124,22 @@ def _resolve_odtc_protocol( self, protocol: Protocol, params: "ODTCThermocyclerBackend.RunProtocolParams", + fluid_quantity: "FluidQuantity", ) -> ODTCProtocol: - """Return ODTCProtocol, compiling from generic Protocol if needed.""" + """Return ODTCProtocol, compiling from generic Protocol if needed. + + fluid_quantity is passed explicitly (already resolved from params.fluid_quantity, + the volume_ul capability arg, or the default) so that _resolve_odtc_protocol + never has to consult params.fluid_quantity directly. + """ if isinstance(protocol, ODTCProtocol): + # Pre-compiled protocol: fluid_quantity is already baked into the XML. + # volume_ul and RunProtocolParams.fluid_quantity have no effect here. return protocol return _from_protocol( protocol, - variant=params.variant, - fluid_quantity=params.fluid_quantity, + variant=self._variant, + fluid_quantity=fluid_quantity, plate_type=params.plate_type, post_heating=params.post_heating, pid_set=list(params.pid_set), @@ -154,6 +182,7 @@ async def _execute_method(self, odtc_protocol: ODTCProtocol) -> None: "ExecuteMethod", methodName=odtc_protocol.name ) self._current_request_id = request_id + self._current_fut = fut fut.add_done_callback(lambda _: self._clear_execution_state()) # ------------------------------------------------------------------ @@ -163,13 +192,29 @@ async def _execute_method(self, odtc_protocol: ODTCProtocol) -> None: async def run_protocol( self, protocol: Protocol, + volume_ul: Optional[float] = None, backend_params: Optional[BackendParams] = None, ) -> None: - """Upload and start a protocol. Non-blocking (fire-and-forget).""" + """Upload and start a protocol. Non-blocking (fire-and-forget). + + Args: + protocol: Protocol to compile and run. + volume_ul: Maximum sample volume in wells (µL). Used to auto-select + FluidQuantity when backend_params.fluid_quantity is not set explicitly. + Overridden by an explicit fluid_quantity in backend_params. + backend_params: ODTC-specific compilation and execution options. + """ if not isinstance(backend_params, ODTCThermocyclerBackend.RunProtocolParams): - backend_params = ODTCThermocyclerBackend.RunProtocolParams(variant=self._variant) + backend_params = ODTCThermocyclerBackend.RunProtocolParams() + + if backend_params.fluid_quantity is not None: + fq = backend_params.fluid_quantity + elif volume_ul is not None: + fq = volume_to_fluid_quantity(volume_ul) + else: + fq = FluidQuantity.UL_30_TO_74 - odtc = self._resolve_odtc_protocol(protocol, backend_params) + odtc = self._resolve_odtc_protocol(protocol, backend_params, fluid_quantity=fq) await self.upload_protocol( odtc, dynamic_pre_method_duration=backend_params.dynamic_pre_method_duration, @@ -182,21 +227,18 @@ async def set_block_temperature( backend_params: Optional[BackendParams] = None, ) -> None: """Set block temperature via a premethod protocol.""" - params = ( - backend_params - if isinstance(backend_params, ODTCThermocyclerBackend.RunProtocolParams) - else ODTCThermocyclerBackend.RunProtocolParams(variant=self._variant) - ) + if not isinstance(backend_params, ODTCThermocyclerBackend.SetBlockTempParams): + backend_params = ODTCThermocyclerBackend.SetBlockTempParams() lid_temp = 110.0 # default premethod = ODTCProtocol( stages=[], - variant=params.variant, - plate_type=params.plate_type, - fluid_quantity=params.fluid_quantity, + variant=self._variant, + plate_type=backend_params.plate_type, + fluid_quantity=backend_params.fluid_quantity, post_heating=False, start_block_temperature=0.0, start_lid_temperature=lid_temp, - pid_set=list(params.pid_set), + pid_set=list(backend_params.pid_set), kind="premethod", is_scratch=True, target_block_temperature=temperature, @@ -236,6 +278,25 @@ async def stop_protocol(self, backend_params: Optional[BackendParams] = None) -> await self._driver.send_command("StopMethod") self._clear_execution_state() + async def wait_for_completion(self, timeout: Optional[float] = None) -> None: + """Block until the running method/premethod completes. + + Returns immediately if no method is currently running. Uses + asyncio.shield so that cancelling the caller does not cancel the + underlying SiLA future (the device keeps running). + + Args: + timeout: Maximum seconds to wait. Defaults to the backend timeout + (3 hours). Pass a smaller value to fail faster. + + Raises: + asyncio.TimeoutError: If the method does not complete within timeout. + """ + if self._current_fut is None or self._current_fut.done(): + return + effective = timeout if timeout is not None else self._timeout + await asyncio.wait_for(asyncio.shield(self._current_fut), timeout=effective) + # ------------------------------------------------------------------ # ODTC-specific public methods (available on the backend directly) # ------------------------------------------------------------------ diff --git a/pylabrobot/inheco/odtc/model.py b/pylabrobot/inheco/odtc/model.py index fdfaaa5c9c6..65a5b140187 100644 --- a/pylabrobot/inheco/odtc/model.py +++ b/pylabrobot/inheco/odtc/model.py @@ -55,11 +55,18 @@ def volume_to_fluid_quantity(volume_ul: float) -> FluidQuantity: """Map volume in µL to ODTC FluidQuantity. Args: - volume_ul: Volume in microliters (maximum 100 µL). + volume_ul: Volume in microliters (must be > 0 and ≤ 100 µL). Returns: FluidQuantity matching the volume range. + + Raises: + ValueError: If volume_ul is <= 0 or > 100. """ + if volume_ul <= 0: + raise ValueError( + f"Volume must be > 0 µL, got {volume_ul} µL." + ) if volume_ul > 100: raise ValueError( f"Volume {volume_ul} µL exceeds ODTC maximum of 100 µL." diff --git a/pylabrobot/inheco/odtc/odtc.py b/pylabrobot/inheco/odtc/odtc.py index f7b14539c2f..4b4c14a6b8f 100644 --- a/pylabrobot/inheco/odtc/odtc.py +++ b/pylabrobot/inheco/odtc/odtc.py @@ -36,10 +36,14 @@ class ODTC(Resource, Device): await odtc.door.open() # load plate onto odtc.door ... await odtc.door.close() - await odtc.tc.run_protocol( - protocol, - backend_params=ODTCThermocyclerBackend.RunProtocolParams(variant=96, fluid_quantity=1), - ) + await odtc.tc.run_protocol(protocol) + # or with explicit params: + # await odtc.tc.run_protocol( + # protocol, + # backend_params=ODTCThermocyclerBackend.RunProtocolParams( + # fluid_quantity=FluidQuantity.UL_30_TO_74, + # ), + # ) await odtc.stop() Physical dimensions (mm): x=156.5, y=248.0, z=124.3. From 78f96c3ff23e1a745c64b09c8912b738a52e77d0 Mon Sep 17 00:00:00 2001 From: Cody Moore <46687103+cmoscy@users.noreply.github.com> Date: Mon, 4 May 2026 23:57:05 -0700 Subject: [PATCH 5/5] ODTCBackendParams for compile/run, wait_for_completion intervals, hello-world refresh --- docs/user_guide/inheco/odtc/hello-world.ipynb | 464 ++++-------------- .../capabilities/thermocycling/standard.py | 2 +- .../thermocycling/thermocycler.py | 11 +- pylabrobot/inheco/odtc/__init__.py | 2 + pylabrobot/inheco/odtc/backend.py | 90 ++-- pylabrobot/inheco/odtc/model.py | 138 ++++-- pylabrobot/inheco/odtc/odtc.py | 2 +- pylabrobot/inheco/odtc/protocol.py | 24 +- .../inheco/odtc/tests/protocol_tests.py | 89 +++- .../inheco/odtc/tests/sila_interface_tests.py | 27 +- pylabrobot/inheco/odtc/xml.py | 14 +- 11 files changed, 342 insertions(+), 521 deletions(-) diff --git a/docs/user_guide/inheco/odtc/hello-world.ipynb b/docs/user_guide/inheco/odtc/hello-world.ipynb index 2283f61a5ef..27352ca2568 100644 --- a/docs/user_guide/inheco/odtc/hello-world.ipynb +++ b/docs/user_guide/inheco/odtc/hello-world.ipynb @@ -6,26 +6,17 @@ "source": [ "# Inheco ODTC\n", "\n", - "The Inheco ODTC (On Deck Thermal Cycler) is a thermocycler designed for automated PCR workflows. It features:\n", + "PCR-oriented thermocycler: **4–99 °C**, heated lid, motorized door, **96** or **384** wells. Ethernet via **SiLA 1 (SOAP)** — you need the device IP.\n", "\n", - "- Precise temperature control (4 – 99 °C, up to 4.4 °C/s ramp rate for the 96-well variant)\n", - "- Heated lid to prevent condensation\n", - "- Motorized door for automated plate handling\n", - "- 96-well and 384-well plate formats\n", + "[Hardware](https://www.inheco.com/odtc.html).\n", "\n", - "The ODTC communicates over Ethernet using the SiLA 1 (SOAP) protocol. You will need the IP address of the device and network connectivity between your computer and the ODTC.\n", + "## Architecture (v1b1)\n", "\n", - "See the [Inheco ODTC product page](https://www.inheco.com/odtc.html) for hardware details.\n", - "\n", - "## Architecture overview\n", - "\n", - "The ODTC integrates into PLR's v1b1 Capability architecture:\n", - "\n", - "- **`ODTC`** — the top-level `Device` (also a `Resource` with physical dimensions). Owns two capabilities.\n", - "- **`odtc.tc`** — the `Thermocycler` capability. All temperature control and protocol execution.\n", - "- **`odtc.door`** — the `LoadingTray` capability. Motorized door open/close and plate-access resource.\n", - "- **`ODTCThermocyclerBackend.RunProtocolParams`** — device-specific parameters (variant, fluid quantity, PID, etc.) passed as `backend_params` to `run_protocol()`.\n", - "- **`Protocol` / `Stage` / `Step` / `Ramp`** — the abstract protocol model. Device-agnostic, composable, fully serializable." + "- **`ODTC`** — `Device` + deck `Resource`; **`setup()`** / **`stop()`** run the SiLA session and wire up the pieces below.\n", + "- **`odtc.driver`** — SiLA transport to the instrument (all commands share this).\n", + "- **`odtc.tc`** — **`Thermocycler`**: temperatures, protocols, **`run_protocol`**, **`wait_for_completion`**.\n", + "- **`odtc.door`** — **`LoadingTray`**: motorized door and plate access for arms.\n", + "- **`Protocol` / `Stage` / `Step` / `Ramp`** — abstract run model. **`ODTCBackendParams`** — ODTC compile options for **`run_protocol()`** / **`ODTCProtocol.from_protocol()`**.\n" ] }, { @@ -37,22 +28,11 @@ }, { "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2026-05-03 21:56:25,826 - pylabrobot.inheco.odtc.odtc - INFO - GetStatus returned state: 'standby'\n", - "2026-05-03 21:56:25,826 - pylabrobot.inheco.odtc.odtc - INFO - Device is in standby, calling Initialize...\n", - "2026-05-03 21:56:34,275 - pylabrobot.inheco.odtc.odtc - INFO - Device initialized and idle\n" - ] - } - ], - "source": [ - "from pylabrobot.inheco.odtc import ODTC, DoorStateUnknownError, FluidQuantity\n", - "from pylabrobot.inheco.odtc.backend import ODTCThermocyclerBackend\n", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.inheco.odtc import ODTC, DoorStateUnknownError, FluidQuantity, ODTCBackendParams\n", "from pylabrobot.inheco.odtc.model import ODTCProtocol\n", "from pylabrobot.capabilities.thermocycling import (\n", " Protocol,\n", @@ -76,31 +56,23 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "> **Tip:** Thermocycler operations go through `odtc.tc`; door operations go through `odtc.door`. The `ODTC` device handles the SiLA connection lifecycle." + "> **Tip:** Thermocycler: `odtc.tc`. Door/plate resource: `odtc.door`. **SiLA** (SOAP) is **`odtc.driver`** (`ODTCDriver` in `driver.py`); `ODTC.setup()` / `stop()` start and stop that connection.\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Door control\n", - "\n", - "`odtc.door` is a `LoadingTray` capability — it controls the motorized door **and** acts as the plate-holding resource in the PLR resource tree (arms can pick up and place plates via `odtc.door`).\n", + "## Door\n", "\n", - "### Door state\n", + "`LoadingTray`: door control and plate spot for arms.\n", "\n", - "The ODTC firmware provides no door-state query command, so PLR tracks state locally:\n", - "\n", - "- **Unknown** (initial, or after reconnect) — `odtc.door.backend.is_open` raises `DoorStateUnknownError`\n", - "- **Open** — after a successful `odtc.door.open()`\n", - "- **Closed** — after a successful `odtc.door.close()`\n", - "\n", - "State resets to unknown whenever the device connection is re-established, because the physical door position may have changed while disconnected." + "**State:** firmware does not report door position — PLR tracks it after `open()` / `close()`. Until then, `odtc.door.backend.is_open` raises **`DoorStateUnknownError`**. After reconnect, state is unknown again.\n" ] }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -109,17 +81,9 @@ }, { "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Door is open: False\n" - ] - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "# Check door state (raises DoorStateUnknownError if neither open() nor close() has been called)\n", "try:\n", @@ -130,7 +94,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -141,53 +105,26 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Temperature sensing\n", + "## Temperatures\n", "\n", - "The ODTC has multiple internal sensors. `request_temperatures()` returns all of them at once:" + "`request_temperatures()` returns all sensors. `request_block_temperature()` / `request_lid_temperature()` are shortcuts:\n" ] }, { "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ - { - "ename": "AttributeError", - "evalue": "'ODTCThermocyclerBackend' object has no attribute 'request_temperatures'", - "output_type": "error", - "traceback": [ - "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[1;31mAttributeError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[1;32mIn[6], line 1\u001b[0m\n\u001b[1;32m----> 1\u001b[0m sensors \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m odtc\u001b[38;5;241m.\u001b[39mtc\u001b[38;5;241m.\u001b[39mbackend\u001b[38;5;241m.\u001b[39mrequest_temperatures()\n\u001b[0;32m 2\u001b[0m \u001b[38;5;28mprint\u001b[39m(sensors)\n", - "\u001b[1;31mAttributeError\u001b[0m: 'ODTCThermocyclerBackend' object has no attribute 'request_temperatures'" - ] - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "sensors = await odtc.tc.backend.request_temperatures()\n", "print(sensors)" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The standard `request_block_temperature()` and `request_lid_temperature()` methods are also available:" - ] - }, { "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Block: 22.2 °C Lid: 22.8 °C\n" - ] - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "block_temp = await odtc.tc.request_block_temperature()\n", "lid_temp = await odtc.tc.request_lid_temperature()\n", @@ -198,53 +135,34 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Holding at a constant temperature\n", + "## Holding (pre-method)\n", + "\n", + "`set_block_temperature(t)` uploads a **pre-method** (ramp + hold). Returns when the command is accepted — call **`wait_for_completion()`** to wait until ready. First ramp to a new setpoint can take several minutes.\n", "\n", - "`set_block_temperature()` creates an ODTC *pre-method* internally, which ramps the block and lid to the target and holds there indefinitely. The call returns immediately (fire-and-forget). Call `deactivate_block()` to stop.\n", + "**Before `run_protocol`:** the block should match your protocol’s **first** `Step.temperature` (this example: **95 °C**). **`run_protocol` does not preheat.** Typical flow: **`set_block_temperature`** → **`wait_for_completion`** → **`run_protocol`**.\n", "\n", - "> **Note:** The first call takes several minutes because the device stabilises the block temperature evenly before entering the steady-state hold." + "**Lid:** align with `Protocol.lid_temperature` (and any per-step overrides). `set_block_temperature` uses backend defaults for the pre-method; use `SetBlockTempParams` if you need explicit lid matching.\n", + "\n", + "**Follow-up method:** if the next method’s first step differs from the current block, preheat again first.\n", + "\n", + "`deactivate_block()` ends the hold.\n" ] }, { "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ - { - "ename": "SiLAError", - "evalue": "Command SetParameters failed with code 2003: 'MethodSet error 262: VALIDATION_FAILED'", - "output_type": "error", - "traceback": [ - "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[1;31mSiLAError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[1;32mIn[8], line 1\u001b[0m\n\u001b[1;32m----> 1\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m odtc\u001b[38;5;241m.\u001b[39mtc\u001b[38;5;241m.\u001b[39mset_block_temperature(\u001b[38;5;241m37.0\u001b[39m)\n", - "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\capabilities\\capability.py:46\u001b[0m, in \u001b[0;36mneed_capability_ready..wrapper\u001b[1;34m(*args, **kwargs)\u001b[0m\n\u001b[0;32m 44\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39msetup_finished:\n\u001b[0;32m 45\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mRuntimeError\u001b[39;00m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mThe capability has not been set up. Call setup() on the parent device.\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m---> 46\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m func(\u001b[38;5;241m*\u001b[39margs, \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs)\n", - "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\capabilities\\thermocycling\\thermocycler.py:69\u001b[0m, in \u001b[0;36mThermocycler.set_block_temperature\u001b[1;34m(self, temperature, backend_params)\u001b[0m\n\u001b[0;32m 57\u001b[0m \u001b[38;5;129m@need_capability_ready\u001b[39m\n\u001b[0;32m 58\u001b[0m \u001b[38;5;28;01masync\u001b[39;00m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;21mset_block_temperature\u001b[39m(\n\u001b[0;32m 59\u001b[0m \u001b[38;5;28mself\u001b[39m,\n\u001b[0;32m 60\u001b[0m temperature: \u001b[38;5;28mfloat\u001b[39m,\n\u001b[0;32m 61\u001b[0m backend_params: Optional[BackendParams] \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mNone\u001b[39;00m,\n\u001b[0;32m 62\u001b[0m ) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[0;32m 63\u001b[0m \u001b[38;5;250m \u001b[39m\u001b[38;5;124;03m\"\"\"Set block temperature and hold.\u001b[39;00m\n\u001b[0;32m 64\u001b[0m \n\u001b[0;32m 65\u001b[0m \u001b[38;5;124;03m Args:\u001b[39;00m\n\u001b[0;32m 66\u001b[0m \u001b[38;5;124;03m temperature: Target block temperature in °C.\u001b[39;00m\n\u001b[0;32m 67\u001b[0m \u001b[38;5;124;03m backend_params: Optional backend-specific parameters.\u001b[39;00m\n\u001b[0;32m 68\u001b[0m \u001b[38;5;124;03m \"\"\"\u001b[39;00m\n\u001b[1;32m---> 69\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mbackend\u001b[38;5;241m.\u001b[39mset_block_temperature(temperature, backend_params\u001b[38;5;241m=\u001b[39mbackend_params)\n", - "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\backend.py:196\u001b[0m, in \u001b[0;36mODTCThermocyclerBackend.set_block_temperature\u001b[1;34m(self, temperature, backend_params)\u001b[0m\n\u001b[0;32m 182\u001b[0m premethod \u001b[38;5;241m=\u001b[39m ODTCProtocol(\n\u001b[0;32m 183\u001b[0m stages\u001b[38;5;241m=\u001b[39m[],\n\u001b[0;32m 184\u001b[0m variant\u001b[38;5;241m=\u001b[39mparams\u001b[38;5;241m.\u001b[39mvariant,\n\u001b[1;32m (...)\u001b[0m\n\u001b[0;32m 193\u001b[0m target_lid_temperature\u001b[38;5;241m=\u001b[39mlid_temp,\n\u001b[0;32m 194\u001b[0m )\n\u001b[0;32m 195\u001b[0m ms \u001b[38;5;241m=\u001b[39m ODTCMethodSet(premethods\u001b[38;5;241m=\u001b[39m[premethod])\n\u001b[1;32m--> 196\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_upload_method_set(ms)\n\u001b[0;32m 197\u001b[0m fut, request_id \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_driver\u001b[38;5;241m.\u001b[39msend_command_async(\n\u001b[0;32m 198\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mExecuteMethod\u001b[39m\u001b[38;5;124m\"\u001b[39m, methodName\u001b[38;5;241m=\u001b[39mpremethod\u001b[38;5;241m.\u001b[39mname\n\u001b[0;32m 199\u001b[0m )\n\u001b[0;32m 200\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_current_request_id \u001b[38;5;241m=\u001b[39m request_id\n", - "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\backend.py:138\u001b[0m, in \u001b[0;36mODTCThermocyclerBackend._upload_method_set\u001b[1;34m(self, method_set, dynamic_pre_method_duration)\u001b[0m\n\u001b[0;32m 136\u001b[0m ET\u001b[38;5;241m.\u001b[39mSubElement(dpm_param, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mBoolean\u001b[39m\u001b[38;5;124m\"\u001b[39m)\u001b[38;5;241m.\u001b[39mtext \u001b[38;5;241m=\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mtrue\u001b[39m\u001b[38;5;124m\"\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m dynamic_pre_method_duration \u001b[38;5;28;01melse\u001b[39;00m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mfalse\u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[0;32m 137\u001b[0m params_xml \u001b[38;5;241m=\u001b[39m ET\u001b[38;5;241m.\u001b[39mtostring(param_set, encoding\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124municode\u001b[39m\u001b[38;5;124m\"\u001b[39m, xml_declaration\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mFalse\u001b[39;00m)\n\u001b[1;32m--> 138\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_driver\u001b[38;5;241m.\u001b[39msend_command(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mSetParameters\u001b[39m\u001b[38;5;124m\"\u001b[39m, paramsXML\u001b[38;5;241m=\u001b[39mparams_xml)\n", - "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\scila\\inheco_sila_interface.py:520\u001b[0m, in \u001b[0;36mInhecoSiLAInterface.send_command\u001b[1;34m(self, command, timeout, **kwargs)\u001b[0m\n\u001b[0;32m 518\u001b[0m timeout \u001b[38;5;241m=\u001b[39m \u001b[38;5;241m60.0\u001b[39m\n\u001b[0;32m 519\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[1;32m--> 520\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m asyncio\u001b[38;5;241m.\u001b[39mwait_for(fut, timeout\u001b[38;5;241m=\u001b[39mtimeout)\n\u001b[0;32m 521\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m asyncio\u001b[38;5;241m.\u001b[39mTimeoutError:\n\u001b[0;32m 522\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m SiLATimeoutError(\n\u001b[0;32m 523\u001b[0m \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mTimed out after \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mtimeout\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124ms waiting for ResponseEvent\u001b[39m\u001b[38;5;124m\"\u001b[39m, command\u001b[38;5;241m=\u001b[39mcommand\n\u001b[0;32m 524\u001b[0m ) \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mNone\u001b[39;00m\n", - "File \u001b[1;32m~\\AppData\\Roaming\\uv\\python\\cpython-3.10.19-windows-x86_64-none\\lib\\asyncio\\tasks.py:445\u001b[0m, in \u001b[0;36mwait_for\u001b[1;34m(fut, timeout)\u001b[0m\n\u001b[0;32m 442\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m\n\u001b[0;32m 444\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m fut\u001b[38;5;241m.\u001b[39mdone():\n\u001b[1;32m--> 445\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mfut\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mresult\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 446\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[0;32m 447\u001b[0m fut\u001b[38;5;241m.\u001b[39mremove_done_callback(cb)\n", - "\u001b[1;31mSiLAError\u001b[0m: Command SetParameters failed with code 2003: 'MethodSet error 262: VALIDATION_FAILED'" - ] - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ + "# Example hold temperature (not the PCR preheat target — see Running a PCR protocol)\n", "await odtc.tc.set_block_temperature(37.0)" ] }, { "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Block: 22.2 °C\n" - ] - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "temp = await odtc.tc.request_block_temperature()\n", "print(f\"Block: {temp:.1f} °C\")" @@ -252,7 +170,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -265,23 +183,21 @@ "source": [ "## Running a PCR protocol\n", "\n", - "Protocols are built from `Protocol` → `Stage` → `Step` objects. This is the same model across all PLR thermocyclers.\n", - "\n", - "### The protocol model\n", + "Same **`Protocol` → `Stage` → `Step`** model as in [Thermocycling](../../01_material-handling/thermocycling/thermocycling).\n", "\n", "| Type | Key fields |\n", "|------|------------|\n", - "| `Step` | `temperature` (°C), `hold_seconds`, `ramp` (optional `Ramp`) |\n", - "| `Ramp` | `rate` (°C/s, default = full device speed), `overshoot` (optional) |\n", - "| `Stage` | `steps`, `repeats` (cycling count), `inner_stages` (nested loops) |\n", - "| `Protocol` | `stages`, `name`, `lid_temperature` (default lid temp) |\n", + "| `Step` | `temperature`, `hold_seconds`, optional `ramp` |\n", + "| `Ramp` | `rate` (°C/s), optional `overshoot` |\n", + "| `Stage` | `steps`, `repeats`; optional nested `inner_stages` |\n", + "| `Protocol` | `stages`, `name`, `lid_temperature` |\n", "\n", - "### Defining a protocol" + "### Example protocol\n" ] }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -322,36 +238,34 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### ODTC-specific run parameters\n", + "### Volume, overshoot, backend params\n", + "\n", + "**Volume → `FluidQuantity`:** `volume_ul` (max µL/well) maps **1–29** / **30–74** / **75–100** to `UL_10_TO_29` / `UL_30_TO_74` / `UL_75_TO_100`. Default when unset: **30–74**. **`backend_params.fluid_quantity`** overrides `volume_ul`.\n", "\n", - "The simplest way to specify sample volume is via the `volume_ul` argument on `run_protocol()` — the backend automatically selects the appropriate `FluidQuantity` for overshoot compensation:\n", + "**Overshoot:** **`apply_overshoot=True`** (default) adds auto overshoot when a step has no `Ramp.overshoot`. **`False`** turns that off; **explicit** `Ramp(..., overshoot=Overshoot(...))` always applies. Same flag via `ODTCProtocol.from_protocol(..., params=ODTCBackendParams(...))`.\n", "\n", "```python\n", "await odtc.tc.run_protocol(pcr_protocol, volume_ul=50.0)\n", + "# dynamic_pre_method_duration only on run_protocol(), not ODTCBackendParams:\n", + "# await odtc.tc.run_protocol(pcr_protocol, volume_ul=50.0, dynamic_pre_method_duration=True)\n", "```\n", "\n", - "For explicit control, pass `ODTCThermocyclerBackend.RunProtocolParams` as `backend_params`. An explicit `fluid_quantity` always overrides `volume_ul`. Device variant is set at construction time via `ODTC(variant=...)`.\n", + "Variant is chosen at construction (`ODTC(variant=96|384)`).\n", "\n", - "| Parameter | Meaning | Default |\n", - "|-----------|---------|--------|\n", - "| `fluid_quantity` | `FluidQuantity.UL_10_TO_29` / `UL_30_TO_74` / `UL_75_TO_100` — overrides `volume_ul` | derived from `volume_ul`, or `UL_30_TO_74` |\n", - "| `post_heating` | Keep block warm after method | True |\n", - "| `dynamic_pre_method_duration` | Device reports live pre-heat time | True |\n", - "| `apply_overshoot` | Auto-compute overshoot for steps without explicit `Ramp.overshoot` | True |\n", - "| `name` | Protocol name stored on device (None = scratch) | None |" + "After `run_protocol`, **`wait_for_completion(timeout=..., report_interval=...)`** logs INFO progress every `report_interval` seconds (default **300**; **`0`** disables).\n" ] }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Option A: pass volume_ul directly — fluid_quantity auto-derived\n", "params_a = None # no backend_params needed; just pass volume_ul to run_protocol\n", "\n", - "# Option B: explicit fluid_quantity in RunProtocolParams (overrides volume_ul)\n", - "params_b = ODTCThermocyclerBackend.RunProtocolParams(\n", + "# Option B: explicit fluid_quantity in ODTCBackendParams (overrides volume_ul)\n", + "params_b = ODTCBackendParams(\n", " fluid_quantity=FluidQuantity.UL_30_TO_74, # 30–74 µL samples\n", " post_heating=True,\n", ")" @@ -361,103 +275,28 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Running the protocol" + "### Run: preheat → run → wait\n" ] }, { "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [ - { - "ename": "OverflowError", - "evalue": "cannot convert float infinity to integer", - "output_type": "error", - "traceback": [ - "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[1;31mOverflowError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[1;32mIn[13], line 1\u001b[0m\n\u001b[1;32m----> 1\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m odtc\u001b[38;5;241m.\u001b[39mtc\u001b[38;5;241m.\u001b[39mrun_protocol(pcr_protocol, backend_params\u001b[38;5;241m=\u001b[39mparams)\n", - "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\capabilities\\capability.py:46\u001b[0m, in \u001b[0;36mneed_capability_ready..wrapper\u001b[1;34m(*args, **kwargs)\u001b[0m\n\u001b[0;32m 44\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39msetup_finished:\n\u001b[0;32m 45\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mRuntimeError\u001b[39;00m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mThe capability has not been set up. Call setup() on the parent device.\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m---> 46\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m func(\u001b[38;5;241m*\u001b[39margs, \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs)\n", - "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\capabilities\\thermocycling\\thermocycler.py:45\u001b[0m, in \u001b[0;36mThermocycler.run_protocol\u001b[1;34m(self, protocol, backend_params)\u001b[0m\n\u001b[0;32m 37\u001b[0m \u001b[38;5;250m\u001b[39m\u001b[38;5;124;03m\"\"\"Execute a thermocycler protocol.\u001b[39;00m\n\u001b[0;32m 38\u001b[0m \n\u001b[0;32m 39\u001b[0m \u001b[38;5;124;03mArgs:\u001b[39;00m\n\u001b[1;32m (...)\u001b[0m\n\u001b[0;32m 42\u001b[0m \u001b[38;5;124;03m fluid_quantity for ODTC).\u001b[39;00m\n\u001b[0;32m 43\u001b[0m \u001b[38;5;124;03m\"\"\"\u001b[39;00m\n\u001b[0;32m 44\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_current_protocol \u001b[38;5;241m=\u001b[39m protocol\n\u001b[1;32m---> 45\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mbackend\u001b[38;5;241m.\u001b[39mrun_protocol(protocol, backend_params\u001b[38;5;241m=\u001b[39mbackend_params)\n", - "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\backend.py:160\u001b[0m, in \u001b[0;36mODTCThermocyclerBackend.run_protocol\u001b[1;34m(self, protocol, backend_params)\u001b[0m\n\u001b[0;32m 158\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[0;32m 159\u001b[0m ms \u001b[38;5;241m=\u001b[39m ODTCMethodSet(premethods\u001b[38;5;241m=\u001b[39m[odtc])\n\u001b[1;32m--> 160\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_upload_method_set(ms, dynamic_pre_method_duration\u001b[38;5;241m=\u001b[39mbackend_params\u001b[38;5;241m.\u001b[39mdynamic_pre_method_duration)\n\u001b[0;32m 162\u001b[0m \u001b[38;5;66;03m# Execute\u001b[39;00m\n\u001b[0;32m 163\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_clear_execution_state()\n", - "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\backend.py:131\u001b[0m, in \u001b[0;36mODTCThermocyclerBackend._upload_method_set\u001b[1;34m(self, method_set, dynamic_pre_method_duration)\u001b[0m\n\u001b[0;32m 125\u001b[0m \u001b[38;5;28;01masync\u001b[39;00m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;21m_upload_method_set\u001b[39m(\n\u001b[0;32m 126\u001b[0m \u001b[38;5;28mself\u001b[39m,\n\u001b[0;32m 127\u001b[0m method_set: ODTCMethodSet,\n\u001b[0;32m 128\u001b[0m dynamic_pre_method_duration: \u001b[38;5;28mbool\u001b[39m \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mTrue\u001b[39;00m,\n\u001b[0;32m 129\u001b[0m ) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[0;32m 130\u001b[0m \u001b[38;5;250m \u001b[39m\u001b[38;5;124;03m\"\"\"Upload a MethodSet to the device via SetParameters.\"\"\"\u001b[39;00m\n\u001b[1;32m--> 131\u001b[0m method_set_xml \u001b[38;5;241m=\u001b[39m \u001b[43mmethod_set_to_xml\u001b[49m\u001b[43m(\u001b[49m\u001b[43mmethod_set\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 132\u001b[0m param_set \u001b[38;5;241m=\u001b[39m ET\u001b[38;5;241m.\u001b[39mElement(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mParameterSet\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[0;32m 133\u001b[0m param \u001b[38;5;241m=\u001b[39m ET\u001b[38;5;241m.\u001b[39mSubElement(param_set, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mParameter\u001b[39m\u001b[38;5;124m\"\u001b[39m, parameterType\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mString\u001b[39m\u001b[38;5;124m\"\u001b[39m, name\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mMethodsXML\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n", - "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\xml.py:618\u001b[0m, in \u001b[0;36mmethod_set_to_xml\u001b[1;34m(method_set)\u001b[0m\n\u001b[0;32m 616\u001b[0m _odtc_protocol_to_premethod_xml(pm, root)\n\u001b[0;32m 617\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m m \u001b[38;5;129;01min\u001b[39;00m method_set\u001b[38;5;241m.\u001b[39mmethods:\n\u001b[1;32m--> 618\u001b[0m \u001b[43m_odtc_protocol_to_method_xml\u001b[49m\u001b[43m(\u001b[49m\u001b[43mm\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mroot\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 619\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m ET\u001b[38;5;241m.\u001b[39mtostring(root, encoding\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124municode\u001b[39m\u001b[38;5;124m\"\u001b[39m, xml_declaration\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mTrue\u001b[39;00m)\n", - "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\xml.py:558\u001b[0m, in \u001b[0;36m_odtc_protocol_to_method_xml\u001b[1;34m(odtc_protocol, parent)\u001b[0m\n\u001b[0;32m 556\u001b[0m flat \u001b[38;5;241m=\u001b[39m _flatten_stages_for_xml(odtc_protocol\u001b[38;5;241m.\u001b[39mstages)\n\u001b[0;32m 557\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m step, number, goto, loop \u001b[38;5;129;01min\u001b[39;00m flat:\n\u001b[1;32m--> 558\u001b[0m \u001b[43m_step_to_xml_element\u001b[49m\u001b[43m(\u001b[49m\u001b[43mstep\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mnumber\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mgoto\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mloop\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43melem\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 560\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m odtc_protocol\u001b[38;5;241m.\u001b[39mpid_set:\n\u001b[0;32m 561\u001b[0m pid_set_elem \u001b[38;5;241m=\u001b[39m ET\u001b[38;5;241m.\u001b[39mSubElement(elem, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mPIDSet\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n", - "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\xml.py:450\u001b[0m, in \u001b[0;36m_step_to_xml_element\u001b[1;34m(step, number, goto_number, loop_number, parent, pid_number)\u001b[0m\n\u001b[0;32m 448\u001b[0m ET\u001b[38;5;241m.\u001b[39mSubElement(elem, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mSlope\u001b[39m\u001b[38;5;124m\"\u001b[39m)\u001b[38;5;241m.\u001b[39mtext \u001b[38;5;241m=\u001b[39m _format_value(slope)\n\u001b[0;32m 449\u001b[0m ET\u001b[38;5;241m.\u001b[39mSubElement(elem, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mPlateauTemperature\u001b[39m\u001b[38;5;124m\"\u001b[39m)\u001b[38;5;241m.\u001b[39mtext \u001b[38;5;241m=\u001b[39m _format_value(step\u001b[38;5;241m.\u001b[39mtemperature)\n\u001b[1;32m--> 450\u001b[0m ET\u001b[38;5;241m.\u001b[39mSubElement(elem, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mPlateauTime\u001b[39m\u001b[38;5;124m\"\u001b[39m)\u001b[38;5;241m.\u001b[39mtext \u001b[38;5;241m=\u001b[39m \u001b[43m_format_value\u001b[49m\u001b[43m(\u001b[49m\u001b[43mstep\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mhold_seconds\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 451\u001b[0m ET\u001b[38;5;241m.\u001b[39mSubElement(elem, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mOverShootSlope1\u001b[39m\u001b[38;5;124m\"\u001b[39m)\u001b[38;5;241m.\u001b[39mtext \u001b[38;5;241m=\u001b[39m _format_value(os1)\n\u001b[0;32m 452\u001b[0m ET\u001b[38;5;241m.\u001b[39mSubElement(elem, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mOverShootTemperature\u001b[39m\u001b[38;5;124m\"\u001b[39m)\u001b[38;5;241m.\u001b[39mtext \u001b[38;5;241m=\u001b[39m _format_value(os_temp)\n", - "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\inheco\\odtc\\xml.py:82\u001b[0m, in \u001b[0;36m_format_value\u001b[1;34m(value, scale)\u001b[0m\n\u001b[0;32m 80\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28misinstance\u001b[39m(value, \u001b[38;5;28mfloat\u001b[39m):\n\u001b[0;32m 81\u001b[0m scaled \u001b[38;5;241m=\u001b[39m value \u001b[38;5;241m/\u001b[39m scale \u001b[38;5;28;01mif\u001b[39;00m scale \u001b[38;5;241m!=\u001b[39m \u001b[38;5;241m1.0\u001b[39m \u001b[38;5;28;01melse\u001b[39;00m value\n\u001b[1;32m---> 82\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m scaled \u001b[38;5;241m==\u001b[39m \u001b[38;5;28;43mint\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43mscaled\u001b[49m\u001b[43m)\u001b[49m:\n\u001b[0;32m 83\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28mstr\u001b[39m(\u001b[38;5;28mint\u001b[39m(scaled))\n\u001b[0;32m 84\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28mstr\u001b[39m(scaled)\n", - "\u001b[1;31mOverflowError\u001b[0m: cannot convert float infinity to integer" - ] - } - ], - "source": [ - "# Option A: volume_ul derives FluidQuantity automatically\n", - "await odtc.tc.run_protocol(pcr_protocol, volume_ul=50.0)\n", - "\n", - "# Option B: explicit fluid_quantity in RunProtocolParams (overrides volume_ul)\n", - "await odtc.tc.run_protocol(pcr_protocol, backend_params=params_b)" - ] - }, - { - "cell_type": "markdown", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ - "`run_protocol()` is fire-and-forget by default — it uploads the method, starts execution, and returns immediately.\n", + "import logging\n", + "logging.basicConfig(level=logging.INFO)\n", "\n", - "### Monitoring progress" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [ - { - "ename": "CancelledError", - "evalue": "", - "output_type": "error", - "traceback": [ - "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[1;31mCancelledError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[1;32mIn[14], line 4\u001b[0m\n\u001b[0;32m 1\u001b[0m \u001b[38;5;28;01mimport\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;21;01masyncio\u001b[39;00m\n\u001b[0;32m 3\u001b[0m \u001b[38;5;66;03m# Wait for the first DataEvent to confirm the method actually started\u001b[39;00m\n\u001b[1;32m----> 4\u001b[0m progress \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m odtc\u001b[38;5;241m.\u001b[39mtc\u001b[38;5;241m.\u001b[39mwait_for_first_progress(timeout\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m60.0\u001b[39m)\n\u001b[0;32m 5\u001b[0m \u001b[38;5;28mprint\u001b[39m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mProtocol started:\u001b[39m\u001b[38;5;124m\"\u001b[39m, progress)\n", - "File \u001b[1;32m~\\repos\\pylabrobot\\pylabrobot\\capabilities\\thermocycling\\thermocycler.py:117\u001b[0m, in \u001b[0;36mThermocycler.wait_for_first_progress\u001b[1;34m(self, timeout)\u001b[0m\n\u001b[0;32m 115\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m progress \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[0;32m 116\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m progress\n\u001b[1;32m--> 117\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m asyncio\u001b[38;5;241m.\u001b[39msleep(\u001b[38;5;241m0.5\u001b[39m)\n\u001b[0;32m 118\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mTimeoutError\u001b[39;00m(\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mNo protocol progress received within \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mtimeout\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124ms.\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n", - "File \u001b[1;32m~\\AppData\\Roaming\\uv\\python\\cpython-3.10.19-windows-x86_64-none\\lib\\asyncio\\tasks.py:605\u001b[0m, in \u001b[0;36msleep\u001b[1;34m(delay, result)\u001b[0m\n\u001b[0;32m 601\u001b[0m h \u001b[38;5;241m=\u001b[39m loop\u001b[38;5;241m.\u001b[39mcall_later(delay,\n\u001b[0;32m 602\u001b[0m futures\u001b[38;5;241m.\u001b[39m_set_result_unless_cancelled,\n\u001b[0;32m 603\u001b[0m future, result)\n\u001b[0;32m 604\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[1;32m--> 605\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m future\n\u001b[0;32m 606\u001b[0m \u001b[38;5;28;01mfinally\u001b[39;00m:\n\u001b[0;32m 607\u001b[0m h\u001b[38;5;241m.\u001b[39mcancel()\n", - "\u001b[1;31mCancelledError\u001b[0m: " - ] - } - ], - "source": [ - "import asyncio\n", + "# 1) Preheat to the first step temperature (StandardPCR starts at 95 C)\n", + "await odtc.tc.set_block_temperature(95.0)\n", + "await odtc.tc.wait_for_completion()\n", "\n", - "# Wait for the first DataEvent to confirm the method actually started\n", - "progress = await odtc.tc.wait_for_first_progress(timeout=60.0)\n", - "print(\"Protocol started:\", progress)" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": {}, - "outputs": [ - { - "ename": "CancelledError", - "evalue": "", - "output_type": "error", - "traceback": [ - "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[1;31mCancelledError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[1;32mIn[15], line 6\u001b[0m\n\u001b[0;32m 4\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m progress \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[0;32m 5\u001b[0m \u001b[38;5;28mprint\u001b[39m(progress)\n\u001b[1;32m----> 6\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m asyncio\u001b[38;5;241m.\u001b[39msleep(\u001b[38;5;241m5\u001b[39m)\n", - "File \u001b[1;32m~\\AppData\\Roaming\\uv\\python\\cpython-3.10.19-windows-x86_64-none\\lib\\asyncio\\tasks.py:605\u001b[0m, in \u001b[0;36msleep\u001b[1;34m(delay, result)\u001b[0m\n\u001b[0;32m 601\u001b[0m h \u001b[38;5;241m=\u001b[39m loop\u001b[38;5;241m.\u001b[39mcall_later(delay,\n\u001b[0;32m 602\u001b[0m futures\u001b[38;5;241m.\u001b[39m_set_result_unless_cancelled,\n\u001b[0;32m 603\u001b[0m future, result)\n\u001b[0;32m 604\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[1;32m--> 605\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m future\n\u001b[0;32m 606\u001b[0m \u001b[38;5;28;01mfinally\u001b[39;00m:\n\u001b[0;32m 607\u001b[0m h\u001b[38;5;241m.\u001b[39mcancel()\n", - "\u001b[1;31mCancelledError\u001b[0m: " - ] - } - ], - "source": [ - "# Poll progress every 5 seconds\n", - "for _ in range(6):\n", - " progress = await odtc.tc.request_progress()\n", - " if progress is not None:\n", - " print(progress)\n", - " await asyncio.sleep(5)" + "# 2) Main method (volume -> FluidQuantity; see markdown above for apply_overshoot / params)\n", + "await odtc.tc.run_protocol(pcr_protocol, volume_ul=50.0)\n", + "# await odtc.tc.run_protocol(pcr_protocol, backend_params=params_b) # alternative\n", + "\n", + "# 3) Block until the PCR finishes; periodic INFO lines (elapsed / ~total) every report_interval seconds\n", + "await odtc.tc.wait_for_completion(report_interval=120.0)\n" ] }, { @@ -469,7 +308,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -482,12 +321,7 @@ "source": [ "## Custom ramp rates\n", "\n", - "Control how fast the block transitions between steps using `Ramp(rate=...)`. The rate is in °C/s.\n", - "\n", - "- `Ramp()` or `FULL_SPEED` — as fast as the device can go (hardware default)\n", - "- `Ramp(rate=4.4)` — maximum heating rate for 96-well variant\n", - "- `Ramp(rate=2.2)` — maximum cooling rate for 96-well variant\n", - "- `Ramp(rate=1.0)` — gentle ramp for sensitive applications" + "`Ramp(rate=...)` in °C/s. **`Ramp()`** / **`FULL_SPEED`** — hardware max. Examples (96-well max heat/cool often **4.4** / **2.2** °C/s).\n" ] }, { @@ -512,7 +346,7 @@ "\n", "await odtc.tc.run_protocol(\n", " precise_protocol,\n", - " backend_params=ODTCThermocyclerBackend.RunProtocolParams(fluid_quantity=1),\n", + " backend_params=ODTCBackendParams(fluid_quantity=FluidQuantity.UL_10_TO_29),\n", ")" ] }, @@ -522,9 +356,7 @@ "source": [ "## Per-step lid temperature\n", "\n", - "The heated lid temperature can be set globally on `Protocol.lid_temperature`, or overridden per step via `Step.lid_temperature`.\n", - "\n", - "> **Note:** `lid_temperature` refers to the *heated cover* that prevents condensation — it is separate from the motorized `door` capability." + "Default: `Protocol.lid_temperature`. Override: `Step.lid_temperature`. (Heated **cover**, not the motorized `door`.)\n" ] }, { @@ -553,119 +385,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Overshoot control\n", - "\n", - "The ODTC uses *overshoot* to achieve fast, precise ramp rates: the block briefly exceeds the target temperature, then settles to the exact plateau. The backend computes optimal overshoot parameters automatically.\n", - "\n", - "You can also specify an explicit overshoot:\n", - "\n", - "```python\n", - "Ramp(\n", - " rate=4.4,\n", - " overshoot=Overshoot(\n", - " target_temp=5.5, # °C above plateau to briefly reach\n", - " hold_seconds=0.0, # time at peak (0 = triangular pulse)\n", - " return_rate=2.2, # °C/s falling back to plateau\n", - " )\n", - ")\n", - "```\n", - "\n", - "When `overshoot` is `None` (the default), the backend computes it from temperature delta, ramp rate, and fluid quantity. To **disable** overshoot entirely, pass `apply_overshoot=False` to `RunProtocolParams` or `ODTCProtocol.from_protocol()`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Auto-computed overshoot (recommended)\n", - "auto_overshoot_protocol = Protocol(\n", - " stages=[\n", - " Stage(\n", - " steps=[\n", - " Step(temperature=95.0, hold_seconds=30, ramp=Ramp(rate=4.4)),\n", - " Step(temperature=55.0, hold_seconds=30, ramp=Ramp(rate=2.2)),\n", - " ],\n", - " repeats=35,\n", - " ),\n", - " ],\n", - ")\n", - "\n", - "# Explicit overshoot override\n", - "explicit_overshoot_protocol = Protocol(\n", - " stages=[\n", - " Stage(\n", - " steps=[\n", - " Step(\n", - " temperature=95.0,\n", - " hold_seconds=30,\n", - " ramp=Ramp(\n", - " rate=4.4,\n", - " overshoot=Overshoot(\n", - " target_temp=6.0,\n", - " hold_seconds=0.0,\n", - " return_rate=2.2,\n", - " ),\n", - " ),\n", - " ),\n", - " ],\n", - " repeats=1,\n", - " )\n", - " ],\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Nested cycling loops\n", - "\n", - "`Stage.inner_stages` supports nested PCR loops. The ODTC encodes these natively using goto/loop firmware instructions.\n", - "\n", - "Example: outer 30-cycle loop containing an inner 5-cycle loop:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "nested_protocol = Protocol(\n", - " name=\"NestedCycles\",\n", - " stages=[\n", - " Stage(\n", - " steps=[Step(temperature=95.0, hold_seconds=10)],\n", - " repeats=30,\n", - " inner_stages=[\n", - " Stage(\n", - " steps=[\n", - " Step(temperature=55.0, hold_seconds=10),\n", - " Step(temperature=72.0, hold_seconds=20),\n", - " ],\n", - " repeats=5,\n", - " ),\n", - " ],\n", - " ),\n", - " Stage(\n", - " steps=[Step(temperature=50.0, hold_seconds=20)],\n", - " repeats=30,\n", - " ),\n", - " ],\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Storing named protocols\n", - "\n", - "You can compile and upload a protocol to the device once, then run it later by name — without recompiling or re-uploading. The method persists on the device across sessions until explicitly deleted.\n", + "## Stored (named) protocols\n", "\n", - "Use `ODTCProtocol.from_protocol()` to compile the protocol with a name, then `upload_protocol()` to store it, and `run_stored_protocol()` to execute it later." + "Compile with `ODTCProtocol.from_protocol()`, **`upload_protocol()`** once, then **`run_stored_protocol()`** later — survives reset/reconnect until deleted.\n" ] }, { @@ -678,9 +400,11 @@ "odtc_protocol = ODTCProtocol.from_protocol(\n", " pcr_protocol,\n", " variant=96,\n", - " fluid_quantity=FluidQuantity.UL_30_TO_74,\n", - " name=\"StandardPCR\", # is_scratch=False: persists on device\n", - " apply_overshoot=True, # default; set False to disable auto-overshoot\n", + " params=ODTCBackendParams(\n", + " fluid_quantity=FluidQuantity.UL_30_TO_74,\n", + " name=\"StandardPCR\", # is_scratch=False: persists on device\n", + " apply_overshoot=True, # default; set False to disable auto-overshoot\n", + " ),\n", ")\n", "print(odtc_protocol)\n", "\n", @@ -711,9 +435,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Variant: 384-well\n", + "## 384-well variant\n", "\n", - "The 384-well ODTC uses a higher maximum heating slope (5.0 °C/s vs 4.4 °C/s) and supports plate type 2." + "Max heating **5.0** °C/s (vs **4.4** on 96). **`plate_type`** **0** or **2** (96-well: **0** only).\n" ] }, { @@ -727,7 +451,7 @@ "#\n", "# await odtc_384.tc.run_protocol(\n", "# pcr_protocol,\n", - "# backend_params=ODTCThermocyclerBackend.RunProtocolParams(\n", + "# backend_params=ODTCBackendParams(\n", "# fluid_quantity=FluidQuantity.UL_75_TO_100,\n", "# plate_type=0,\n", "# ),\n", @@ -747,7 +471,7 @@ "metadata": {}, "outputs": [], "source": [ - "await odtc.stop() # deactivates block, closes SiLA connection" + "await odtc.stop() # deactivates block, stops driver (closes SiLA)\n" ] } ], diff --git a/pylabrobot/capabilities/thermocycling/standard.py b/pylabrobot/capabilities/thermocycling/standard.py index 47ed7477f82..e3ff01e3d07 100644 --- a/pylabrobot/capabilities/thermocycling/standard.py +++ b/pylabrobot/capabilities/thermocycling/standard.py @@ -74,7 +74,7 @@ class Step(SerializableMixin): hold_seconds: Finite positive number of seconds to hold at the target temperature. Must be a real number (e.g. 30, 300). For an indefinite hold after a protocol completes, use the device's post-heating - mechanism instead (e.g. ``post_heating=True`` on ``RunProtocolParams`` + mechanism instead (e.g. ``post_heating=True`` on ``ODTCBackendParams`` or ``ODTCProtocol`` for the ODTC). ramp: Transition profile into this step's temperature. Defaults to FULL_SPEED (full device speed, no overshoot). diff --git a/pylabrobot/capabilities/thermocycling/thermocycler.py b/pylabrobot/capabilities/thermocycling/thermocycler.py index f5526fef33f..9d2e9746509 100644 --- a/pylabrobot/capabilities/thermocycling/thermocycler.py +++ b/pylabrobot/capabilities/thermocycling/thermocycler.py @@ -96,7 +96,11 @@ async def request_progress(self) -> Optional[Any]: """Return backend-specific progress for the running protocol, or None.""" return await self.backend.request_progress() - async def wait_for_completion(self, timeout: Optional[float] = None) -> None: + async def wait_for_completion( + self, + timeout: Optional[float] = None, + report_interval: float = 300.0, + ) -> None: """Block until the running protocol completes, or timeout expires. Returns immediately if no protocol is running. Delegates to the backend's @@ -105,6 +109,9 @@ async def wait_for_completion(self, timeout: Optional[float] = None) -> None: Args: timeout: Maximum seconds to wait. None means the backend's own default (typically 3 hours for ODTC). Pass a smaller value to fail faster. + report_interval: Log a progress update every this many seconds + (default 300 = 5 minutes). Pass 0 to disable. Forwarded to the + backend if it supports it. Raises: RuntimeError: If capability is not set up. @@ -113,7 +120,7 @@ async def wait_for_completion(self, timeout: Optional[float] = None) -> None: if not self._setup_finished: raise RuntimeError("Thermocycler capability is not set up.") if hasattr(self.backend, "wait_for_completion"): - await self.backend.wait_for_completion(timeout=timeout) + await self.backend.wait_for_completion(timeout=timeout, report_interval=report_interval) async def wait_for_first_progress(self, timeout: float = 60.0) -> Any: """Block until the backend reports non-None progress, or raise TimeoutError. diff --git a/pylabrobot/inheco/odtc/__init__.py b/pylabrobot/inheco/odtc/__init__.py index e44839fa850..200369c4ff9 100644 --- a/pylabrobot/inheco/odtc/__init__.py +++ b/pylabrobot/inheco/odtc/__init__.py @@ -5,6 +5,7 @@ from .driver import ODTCDriver from .model import ( FluidQuantity, + ODTCBackendParams, ODTCPID, ODTCMethodSet, ODTCProgress, @@ -22,6 +23,7 @@ "ODTCDoorBackend", "DoorStateUnknownError", "FluidQuantity", + "ODTCBackendParams", "ODTCProtocol", "ODTCPID", "ODTCMethodSet", diff --git a/pylabrobot/inheco/odtc/backend.py b/pylabrobot/inheco/odtc/backend.py index d09eebb0d1d..eb6f1ba4cc6 100644 --- a/pylabrobot/inheco/odtc/backend.py +++ b/pylabrobot/inheco/odtc/backend.py @@ -16,6 +16,7 @@ from .driver import ODTCDriver from .model import ( FluidQuantity, + ODTCBackendParams, ODTCPID, ODTCMethodSet, ODTCProgress, @@ -43,36 +44,12 @@ class ODTCThermocyclerBackend(ThermocyclerBackend): via ODTCProtocol.from_protocol) or ODTCProtocol directly (used as-is). Device variant is fixed at construction time (via ODTC(variant=...)). - Per-call config uses atomic params classes: - - RunProtocolParams → run_protocol() - - SetBlockTempParams → set_block_temperature() - - StepParams → Step.backend_params (per-step PID override) + Per-call compilation and execution config uses ODTCBackendParams (defined in + model.py — the single source of truth for compilation defaults). + Per-call temperature-control config uses SetBlockTempParams. + Per-step PID overrides use StepParams. """ - @dataclass - class RunProtocolParams(BackendParams): - """Per-call params for run_protocol(). Controls protocol compilation and execution. - - Pass as backend_params to run_protocol(). Device variant is taken from the - backend's construction-time variant (ODTC(variant=...)) and is not per-call. - - fluid_quantity takes precedence over the volume_ul arg on run_protocol() when - both are provided. If neither is set, defaults to FluidQuantity.UL_30_TO_74. - """ - fluid_quantity: Optional[FluidQuantity] = None - plate_type: int = 0 - post_heating: bool = True - pid_set: List[ODTCPID] = field(default_factory=lambda: [ODTCPID(number=1)]) - dynamic_pre_method_duration: bool = True - default_heating_slope: Optional[float] = None # None = hardware max - default_cooling_slope: Optional[float] = None # None = hardware max - name: Optional[str] = None - creator: Optional[str] = None - apply_overshoot: bool = True - """If True (default), auto-compute overshoot for steps without an explicit Ramp.overshoot. - If False, no overshoot is applied regardless of ramp rate or fluid quantity. - Explicit Ramp.overshoot values are always honoured either way.""" - @dataclass class SetBlockTempParams(BackendParams): """Per-call params for set_block_temperature(). Controls premethod compilation. @@ -123,8 +100,8 @@ def _clear_execution_state(self) -> None: def _resolve_odtc_protocol( self, protocol: Protocol, - params: "ODTCThermocyclerBackend.RunProtocolParams", - fluid_quantity: "FluidQuantity", + params: ODTCBackendParams, + fluid_quantity: FluidQuantity, ) -> ODTCProtocol: """Return ODTCProtocol, compiling from generic Protocol if needed. @@ -134,7 +111,7 @@ def _resolve_odtc_protocol( """ if isinstance(protocol, ODTCProtocol): # Pre-compiled protocol: fluid_quantity is already baked into the XML. - # volume_ul and RunProtocolParams.fluid_quantity have no effect here. + # volume_ul and ODTCBackendParams.fluid_quantity have no effect here. return protocol return _from_protocol( protocol, @@ -144,9 +121,9 @@ def _resolve_odtc_protocol( post_heating=params.post_heating, pid_set=list(params.pid_set), name=params.name, + apply_overshoot=params.apply_overshoot, default_heating_slope=params.default_heating_slope, default_cooling_slope=params.default_cooling_slope, - apply_overshoot=params.apply_overshoot, creator=params.creator, ) @@ -194,6 +171,7 @@ async def run_protocol( protocol: Protocol, volume_ul: Optional[float] = None, backend_params: Optional[BackendParams] = None, + dynamic_pre_method_duration: bool = True, ) -> None: """Upload and start a protocol. Non-blocking (fire-and-forget). @@ -202,10 +180,13 @@ async def run_protocol( volume_ul: Maximum sample volume in wells (µL). Used to auto-select FluidQuantity when backend_params.fluid_quantity is not set explicitly. Overridden by an explicit fluid_quantity in backend_params. - backend_params: ODTC-specific compilation and execution options. + backend_params: ODTCBackendParams with compilation options. Defaults to + ODTCBackendParams() when not provided or wrong type. + dynamic_pre_method_duration: When True (default), the device reports live + pre-heat remaining time. When False, uses the fixed 600 s estimate. """ - if not isinstance(backend_params, ODTCThermocyclerBackend.RunProtocolParams): - backend_params = ODTCThermocyclerBackend.RunProtocolParams() + if not isinstance(backend_params, ODTCBackendParams): + backend_params = ODTCBackendParams() if backend_params.fluid_quantity is not None: fq = backend_params.fluid_quantity @@ -217,7 +198,7 @@ async def run_protocol( odtc = self._resolve_odtc_protocol(protocol, backend_params, fluid_quantity=fq) await self.upload_protocol( odtc, - dynamic_pre_method_duration=backend_params.dynamic_pre_method_duration, + dynamic_pre_method_duration=dynamic_pre_method_duration, ) await self._execute_method(odtc) @@ -278,7 +259,11 @@ async def stop_protocol(self, backend_params: Optional[BackendParams] = None) -> await self._driver.send_command("StopMethod") self._clear_execution_state() - async def wait_for_completion(self, timeout: Optional[float] = None) -> None: + async def wait_for_completion( + self, + timeout: Optional[float] = None, + report_interval: float = 300.0, + ) -> None: """Block until the running method/premethod completes. Returns immediately if no method is currently running. Uses @@ -288,6 +273,9 @@ async def wait_for_completion(self, timeout: Optional[float] = None) -> None: Args: timeout: Maximum seconds to wait. Defaults to the backend timeout (3 hours). Pass a smaller value to fail faster. + report_interval: Log a progress update every this many seconds via + self.logger at INFO level (default 300 = 5 minutes). Pass 0 to + disable periodic reporting. Raises: asyncio.TimeoutError: If the method does not complete within timeout. @@ -295,7 +283,25 @@ async def wait_for_completion(self, timeout: Optional[float] = None) -> None: if self._current_fut is None or self._current_fut.done(): return effective = timeout if timeout is not None else self._timeout - await asyncio.wait_for(asyncio.shield(self._current_fut), timeout=effective) + if not report_interval: + await asyncio.wait_for(asyncio.shield(self._current_fut), timeout=effective) + return + + loop = asyncio.get_running_loop() + deadline = loop.time() + effective + while not self._current_fut.done(): + remaining = deadline - loop.time() + if remaining <= 0: + raise asyncio.TimeoutError() + wait_s = min(report_interval, remaining) + try: + await asyncio.wait_for(asyncio.shield(self._current_fut), timeout=wait_s) + return + except asyncio.TimeoutError: + pass + progress = await self.request_progress() + if progress is not None: + self.logger.info(str(progress)) # ------------------------------------------------------------------ # ODTC-specific public methods (available on the backend directly) @@ -401,8 +407,12 @@ async def upload_protocol( Typical workflow:: odtc_p = ODTCProtocol.from_protocol( - protocol, name="StandardPCR", - fluid_quantity=FluidQuantity.UL_30_TO_74, + protocol, + variant=96, + params=ODTCBackendParams( + fluid_quantity=FluidQuantity.UL_30_TO_74, + name="StandardPCR", + ), ) await odtc.tc.backend.upload_protocol(odtc_p) # later: diff --git a/pylabrobot/inheco/odtc/model.py b/pylabrobot/inheco/odtc/model.py index 65a5b140187..2072abb923e 100644 --- a/pylabrobot/inheco/odtc/model.py +++ b/pylabrobot/inheco/odtc/model.py @@ -18,6 +18,7 @@ from enum import Enum from typing import Any, Dict, List, Literal, Optional, Tuple +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.thermocycling.standard import Protocol, Stage, Step ODTCVariant = Literal[96, 384] @@ -196,6 +197,29 @@ class ODTCPID: i_lid: float = xml_field(tag="ILid", default=70.0) +@dataclass +class ODTCBackendParams(BackendParams): + """ODTC-specific backend parameters. Single source of truth for compilation defaults. + + Use as backend_params in run_protocol() or as params in ODTCProtocol.from_protocol(). + Both paths accept this object — defaults are defined here and nowhere else. + + Device variant is fixed at ODTC construction time and is not a per-call field. + fluid_quantity takes precedence over the volume_ul arg on run_protocol() when both + are provided. If neither is set, defaults to FluidQuantity.UL_30_TO_74. + """ + + fluid_quantity: Optional[FluidQuantity] = None + plate_type: int = 0 + post_heating: bool = True + pid_set: List[ODTCPID] = field(default_factory=lambda: [ODTCPID(number=1)]) + default_heating_slope: Optional[float] = None # None = hardware max + default_cooling_slope: Optional[float] = None # None = hardware max + name: Optional[str] = None + creator: Optional[str] = None + apply_overshoot: bool = True + + @dataclass class ODTCMethodSet: """Container for all methods and premethods uploaded as a set.""" @@ -352,65 +376,55 @@ def from_protocol( cls, protocol: "Protocol", variant: ODTCVariant = 96, - fluid_quantity: Optional["FluidQuantity"] = None, - plate_type: int = 0, - post_heating: bool = True, - pid_set: Optional[List["ODTCPID"]] = None, - name: Optional[str] = None, + params: Optional["ODTCBackendParams"] = None, lid_temperature: Optional[float] = None, start_lid_temperature: Optional[float] = None, - default_heating_slope: Optional[float] = None, - default_cooling_slope: Optional[float] = None, - apply_overshoot: bool = True, - creator: Optional[str] = None, description: Optional[str] = None, datetime: Optional[str] = None, ) -> "ODTCProtocol": """Compile a Protocol into a device-ready ODTCProtocol. - When ``name`` is provided, ``is_scratch=False`` and the method persists - on the device across sessions. Without a name it is uploaded as a + Compilation defaults (fluid_quantity, post_heating, pid_set, etc.) are + taken from ``params`` (an ODTCBackendParams). This is the same object + accepted by run_protocol(), so defaults can never drift between the two + paths. + + When ``params.name`` is provided, ``is_scratch=False`` and the method + persists on the device across sessions. Without a name it is uploaded as a temporary scratch method (deleted on next Reset). Args: protocol: Source protocol with stages/steps. variant: ODTC variant (96 or 384). - fluid_quantity: Fluid volume range for thermal compensation. - Defaults to FluidQuantity.UL_30_TO_74. - plate_type: Plate type code. - post_heating: Whether to keep the block warm after the method. - pid_set: PID configurations. Defaults to [ODTCPID(number=1)]. - name: Method name stored on the device. None = scratch/unnamed. + params: Compilation configuration. Defaults to ODTCBackendParams() when + not provided. See ODTCBackendParams for field descriptions. lid_temperature: Default lid temperature (°C). None = hardware max. - start_lid_temperature: Lid temperature during preheat (if different). - default_heating_slope: Default heating rate (°C/s). None = hardware max. - default_cooling_slope: Default cooling rate (°C/s). None = hardware max. - apply_overshoot: If True (default), automatically compute overshoot for - steps that do not specify an explicit Ramp.overshoot. If False, no - overshoot is applied regardless of ramp rate or fluid quantity. - Explicit Ramp.overshoot values are always honoured either way. - creator: Author string for metadata. - description: Description for metadata. - datetime: ISO timestamp; generated if None. + Advanced override; most callers do not need this. + start_lid_temperature: Lid temperature during preheat. None = lid_temperature. + Advanced override; most callers do not need this. + description: Description string stored in device metadata. + datetime: ISO timestamp stored in device metadata; auto-generated if None. Returns: ODTCProtocol ready for upload or direct execution. """ from .protocol import _from_protocol # lazy import — avoids circular dependency + p = params if params is not None else ODTCBackendParams() + fq = p.fluid_quantity if p.fluid_quantity is not None else FluidQuantity.UL_30_TO_74 return _from_protocol( protocol, variant=variant, - fluid_quantity=fluid_quantity, - plate_type=plate_type, - post_heating=post_heating, - pid_set=pid_set, - name=name, + fluid_quantity=fq, + plate_type=p.plate_type, + post_heating=p.post_heating, + pid_set=list(p.pid_set), + name=p.name, + apply_overshoot=p.apply_overshoot, + default_heating_slope=p.default_heating_slope, + default_cooling_slope=p.default_cooling_slope, + creator=p.creator, lid_temperature=lid_temperature, start_lid_temperature=start_lid_temperature, - default_heating_slope=default_heating_slope, - default_cooling_slope=default_cooling_slope, - apply_overshoot=apply_overshoot, - creator=creator, description=description, datetime=datetime, ) @@ -421,6 +435,13 @@ def from_protocol( # ============================================================================= +def _fmt_duration(seconds: float) -> str: + """Format a duration in seconds as 'Xm YYs'.""" + m = int(seconds) // 60 + s = int(seconds) % 60 + return f"{m}m {s:02d}s" + + @dataclass class ODTCProgress: """Progress for a run, built from DataEvent payload and optional protocol.""" @@ -436,25 +457,38 @@ class ODTCProgress: remaining_hold_s: float = 0.0 estimated_duration_s: Optional[float] = None remaining_duration_s: Optional[float] = None + is_premethod: bool = False def format_progress_log_message(self) -> str: - step_total = self.total_step_count - cycle_total = self.total_cycle_count - step_idx = self.current_step_index - cycle_idx = self.current_cycle_index - setpoint = self.target_temp_c if self.target_temp_c is not None else 0.0 - block = self.current_temp_c or 0.0 - lid = self.lid_temp_c or 0.0 - if step_total and cycle_total: - return ( - f"ODTC progress: elapsed {self.elapsed_s:.0f}s, step {step_idx + 1}/{step_total}, " - f"cycle {cycle_idx + 1}/{cycle_total}, setpoint {setpoint:.1f}°C, " - f"block {block:.1f}°C, lid {lid:.1f}°C" + elapsed_str = _fmt_duration(self.elapsed_s) + if self.estimated_duration_s is not None: + time_bracket = f"[{elapsed_str} / ~{_fmt_duration(self.estimated_duration_s)}]" + else: + time_bracket = f"[{elapsed_str} elapsed]" + + temp_parts = [] + if self.current_temp_c is not None: + t = f"block {self.current_temp_c:.1f}C" + if self.target_temp_c is not None: + t += f" (target {self.target_temp_c:.1f}C)" + temp_parts.append(t) + elif self.target_temp_c is not None: + temp_parts.append(f"target {self.target_temp_c:.1f}C") + if self.lid_temp_c is not None: + temp_parts.append(f"lid {self.lid_temp_c:.1f}C") + temp_str = ", ".join(temp_parts) + + if self.total_step_count and self.total_cycle_count: + ctx = ( + f"step {self.current_step_index + 1}/{self.total_step_count}, " + f"cycle {self.current_cycle_index + 1}/{self.total_cycle_count}" ) - return ( - f"ODTC progress: elapsed {self.elapsed_s:.0f}s, block {block:.1f}°C " - f"(target {setpoint:.1f}°C), lid {lid:.1f}°C" - ) + return f"ODTC {time_bracket} {ctx}" + (f", {temp_str}" if temp_str else "") + + if self.is_premethod: + return f"ODTC {time_bracket} preheating" + (f", {temp_str}" if temp_str else "") + + return f"ODTC {time_bracket}" + (f" {temp_str}" if temp_str else "") def __str__(self) -> str: return self.format_progress_log_message() diff --git a/pylabrobot/inheco/odtc/odtc.py b/pylabrobot/inheco/odtc/odtc.py index 4b4c14a6b8f..674b13b77a2 100644 --- a/pylabrobot/inheco/odtc/odtc.py +++ b/pylabrobot/inheco/odtc/odtc.py @@ -40,7 +40,7 @@ class ODTC(Resource, Device): # or with explicit params: # await odtc.tc.run_protocol( # protocol, - # backend_params=ODTCThermocyclerBackend.RunProtocolParams( + # backend_params=ODTCBackendParams( # fluid_quantity=FluidQuantity.UL_30_TO_74, # ), # ) diff --git a/pylabrobot/inheco/odtc/protocol.py b/pylabrobot/inheco/odtc/protocol.py index 3d97e85e0ea..94440834478 100644 --- a/pylabrobot/inheco/odtc/protocol.py +++ b/pylabrobot/inheco/odtc/protocol.py @@ -228,32 +228,27 @@ def _transform_stages( def _from_protocol( protocol: Protocol, - variant: ODTCVariant = 96, - fluid_quantity: Optional["FluidQuantity"] = None, - plate_type: int = 0, - post_heating: bool = True, - pid_set: Optional[List[ODTCPID]] = None, + variant: ODTCVariant, + fluid_quantity: "FluidQuantity", + plate_type: int, + post_heating: bool, + pid_set: List[ODTCPID], + apply_overshoot: bool, name: Optional[str] = None, lid_temperature: Optional[float] = None, start_lid_temperature: Optional[float] = None, default_heating_slope: Optional[float] = None, default_cooling_slope: Optional[float] = None, - apply_overshoot: bool = True, creator: Optional[str] = None, description: Optional[str] = None, datetime: Optional[str] = None, ) -> ODTCProtocol: """Private implementation of ODTCProtocol.from_protocol(). - Use ODTCProtocol.from_protocol() as the public API. + All compilation config params are required — callers must resolve defaults + from ODTCBackendParams before calling. Use ODTCProtocol.from_protocol() as + the public API. """ - from .model import FluidQuantity as _FQ - if fluid_quantity is None: - fluid_quantity = _FQ.UL_30_TO_74 - - if pid_set is None: - pid_set = [ODTCPID(number=1)] - constraints = get_constraints(variant) effective_lid = lid_temperature if lid_temperature is not None else constraints.max_lid_temp @@ -662,4 +657,5 @@ def build_progress_from_data_event( remaining_hold_s=position.get("remaining_hold_s") or 0.0, estimated_duration_s=est_s, remaining_duration_s=rem_s, + is_premethod=(odtc_protocol.kind == "premethod"), ) diff --git a/pylabrobot/inheco/odtc/tests/protocol_tests.py b/pylabrobot/inheco/odtc/tests/protocol_tests.py index 65409f82792..2d147afc977 100644 --- a/pylabrobot/inheco/odtc/tests/protocol_tests.py +++ b/pylabrobot/inheco/odtc/tests/protocol_tests.py @@ -11,7 +11,7 @@ Stage, Step, ) -from pylabrobot.inheco.odtc.model import ODTCPID, ODTCProtocol +from pylabrobot.inheco.odtc.model import FluidQuantity, ODTCPID, ODTCProtocol from pylabrobot.inheco.odtc.protocol import ( _calc_overshoot, _from_protocol, @@ -78,19 +78,25 @@ def test_overshoot_capped_at_102(self): class TestFromProtocol(unittest.TestCase): def test_produces_odtc_protocol(self): p = _pcr_protocol() - odtc = _from_protocol(p, variant=96, fluid_quantity=1) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True) self.assertIsInstance(odtc, ODTCProtocol) self.assertIsInstance(odtc, Protocol) def test_stage_count_preserved(self): p = _pcr_protocol() - odtc = _from_protocol(p, variant=96, fluid_quantity=1) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True) self.assertEqual(len(odtc.stages), 3) self.assertEqual(odtc.stages[1].repeats, 35) def test_step_count_preserved(self): p = _pcr_protocol() - odtc = _from_protocol(p, variant=96, fluid_quantity=1) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True) self.assertEqual(len(odtc.stages[1].steps), 3) def test_slope_clamped_to_hardware_max(self): @@ -98,7 +104,9 @@ def test_slope_clamped_to_hardware_max(self): p = Protocol( stages=[Stage(steps=[Step(95.0, 30.0, ramp=Ramp(rate=99.9))], repeats=1)], ) - odtc = _from_protocol(p, variant=96) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True) step = odtc.stages[0].steps[0] self.assertLessEqual(step.ramp.rate, 4.4 + 0.01) @@ -107,7 +115,9 @@ def test_overshoot_computed_for_valid_step(self): p = Protocol( stages=[Stage(steps=[Step(95.0, 30.0, ramp=Ramp(rate=4.4))], repeats=1)], ) - odtc = _from_protocol(p, variant=96, fluid_quantity=1) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True) step = odtc.stages[0].steps[0] # Not necessarily has overshoot (depends on prev_temp=25, delta=70 > 5 and target > 35) # So we just check it's a Ramp object with rate set @@ -120,7 +130,9 @@ def test_user_overshoot_honoured(self): p = Protocol( stages=[Stage(steps=[Step(95.0, 30.0, ramp=Ramp(rate=4.4, overshoot=user_os))], repeats=1)], ) - odtc = _from_protocol(p, variant=96, fluid_quantity=1) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True) step = odtc.stages[0].steps[0] self.assertIsNotNone(step.ramp.overshoot) self.assertAlmostEqual(step.ramp.overshoot.target_temp, 3.0) @@ -128,31 +140,41 @@ def test_user_overshoot_honoured(self): def test_lid_temperature_applied(self): p = Protocol(stages=[Stage(steps=[Step(95.0, 30.0)], repeats=1)]) - odtc = _from_protocol(p, variant=96, lid_temperature=105.0) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True, lid_temperature=105.0) step = odtc.stages[0].steps[0] self.assertAlmostEqual(step.lid_temperature, 105.0) def test_name_sets_is_scratch_false(self): p = Protocol(stages=[], name="MyPCR") - odtc = _from_protocol(p, variant=96, name="MyPCR") + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True, name="MyPCR") self.assertFalse(odtc.is_scratch) self.assertEqual(odtc.name, "MyPCR") def test_no_name_sets_is_scratch_true(self): p = Protocol(stages=[]) - odtc = _from_protocol(p, variant=96) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True) self.assertTrue(odtc.is_scratch) def test_start_block_temperature_is_first_step(self): p = _pcr_protocol() - odtc = _from_protocol(p, variant=96) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True) self.assertAlmostEqual(odtc.start_block_temperature, 95.0) def test_inner_stages_preserved(self): inner = Stage(steps=[Step(55.0, 30.0), Step(72.0, 60.0)], repeats=35) outer = Stage(steps=[Step(95.0, 10.0)], repeats=1, inner_stages=[inner]) p = Protocol(stages=[outer]) - odtc = _from_protocol(p, variant=96, fluid_quantity=1) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True) self.assertEqual(len(odtc.stages[0].inner_stages), 1) self.assertEqual(odtc.stages[0].inner_stages[0].repeats, 35) @@ -160,7 +182,9 @@ def test_inner_stages_preserved(self): class TestExpandedStepCount(unittest.TestCase): def _make_pcr_odtc(self) -> ODTCProtocol: p = _pcr_protocol() - return _from_protocol(p, variant=96, fluid_quantity=1) + return _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True) def test_step_count_with_loops(self): odtc = self._make_pcr_odtc() @@ -176,7 +200,9 @@ def test_cycle_count(self): class TestEstimateDuration(unittest.TestCase): def test_duration_positive(self): p = _pcr_protocol() - odtc = _from_protocol(p, variant=96, fluid_quantity=1) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True) dur = estimate_method_duration_seconds(odtc) self.assertGreater(dur, 0) @@ -212,7 +238,9 @@ def test_no_protocol_returns_basic_progress(self): def test_with_protocol_returns_enriched_progress(self): p = _pcr_protocol() - odtc = _from_protocol(p, variant=96, fluid_quantity=1) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True) payload = self._make_payload(150.0) progress = build_progress_from_data_event(payload, odtc_protocol=odtc) self.assertAlmostEqual(progress.elapsed_s, 150.0) @@ -222,25 +250,32 @@ def test_with_protocol_returns_enriched_progress(self): def test_str_format(self): p = _pcr_protocol() - odtc = _from_protocol(p, variant=96, fluid_quantity=1) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True) payload = self._make_payload(5.0) progress = build_progress_from_data_event(payload, odtc_protocol=odtc) msg = str(progress) - self.assertIn("elapsed", msg) + self.assertIn("ODTC", msg) + self.assertIn("step", msg) class TestApplyOvershoot(unittest.TestCase): def test_apply_overshoot_false_produces_no_auto_overshoot(self): """apply_overshoot=False: steps with large temp delta get no auto-computed overshoot.""" p = Protocol(stages=[Stage(steps=[Step(temperature=95.0, hold_seconds=30.0)], repeats=1)]) - odtc = _from_protocol(p, variant=96, fluid_quantity=1, apply_overshoot=False) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=False) step = odtc.stages[0].steps[0] self.assertIsNone(step.ramp.overshoot) def test_apply_overshoot_true_computes_for_large_delta(self): """apply_overshoot=True (default): large delta step gets overshoot computed.""" p = Protocol(stages=[Stage(steps=[Step(temperature=95.0, hold_seconds=30.0, ramp=Ramp(rate=4.4))], repeats=1)]) - odtc = _from_protocol(p, variant=96, fluid_quantity=1, apply_overshoot=True) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=True) step = odtc.stages[0].steps[0] # delta 95-25=70 > threshold; fluid_quantity=1 → overshoot should be computed self.assertIsNotNone(step.ramp.overshoot) @@ -253,17 +288,20 @@ def test_explicit_overshoot_honoured_when_apply_false(self): steps=[Step(temperature=95.0, hold_seconds=30.0, ramp=Ramp(rate=4.4, overshoot=user_os))], repeats=1, )]) - odtc = _from_protocol(p, variant=96, fluid_quantity=1, apply_overshoot=False) + odtc = _from_protocol(p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, + plate_type=0, post_heating=True, pid_set=[ODTCPID(number=1)], + apply_overshoot=False) step = odtc.stages[0].steps[0] self.assertIsNotNone(step.ramp.overshoot) self.assertAlmostEqual(step.ramp.overshoot.target_temp, 3.0) def test_from_protocol_classmethod_is_proper_classmethod(self): """ODTCProtocol.from_protocol is a proper classmethod, not a monkey-patch.""" - from pylabrobot.inheco.odtc.model import ODTCProtocol, FluidQuantity + from pylabrobot.inheco.odtc.model import ODTCProtocol, FluidQuantity, ODTCBackendParams p = _pcr_protocol() odtc = ODTCProtocol.from_protocol( - p, variant=96, fluid_quantity=FluidQuantity.UL_30_TO_74, name="TestPCR" + p, variant=96, + params=ODTCBackendParams(fluid_quantity=FluidQuantity.UL_30_TO_74, name="TestPCR"), ) self.assertIsInstance(odtc, ODTCProtocol) self.assertEqual(odtc.name, "TestPCR") @@ -272,9 +310,12 @@ def test_from_protocol_classmethod_is_proper_classmethod(self): def test_from_protocol_classmethod_apply_overshoot_false(self): """ODTCProtocol.from_protocol apply_overshoot=False works via classmethod.""" - from pylabrobot.inheco.odtc.model import ODTCProtocol + from pylabrobot.inheco.odtc.model import ODTCProtocol, FluidQuantity, ODTCBackendParams p = Protocol(stages=[Stage(steps=[Step(temperature=95.0, hold_seconds=30.0, ramp=Ramp(rate=4.4))], repeats=1)]) - odtc = ODTCProtocol.from_protocol(p, variant=96, fluid_quantity=1, apply_overshoot=False) + odtc = ODTCProtocol.from_protocol( + p, variant=96, + params=ODTCBackendParams(fluid_quantity=FluidQuantity.UL_30_TO_74, apply_overshoot=False), + ) step = odtc.stages[0].steps[0] self.assertIsNone(step.ramp.overshoot) diff --git a/pylabrobot/inheco/odtc/tests/sila_interface_tests.py b/pylabrobot/inheco/odtc/tests/sila_interface_tests.py index efb651908b2..d424d9310f7 100644 --- a/pylabrobot/inheco/odtc/tests/sila_interface_tests.py +++ b/pylabrobot/inheco/odtc/tests/sila_interface_tests.py @@ -7,7 +7,7 @@ from pylabrobot.capabilities.thermocycling.standard import Protocol, Ramp, Stage, Step from pylabrobot.inheco.odtc.backend import ODTCThermocyclerBackend from pylabrobot.inheco.odtc.driver import ODTCDriver -from pylabrobot.inheco.odtc.model import ODTCPID, ODTCProtocol +from pylabrobot.inheco.odtc.model import FluidQuantity, ODTCBackendParams, ODTCPID, ODTCProtocol from pylabrobot.inheco.scila.inheco_sila_interface import SiLAError, SiLAState @@ -166,21 +166,18 @@ def test_unknown_device_error_code_also_raises(self): # --------------------------------------------------------------------------- -class TestRunProtocolParams(unittest.TestCase): +class TestODTCBackendParams(unittest.TestCase): def test_default_params(self): - p = ODTCThermocyclerBackend.RunProtocolParams() - self.assertEqual(p.variant, 96) - self.assertEqual(p.fluid_quantity, 1) + p = ODTCBackendParams() + self.assertIsNone(p.fluid_quantity) self.assertTrue(p.post_heating) - self.assertTrue(p.dynamic_pre_method_duration) self.assertIsNone(p.name) + self.assertEqual(p.plate_type, 0) + self.assertTrue(p.apply_overshoot) def test_custom_params(self): - p = ODTCThermocyclerBackend.RunProtocolParams( - variant=384, fluid_quantity=2, name="PCR_384" - ) - self.assertEqual(p.variant, 384) - self.assertEqual(p.fluid_quantity, 2) + p = ODTCBackendParams(fluid_quantity=FluidQuantity.UL_75_TO_100, name="PCR_384") + self.assertEqual(p.fluid_quantity, FluidQuantity.UL_75_TO_100) self.assertEqual(p.name, "PCR_384") def test_step_params_default(self): @@ -200,8 +197,8 @@ def test_plain_protocol_compiles_to_odtc_protocol(self): stages=[Stage(steps=[Step(95.0, 30.0)], repeats=1)], name="TestPCR", ) - params = ODTCThermocyclerBackend.RunProtocolParams(variant=96, fluid_quantity=1) - odtc = backend._resolve_odtc_protocol(protocol, params) + params = ODTCBackendParams(fluid_quantity=FluidQuantity.UL_30_TO_74) + odtc = backend._resolve_odtc_protocol(protocol, params, fluid_quantity=FluidQuantity.UL_30_TO_74) self.assertIsInstance(odtc, ODTCProtocol) self.assertEqual(len(odtc.stages), 1) @@ -214,8 +211,8 @@ def test_odtc_protocol_used_directly(self): start_block_temperature=25.0, start_lid_temperature=110.0, pid_set=[ODTCPID(number=1)], ) - params = ODTCThermocyclerBackend.RunProtocolParams() - resolved = backend._resolve_odtc_protocol(odtc, params) + params = ODTCBackendParams() + resolved = backend._resolve_odtc_protocol(odtc, params, fluid_quantity=FluidQuantity.UL_30_TO_74) self.assertIs(resolved, odtc) diff --git a/pylabrobot/inheco/odtc/xml.py b/pylabrobot/inheco/odtc/xml.py index e82c1fa8d13..9092fcc5b36 100644 --- a/pylabrobot/inheco/odtc/xml.py +++ b/pylabrobot/inheco/odtc/xml.py @@ -464,7 +464,13 @@ def _step_to_xml_element( def _parse_method_element_to_odtc_protocol(elem: ET.Element) -> ODTCProtocol: - """Parse a element into ODTCProtocol with Stage tree.""" + """Parse a element into ODTCProtocol with Stage tree. + + Note: missing-field fallbacks here (post_heating=False, fluid_quantity=0 / + UL_10_TO_29) reflect whatever the device stored. They are intentionally + different from ODTCBackendParams compilation defaults and should not be + changed to match them. + """ name = elem.attrib["methodName"] creator = elem.attrib.get("creator") description = elem.attrib.get("description") @@ -505,7 +511,11 @@ def _parse_method_element_to_odtc_protocol(elem: ET.Element) -> ODTCProtocol: def _parse_premethod_element_to_odtc_protocol(elem: ET.Element) -> ODTCProtocol: - """Parse a element into ODTCProtocol (kind='premethod').""" + """Parse a element into ODTCProtocol (kind='premethod'). + + Note: fluid_quantity=0 and post_heating=False are device-storage values, not + compilation defaults. See ODTCBackendParams for compilation defaults. + """ name = elem.attrib.get("methodName") or "" creator = elem.attrib.get("creator") description = elem.attrib.get("description")