diff --git a/pylabrobot/thermocycling/backend.py b/pylabrobot/thermocycling/backend.py
index cf0955364ae..30221ada5c0 100644
--- a/pylabrobot/thermocycling/backend.py
+++ b/pylabrobot/thermocycling/backend.py
@@ -41,13 +41,45 @@ async def deactivate_lid(self):
"""Deactivate thermocycler lid."""
@abstractmethod
- async def run_protocol(self, protocol: Protocol, block_max_volume: float):
- """Execute thermocycler protocol run.
+ async def run_protocol(
+ self,
+ protocol: Protocol,
+ block_max_volume: float,
+ **kwargs,
+ ):
+ """Execute thermocycler protocol run (always non-blocking).
+
+ Starts the protocol and returns an execution handle immediately. To block
+ until completion, await the handle (e.g. await handle.wait()) or use
+ wait_for_profile_completion() on the thermocycler.
Args:
protocol: Protocol object containing stages with steps and repeats.
block_max_volume: Maximum block volume (µL) for safety.
+ **kwargs: Backend-specific options (e.g. ODTC accepts config=ODTCConfig).
+
+ Returns:
+ Execution handle (backend-specific; e.g. MethodExecution for ODTC), or
+ None for backends that do not return a handle. Caller can await
+ handle.wait() or use wait_for_profile_completion() to block until done.
+ """
+
+ async def run_stored_protocol(self, name: str, wait: bool = False, **kwargs):
+ """Execute a stored protocol by name (optional; backends that support it override).
+
+ Args:
+ name: Name of the stored protocol to run.
+ wait: If False (default), start and return an execution handle. If True,
+ block until done then return the (completed) handle.
+ **kwargs: Backend-specific options.
+
+ Returns:
+ Execution handle (backend-specific). Same as run_protocol.
+
+ Raises:
+ NotImplementedError: This backend does not support running stored protocols by name.
"""
+ raise NotImplementedError("This backend does not support running stored protocols by name.")
@abstractmethod
async def get_block_current_temperature(self) -> List[float]:
diff --git a/pylabrobot/thermocycling/chatterbox.py b/pylabrobot/thermocycling/chatterbox.py
index 1c45e40752d..af4a23ac3e5 100644
--- a/pylabrobot/thermocycling/chatterbox.py
+++ b/pylabrobot/thermocycling/chatterbox.py
@@ -97,7 +97,7 @@ async def deactivate_lid(self):
print("Deactivating lid.")
self._state.lid_target = None
- async def run_protocol(self, protocol: Protocol, block_max_volume: float):
+ async def run_protocol(self, protocol: Protocol, block_max_volume: float, **kwargs):
"""Run a protocol with stages and repeats."""
print("Running protocol:")
diff --git a/pylabrobot/thermocycling/inheco/README.md b/pylabrobot/thermocycling/inheco/README.md
new file mode 100644
index 00000000000..884d8ac96c3
--- /dev/null
+++ b/pylabrobot/thermocycling/inheco/README.md
@@ -0,0 +1,245 @@
+# ODTC (On-Deck Thermocycler) Implementation Guide
+
+## Overview
+
+Interface for Inheco ODTC thermocyclers via SiLA (SOAP over HTTP). Asynchronous method execution (blocking and non-blocking), round-trip through **ODTC types** (ODTC XML ↔ ODTCProtocol / ODTCStep / ODTCStage), parallel commands (e.g. read temperatures during run), and **progress from DataEvents** via a single type: **ODTCProgress**.
+
+- **Primary API:** Run protocols by name: `run_stored_protocol(name)`. Protocol is already on the device; no editing.
+- **Secondary:** (1) **Edited ODTCProtocol** — get from device, change only **hold times and cycle count**; upload and run by name. Do not change temperature setpoints (overshoots are temperature- and ramp-specific). (2) **Protocol + ODTCConfig** — custom run: `protocol_to_odtc_protocol(protocol, config=get_default_config())`, then `run_protocol(odtc, block_max_volume)`.
+
+**Architecture:** `ODTCSiLAInterface` (SiLA SOAP, state machine; stores raw DataEvent payloads) → `ODTCBackend` (method execution, protocol conversion; builds **ODTCProgress** from latest payload + protocol) → `ODTCThermocycler` (resource; preferred) or generic `Thermocycler` with `ODTCBackend`. Types in `odtc_model.py`.
+
+**Progress:** One type — **ODTCProgress**. Built from raw DataEvent payload + optional protocol via `ODTCProgress.from_data_event(payload, odtc)`. Provides elapsed_s, temperatures, step/cycle/hold (from protocol when registered), and **estimated_duration_s** / **remaining_duration_s** (we compute these; device does not send them). Use `get_progress_snapshot()`, `get_hold_time()`, `get_current_step_index()`, `get_current_cycle_index()`; callback: `ODTCBackend(..., progress_callback=...)` receives ODTCProgress.
+
+**Tutorial:** `odtc_tutorial.ipynb`. Sections: **Setup** → **Workflows** → **Types and conversion** → **Commands** → **Device protocols** → **DataEvents and progress** → **Error handling** → **Best practices** → **Complete example**.
+
+## Setup
+
+**Preferred: ODTCThermocycler** (owns dimensions and backend):
+
+```python
+from pylabrobot.resources import Coordinate
+from pylabrobot.thermocycling.inheco import ODTCThermocycler
+
+tc = ODTCThermocycler(
+ name="odtc",
+ odtc_ip="192.168.1.100",
+ variant=96, # or 384; or 960000 / 384000
+ child_location=Coordinate(0, 0, 0),
+)
+await tc.setup() # HTTP event receiver + Reset + Initialize → idle
+```
+
+**Alternative:** Generic `Thermocycler` with `ODTCBackend(odtc_ip=..., variant=...)` for custom dimensions.
+
+**Duration:** Device does not return duration. We set **estimated_duration_s** (PreMethod = 10 min; Method = from protocol or device; fallback = effective lifetime). **remaining_duration_s** = max(0, estimated_duration_s - elapsed_s). Used for `handle.estimated_remaining_time` and progress.
+
+**Options:** `setup(full=True, simulation_mode=False, max_attempts=3, retry_backoff_base_seconds=1.0)`. Use `full=False` to only start the event receiver (e.g. **Reconnecting after session loss**).
+
+**Simulation:** `await tc.backend.reset(simulation_mode=True)`; exit with `simulation_mode=False`. Commands return immediately with estimated duration.
+
+**Reconnecting after session loss:** If the connection was lost while a method is running, create a new backend/thermocycler and call `await tc.backend.setup(full=False)` (do not full setup—that would Reset and abort). Use `wait_for_completion_by_time(...)` or a handle's `wait_resumable()` to wait; then `setup(full=True)` if needed for later commands.
+
+**Cleanup:** `await tc.stop()`.
+
+## Workflows
+
+### 1. Run stored protocol by name (primary)
+
+Protocol is already on the device; single call, no upload. Preferred usage.
+
+**PreMethod before protocol:** You must run a **preMethod** (set block/mount temperature) **before** running a protocol by name. The block and lid temperatures from `set_block_temperature(...)` must **match** the protocol’s initial temperatures (e.g. the method’s `start_block_temperature` and initial lid temp). Run `set_block_temperature` to reach those temps, wait for completion, then call `run_stored_protocol(name)`.
+
+```python
+protocol_list = await tc.backend.list_protocols()
+# Optional: get protocol to read initial temps for preMethod
+odtc = await tc.backend.get_protocol("PCR_30cycles")
+if odtc is not None:
+ # PreMethod: match block/lid to protocol initial temps; wait for completion then run
+ await tc.set_block_temperature(
+ [odtc.start_block_temperature],
+ lid_temperature=odtc.start_lid_temperature if odtc.start_lid_temperature else None,
+ wait=True,
+ )
+execution = await tc.run_stored_protocol("PCR_30cycles") # returns handle (wait=False default)
+await execution # block until done; or use wait=True to block on the call
+```
+
+### 2. Edited ODTCProtocol (secondary)
+
+Get from device → modify **only hold times and cycle count** → upload → run. Preserves ODTC parameters (overshoot, slopes) because temperatures are unchanged.
+
+**Avoid modifying temperature parameters** (e.g. `plateau_temperature`) on device-derived protocols: overshoots are temperature-difference and ramp-speed specific, so the device’s tuning no longer matches and thermal performance can suffer.
+
+```python
+odtc = await tc.backend.get_protocol("PCR_30cycles")
+if odtc is None:
+ raise ValueError("Protocol not found")
+odtc.steps[1].plateau_time = 45.0 # hold time (s) — safe
+# odtc.steps[4].loop_number = 35 # cycle count (adjust step to match protocol) — safe
+# Do NOT change odtc.steps[i].plateau_temperature (overshoot/tuning is temperature-specific)
+await tc.backend.upload_protocol(odtc, name="PCR_35cycles")
+await tc.run_stored_protocol("PCR_35cycles")
+```
+
+For cycle count, set `loop_number` on the step that defines the cycle (the one with `goto_number`). Alternatively use **Workflow 4** to edit in Protocol form (`stage.repeats`).
+
+### 3. Set block and lid temperature (preMethod)
+
+Hold block (and lid) at a set temperature; ODTC runs a **PreMethod** (no direct SetBlockTemperature command). Run this **before** a protocol by name so block and lid match the protocol’s initial temperatures; then run the protocol. Default lid 110°C (96-well) or 115°C (384-well).
+
+```python
+# Returns handle by default (wait=False); await it to block
+await tc.set_block_temperature([95.0]) # or: h = await tc.set_block_temperature([95.0]); await h
+await tc.set_block_temperature([37.0], lid_temperature=110.0)
+# Block on call: await tc.set_block_temperature([95.0], wait=True)
+```
+
+Estimated duration for this path is 10 minutes.
+
+### 4. Custom run (Protocol + generic ODTCConfig) (secondary)
+
+When you have a generic **Protocol** (e.g. from a builder): attach a generic **ODTCConfig** (e.g. `get_default_config()`), convert to ODTCProtocol, run. New protocols use default overshoot; for best thermal performance prefer running stored protocols by name (Workflow 1) or edited ODTCProtocol with only non-temperature changes (Workflow 2).
+
+```python
+from pylabrobot.thermocycling.inheco.odtc_model import protocol_to_odtc_protocol
+from pylabrobot.thermocycling.standard import Protocol, Stage, Step
+
+config = tc.backend.get_default_config(block_max_volume=50.0)
+protocol = Protocol(stages=[Stage(steps=[Step(temperature=[95.0], hold_seconds=30.0)], repeats=30)])
+odtc = protocol_to_odtc_protocol(protocol, config=config)
+await tc.run_protocol(odtc, block_max_volume=50.0)
+```
+
+### 5. From XML file
+
+`method_set = parse_method_set_file("my_methods.xml")` (from `odtc_model`), then `await tc.backend.upload_method_set(method_set)` and `await tc.run_stored_protocol("PCR_30cycles")`.
+
+## Types and conversion
+
+### ODTC types
+
+| Type | Role |
+|------|------|
+| **ODTCStep** | Single temperature step: `plateau_temperature`, `plateau_time`, `slope`, overshoot, `goto_number`, `loop_number`. |
+| **ODTCStage** | `steps: List[ODTCStep]`, optional `inner_stages`. |
+| **ODTCProtocol** | **Methods** (cycling) and **premethods** (hold block/lid); `kind='method'` or `'premethod'`. For methods, **`.steps`** is the main representation (flat list with goto/loop). |
+| **ODTCProgress** | Single progress type: built from raw DataEvent payload + optional protocol via `ODTCProgress.from_data_event(payload, odtc)`. Elapsed, temps, step/cycle/hold (when protocol registered), **estimated_duration_s** and **remaining_duration_s** (we compute; device does not send). Returned by `get_progress_snapshot()` and passed to **progress_callback**. |
+
+When editing a device-derived ODTCProtocol (secondary usage), change only **hold times** (`plateau_time`) and **cycle count** (`loop_number`). Avoid changing temperature setpoints: overshoots are temperature-difference and ramp-speed specific.
+
+### Protocol + ODTCConfig (custom runs only)
+
+Use when you have a **Protocol** and want to run it on ODTC. **`odtc_method_to_protocol(odtc)`** returns `(Protocol, ODTCConfig)`; **`protocol_to_odtc_protocol(protocol, config=config)`** converts back. Conversion is **lossless** when you keep the same `ODTCConfig`.
+
+**What ODTCConfig preserves:** Method-level: `name`, `fluid_quantity`, `variant`, `lid_temperature`, `post_heating`, `pid_set`, etc. Per-step (`config.step_settings[step_index]`): `slope`, overshoot (`overshoot_temperature`, `overshoot_time`, `overshoot_slope1`/`overshoot_slope2`), `lid_temp`, `pid_number`. **Protocol** holds temperatures, hold times, stage structure, repeat counts.
+
+**Overshoot:** ODTC-specific; not in generic Protocol. Overshoots are **temperature-difference and ramp-speed specific** — tuning is valid for the setpoints it was designed for. Preserved in `ODTCConfig.step_settings`. When editing device-derived protocols, avoid changing temperatures; when building new protocols (Protocol + generic config), default overshoot applies.
+
+**Conversion summary:** Device → ODTCProtocol: `get_protocol(name)`. ODTCProtocol → Protocol view: `odtc_protocol_to_protocol(odtc)` or `odtc_method_to_protocol(odtc)` for editing then `protocol_to_odtc_protocol(protocol, config=config)` and upload. Protocol + ODTCConfig → ODTCProtocol: `protocol_to_odtc_protocol(protocol, config=config)`.
+
+**Method name:** Device identifies protocols by string (e.g. `"PCR_30cycles"`, `"plr_currentProtocol"`). Use with `run_stored_protocol(name)`, `get_protocol(name)`, `list_protocols()`.
+
+**API:** `tc.run_stored_protocol(name)`, `tc.run_protocol(odtc, block_max_volume)` (ODTCProtocol or Protocol). Backend: `list_protocols()`, `get_protocol(name)` → `Optional[ODTCProtocol]`, `upload_protocol(protocol_or_odtc, name=..., config=...)` (config only when protocol is `Protocol`), `set_block_temperature(...)`, `get_default_config()`, `execute_method(method_name)`.
+
+## Commands and execution
+
+**Default: async.** Execution commands (**run_stored_protocol**, **set_block_temperature**, **run_protocol**) default to **wait=False**: they return an execution handle (async). Pass **wait=True** to block until completion. So all such commands are async unless you specify otherwise.
+
+**Synchronous** (no wait parameter; complete before returning): **setup()**, **get_status()**, **get_device_identification()**, **read_temperatures()**, **list_protocols()**, **get_protocol()**, and other informational calls.
+
+**Lid/door:** **open_lid** and **close_lid** default to **wait=True** (block); pass **wait=False** to get a handle.
+
+**Example:**
+
+```python
+# run_stored_protocol and set_block_temperature default to wait=False → handle
+execution = await tc.run_stored_protocol("PCR_30cycles")
+temps = await tc.read_temperatures() # parallel where allowed
+if await execution.is_running():
+ print(f"Method {execution.method_name} still running")
+events = await execution.get_data_events()
+await execution # block until done
+
+# To block on start: execution = await tc.run_stored_protocol("PCR_30cycles", wait=True)
+# Lid: door_opening = await tc.open_lid(wait=False); await door_opening
+```
+
+**State:** `await tc.is_profile_running()`. `await tc.wait_for_profile_completion(poll_interval=5.0, timeout=3600.0)`.
+
+**Temperature:** `await tc.set_block_temperature([temp])` or with `lid_temperature=...`; returns handle by default (wait=False). Implemented via PreMethod (no direct SetBlockTemperature).
+
+**Parallel with ExecuteMethod:** ✅ ReadActualTemperature, OpenDoor/CloseDoor, StopMethod. ❌ SetParameters/GetParameters, GetLastData, another ExecuteMethod.
+
+**Waiting:** Await a handle (`await execution`) or use `wait=True`. Backend polls latest DataEvent at **progress_log_interval** (default 150 s), builds **ODTCProgress**, and logs it (and/or calls **progress_callback** with ODTCProgress).
+
+**Execution handle (ODTCExecution):** `request_id`, `command_name`, `estimated_remaining_time` (our estimate when protocol known; else effective lifetime); awaitable, `wait()`, `get_data_events()`. ExecuteMethod: `method_name`, `is_running()`, `stop()`.
+
+## Device protocols
+
+**List:** `protocol_list = await tc.backend.list_protocols()` (ProtocolList: `.methods`, `.premethods`, `.all`). Or `methods, premethods = await tc.backend.list_methods()`.
+
+**Get by name:** `odtc = await tc.backend.get_protocol("PCR_30cycles")` → `Optional[ODTCProtocol]` (None for premethods or missing). Then modify and upload (Workflow 2) or `print(odtc)` and run by name.
+
+**Full MethodSet (advanced):** `method_set = await tc.backend.get_method_set()` → ODTCMethodSet; iterate `method_set.methods` and `method_set.premethods`.
+
+**Display:** `print(odtc)` and `print(await tc.backend.read_temperatures())` show labeled summaries. When you await a handle, INFO logs multi-line command/duration/remaining.
+
+## DataEvents and progress
+
+During method execution the device sends **DataEvent** messages (raw payloads). We store them and turn the latest into **ODTCProgress** in one place: **`ODTCProgress.from_data_event(payload, odtc)`**. The device sends **elapsed time and temperatures** (block/lid) only; it does **not** send step/cycle/hold or estimated/remaining duration — we derive those from the protocol when it is registered.
+
+**ODTCProgress** (single type): `elapsed_s`, `current_temp_c`, `target_temp_c`, `lid_temp_c`; when protocol is registered: `current_step_index`, `total_step_count`, `current_cycle_index`, `total_cycle_count`, `remaining_hold_s`, **`estimated_duration_s`** (protocol total), **`remaining_duration_s`** = max(0, estimated_duration_s - elapsed_s). Use **`get_progress_snapshot()`** → ODTCProgress; **`get_hold_time()`**, **`get_current_step_index()`**, **`get_current_cycle_index()`** read from the same snapshot. **`progress_callback`** (if set) receives ODTCProgress every **progress_log_interval** (default 150 s). Set `progress_log_interval` to `None` or `0` to disable logging/callback.
+
+**Logging:** When you await a handle, the backend logs progress (e.g. `progress.format_progress_log_message()`) every progress_log_interval. Configure `pylabrobot.thermocycling.inheco` (and optionally `pylabrobot.storage.inheco`) for level. Optional raw DataEvent JSONL: **`tc.backend.data_event_log_path`** = file path.
+
+**ExecuteMethod:** Backend waits for the first DataEvent (up to `first_event_timeout_seconds`, default 60 s) to set handle lifetime/ETA from our estimated duration. Completion is via ResponseEvent or GetStatus polling.
+
+## Error handling
+
+**Return codes:** 1 = sync success; 2 = async accepted; 3 = async completed (ResponseEvent); 4 = device busy; 5 = LockId mismatch; 6 = invalid/duplicate requestId; 9 = command not allowed in current state.
+
+**State transitions:** `startup` → `standby` (Reset) → `idle` (Initialize) → `busy` (async command) → `idle` (completion).
+
+## Best practices
+
+1. **Always call `setup()`** before using the device.
+2. **Async by default:** run_stored_protocol and set_block_temperature default to wait=False (return handle); use wait=True to block when you need to wait before continuing.
+3. **Check state** with `is_profile_running()` before starting new methods.
+4. **Prefer running stored protocols by name** (primary). **Run a preMethod first:** use `set_block_temperature(...)` so block and lid match the protocol’s initial temperatures (`start_block_temperature`, `start_lid_temperature`), then run the protocol. Secondary: edited ODTCProtocol (change only hold times and cycle count; **avoid changing temperatures** — overshoots are temperature- and ramp-specific) or Protocol + generic config for custom runs. When using Protocol + device-derived config, preserve the `ODTCConfig` from `odtc_method_to_protocol(odtc)`. New protocols (generic Protocol) use `get_default_config()`; overshoot defaults until automatic derivation (future work).
+5. **Handle timeouts** when waiting for method completion.
+6. **Clean up** with `stop()` when done.
+
+## Complete example
+
+```python
+from pylabrobot.resources import Coordinate
+from pylabrobot.thermocycling.inheco import ODTCThermocycler
+from pylabrobot.thermocycling.inheco.odtc_model import protocol_to_odtc_protocol
+from pylabrobot.thermocycling.standard import Protocol, Stage, Step
+
+tc = ODTCThermocycler(name="odtc", odtc_ip="192.168.1.100", variant=96, child_location=Coordinate(0, 0, 0))
+await tc.setup()
+
+# Run modified ODTCProtocol without saving a new template (run_protocol uploads to scratch, runs, no overwrite of stored methods)
+odtc = await tc.backend.get_protocol("PCR_30cycles")
+if odtc is not None:
+ odtc.steps[1].plateau_time = 45.0
+ execution = await tc.run_protocol(odtc, block_max_volume=50.0)
+ await execution
+
+# Custom run: Protocol + ODTCConfig
+config = tc.backend.get_default_config(block_max_volume=50.0)
+protocol = Protocol(stages=[
+ Stage(steps=[
+ Step(temperature=[95.0], hold_seconds=30.0),
+ Step(temperature=[60.0], hold_seconds=30.0),
+ Step(temperature=[72.0], hold_seconds=60.0),
+ ], repeats=30)
+])
+odtc = protocol_to_odtc_protocol(protocol, config=config)
+await tc.run_protocol(odtc, block_max_volume=50.0)
+
+await tc.set_block_temperature([37.0])
+await tc.stop()
+```
diff --git a/pylabrobot/thermocycling/inheco/__init__.py b/pylabrobot/thermocycling/inheco/__init__.py
new file mode 100644
index 00000000000..b0b82b14ac1
--- /dev/null
+++ b/pylabrobot/thermocycling/inheco/__init__.py
@@ -0,0 +1,48 @@
+"""Inheco ODTC thermocycler implementation.
+
+Preferred: use ODTCThermocycler (owns connection params and dimensions):
+
+ tc = ODTCThermocycler(
+ name="odtc1",
+ odtc_ip="192.168.1.100",
+ variant=384,
+ child_location=Coordinate.zero(),
+ )
+
+Alternative: use generic Thermocycler with ODTCBackend (e.g. for custom backend):
+
+ backend = ODTCBackend(odtc_ip="192.168.1.100", variant=384)
+ tc = Thermocycler(
+ name="odtc1",
+ size_x=156.5,
+ size_y=248,
+ size_z=124.3,
+ backend=backend,
+ child_location=...,
+ )
+
+Variant accepts 96, 384 or device codes (960000, 384000). Use tc.run_protocol(protocol,
+block_max_volume) for in-memory protocols; tc.run_stored_protocol("my_pcr") for
+stored-by-name (ODTC only).
+"""
+
+from .odtc_backend import ODTCBackend, ODTCExecution
+from .odtc_model import ODTC_DIMENSIONS, ODTCProgress, ODTCProtocol, ProtocolList, normalize_variant
+from .odtc_thermocycler import ODTCThermocycler
+
+# Backward-compat aliases (single execution handle type)
+CommandExecution = ODTCExecution
+MethodExecution = ODTCExecution
+
+__all__ = [
+ "CommandExecution",
+ "MethodExecution",
+ "ODTCBackend",
+ "ODTC_DIMENSIONS",
+ "ODTCExecution",
+ "ODTCProgress",
+ "ODTCProtocol",
+ "ODTCThermocycler",
+ "ProtocolList",
+ "normalize_variant",
+]
diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py
new file mode 100644
index 00000000000..589ce0d333f
--- /dev/null
+++ b/pylabrobot/thermocycling/inheco/odtc_backend.py
@@ -0,0 +1,1691 @@
+"""ODTC backend implementing ThermocyclerBackend interface using ODTC SiLA interface."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass, replace
+from enum import Enum
+from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Tuple, Union
+
+from pylabrobot.thermocycling.backend import ThermocyclerBackend
+from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol
+
+from .odtc_model import (
+ PREMETHOD_ESTIMATED_DURATION_SECONDS,
+ ODTCConfig,
+ ODTCHardwareConstraints,
+ ODTCMethodSet,
+ ODTCProgress,
+ ODTCProtocol,
+ ODTCSensorValues,
+ ProtocolList,
+ estimate_odtc_protocol_duration_seconds,
+ generate_odtc_timestamp,
+ get_constraints,
+ get_method_by_name,
+ list_method_names,
+ list_premethod_names,
+ method_set_to_xml,
+ normalize_variant,
+ odtc_protocol_to_protocol,
+ parse_method_set,
+ parse_method_set_file,
+ parse_sensor_values,
+ protocol_to_odtc_protocol,
+ resolve_protocol_name,
+ validate_volume_fluid_quantity,
+ volume_to_fluid_quantity,
+)
+from .odtc_sila_interface import (
+ DEFAULT_FIRST_EVENT_TIMEOUT_SECONDS,
+ DEFAULT_LIFETIME_OF_EXECUTION,
+ POLLING_START_BUFFER,
+ FirstEventType,
+ ODTCSiLAInterface,
+ SiLAState,
+)
+
+# Buffer (seconds) added to device remaining duration (ExecuteMethod) or first_event_timeout (status commands) for timeout cap (fail faster than full lifetime).
+LIFETIME_BUFFER_SECONDS: float = 60.0
+
+
+class ODTCCommand(str, Enum):
+ """SiLA async command identifier for execute()."""
+
+ INITIALIZE = "Initialize"
+ RESET = "Reset"
+ LOCK_DEVICE = "LockDevice"
+ UNLOCK_DEVICE = "UnlockDevice"
+ OPEN_DOOR = "OpenDoor"
+ CLOSE_DOOR = "CloseDoor"
+ STOP_METHOD = "StopMethod"
+ EXECUTE_METHOD = "ExecuteMethod"
+
+
+# =============================================================================
+# SiLA Response Normalization (single abstraction for dict or ET responses)
+# =============================================================================
+
+
+class _NormalizedSiLAResponse:
+ """Normalized result of a SiLA command (sync dict or async ElementTree).
+
+ Used only by ODTCBackend. Build via from_raw(); then get_value() for
+ dict-path extraction or get_parameter_string() for Parameter/String.
+ """
+
+ def __init__(
+ self,
+ command_name: str,
+ _dict: Optional[Dict[str, Any]] = None,
+ _et_root: Optional[ET.Element] = None,
+ ) -> None:
+ self._command_name = command_name
+ self._dict = _dict
+ self._et_root = _et_root
+ if _dict is not None and _et_root is not None:
+ raise ValueError("_NormalizedSiLAResponse: provide _dict or _et_root, not both")
+ if _dict is None and _et_root is None:
+ raise ValueError("_NormalizedSiLAResponse: provide _dict or _et_root")
+
+ @classmethod
+ def from_raw(
+ cls,
+ raw: Union[Dict[str, Any], ET.Element, None],
+ command_name: str,
+ ) -> "_NormalizedSiLAResponse":
+ """Build from send_command return value (dict for sync, ET root for async)."""
+ if raw is None:
+ return cls(command_name=command_name, _dict={})
+ if isinstance(raw, dict):
+ return cls(command_name=command_name, _dict=raw)
+ return cls(command_name=command_name, _et_root=raw)
+
+ def get_value(self, *path: str, required: bool = True) -> Any:
+ """Get nested value from dict response by key path. Only for dict (sync) responses."""
+ if self._dict is None:
+ raise ValueError(
+ f"{self._command_name}: get_value() only supported for dict (sync) responses"
+ )
+ value: Any = self._dict
+ path_list = list(path)
+ for key in path_list:
+ if not isinstance(value, dict):
+ if required:
+ raise ValueError(
+ f"{self._command_name}: Expected dict at path {path_list}, got {type(value).__name__}"
+ )
+ return None
+ value = value.get(key, {})
+
+ if value is None or (isinstance(value, dict) and not value and required):
+ if required:
+ raise ValueError(
+ f"{self._command_name}: Could not find value at path {path_list}. Response: {self._dict}"
+ )
+ return None
+ return value
+
+ def get_parameter_string(
+ self,
+ name: str,
+ allow_root_fallback: bool = False,
+ ) -> str:
+ """Get Parameter[@name=name]/String value (dict or ET response)."""
+ if self._dict is not None:
+ response_data_path: List[str] = [
+ f"{self._command_name}Response",
+ "ResponseData",
+ ]
+ response_data = self._get_dict_path(response_data_path, required=True)
+ param = response_data.get("Parameter")
+ if isinstance(param, list):
+ found = next((p for p in param if p.get("name") == name), None)
+ elif isinstance(param, dict):
+ found = param if param.get("name") == name else None
+ else:
+ found = None
+ if found is None:
+ raise ValueError(f"Parameter '{name}' not found in {self._command_name} response")
+ value = found.get("String")
+ if value is None:
+ raise ValueError(f"String element not found in {name} parameter")
+ return str(value)
+
+ resp = self._et_root
+ if resp is None:
+ raise ValueError(f"Empty response from {self._command_name}")
+
+ param = None
+ if resp.tag == "Parameter" and resp.get("name") == name:
+ param = resp
+ else:
+ param = resp.find(f".//Parameter[@name='{name}']")
+
+ if param is None and allow_root_fallback:
+ param = resp if resp.tag == "Parameter" else resp.find(".//Parameter")
+
+ if param is None:
+ xml_str = ET.tostring(resp, encoding="unicode")
+ raise ValueError(
+ f"Parameter '{name}' not found in {self._command_name} response. "
+ f"Root element tag: {resp.tag}\nFull XML response:\n{xml_str}"
+ )
+
+ string_elem = param.find("String")
+ if string_elem is None or string_elem.text is None:
+ raise ValueError(f"String element not found in {self._command_name} Parameter response")
+ return str(string_elem.text)
+
+ def _get_dict_path(self, path: List[str], required: bool = True) -> Any:
+ """Internal: traverse dict by path."""
+ if self._dict is None:
+ if required:
+ raise ValueError(f"{self._command_name}: response is not dict")
+ return None
+ value: Any = self._dict
+ for key in path:
+ if not isinstance(value, dict):
+ if required:
+ raise ValueError(
+ f"{self._command_name}: Expected dict at path {path}, got {type(value).__name__}"
+ )
+ return None
+ value = value.get(key, {})
+ if value is None or (isinstance(value, dict) and not value and required):
+ if required:
+ raise ValueError(f"{self._command_name}: Could not find value at path {path}")
+ return None
+ return value
+
+ def raw(self) -> Union[Dict[str, Any], ET.Element]:
+ """Return the underlying dict or ET root (e.g. for GetLastData)."""
+ if self._dict is not None:
+ return self._dict
+ if self._et_root is not None:
+ return self._et_root
+ return {}
+
+
+@dataclass
+class ODTCExecution:
+ """Handle for an executing async command (SiLA return_code 2). Returned when wait=False.
+
+ Provides: awaitable interface, request_id, done/status, wait/wait_resumable,
+ get_data_events. For ExecuteMethod: method_name, is_running(), stop().
+ """
+
+ request_id: int
+ command_name: str
+ _future: asyncio.Future[Any]
+ backend: "ODTCBackend"
+ estimated_remaining_time: Optional[float] = None
+ started_at: Optional[float] = None
+ lifetime: Optional[float] = None
+ method_name: Optional[str] = None # set for ExecuteMethod
+
+ def __await__(self):
+ return self.wait().__await__()
+
+ @property
+ def done(self) -> bool:
+ return self._future.done()
+
+ @property
+ def status(self) -> str:
+ if not self._future.done():
+ return "running"
+ try:
+ self._future.result()
+ return "success"
+ except Exception:
+ return "error"
+
+ def _log_wait_info(self) -> None:
+ import time
+
+ name = f"{self.method_name} ({self.command_name})" if self.method_name else self.command_name
+ lifetime = (
+ self.lifetime if self.lifetime is not None else self.backend._get_effective_lifetime()
+ )
+ started_at = self.started_at if self.started_at is not None else time.time()
+ remaining = max(0.0, lifetime - (time.time() - started_at)) if lifetime is not None else None
+ ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
+ lines = [
+ f"[{ts}] Waiting for command",
+ f" Command: {name}",
+ f" Duration (timeout): {lifetime}s",
+ ]
+ if remaining is not None:
+ lines.append(f" Remaining: {remaining:.0f}s")
+ self.backend.logger.info("\n".join(lines))
+
+ async def _is_done(self) -> bool:
+ return self._future.done()
+
+ async def wait(self) -> None:
+ if not self._future.done():
+ self._log_wait_info()
+ interval = self.backend.progress_log_interval
+ if interval and interval > 0:
+ task = asyncio.create_task(
+ self.backend._run_progress_loop_until(
+ self.request_id,
+ interval,
+ self._is_done,
+ self.backend.progress_callback,
+ )
+ )
+ try:
+ await self._future
+ finally:
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+ else:
+ await self._future
+
+ async def wait_resumable(self, poll_interval: float = 5.0) -> None:
+ import time
+
+ self._log_wait_info()
+ started_at = self.started_at if self.started_at is not None else time.time()
+ lifetime = (
+ self.lifetime if self.lifetime is not None else self.backend._get_effective_lifetime()
+ )
+ await self.backend.wait_for_completion_by_time(
+ request_id=self.request_id,
+ started_at=started_at,
+ estimated_remaining_time=self.estimated_remaining_time,
+ lifetime=lifetime,
+ poll_interval=poll_interval,
+ terminal_state="idle",
+ progress_log_interval=self.backend.progress_log_interval,
+ progress_callback=self.backend.progress_callback,
+ )
+
+ async def get_data_events(self) -> List[Dict[str, Any]]:
+ events_dict = await self.backend.get_data_events(self.request_id)
+ return events_dict.get(self.request_id, [])
+
+ async def is_running(self) -> bool:
+ """True if device is busy (only meaningful when command_name == 'ExecuteMethod')."""
+ if self.command_name != "ExecuteMethod":
+ return not self._future.done()
+ return await self.backend.is_method_running()
+
+ async def stop(self) -> None:
+ """Stop the running method (no-op unless command_name == 'ExecuteMethod')."""
+ if self.command_name == "ExecuteMethod":
+ await self.backend.stop_method()
+
+
+class ODTCBackend(ThermocyclerBackend):
+ """ODTC backend using ODTC-specific SiLA interface.
+
+ Implements ThermocyclerBackend interface for Inheco ODTC devices.
+ Uses ODTCSiLAInterface for low-level SiLA communication with parallelism,
+ state management, and lockId validation.
+
+ ODTC dimensions for Thermocycler: size_x=156.5, size_y=248, size_z=124.3 (mm).
+ Construct: backend = ODTCBackend(odtc_ip="...", variant=384000); then
+ Thermocycler(name="odtc1", size_x=156.5, size_y=248, size_z=124.3, backend=backend, ...).
+ """
+
+ def __init__(
+ self,
+ odtc_ip: str,
+ variant: int = 960000,
+ client_ip: Optional[str] = None,
+ logger: Optional[logging.Logger] = None,
+ poll_interval: float = 5.0,
+ lifetime_of_execution: Optional[float] = None,
+ on_response_event_missing: Literal["warn_and_continue", "error"] = "warn_and_continue",
+ progress_log_interval: Optional[float] = 150.0,
+ progress_callback: Optional[Callable[..., None]] = None,
+ data_event_log_path: Optional[str] = None,
+ first_event_timeout_seconds: float = DEFAULT_FIRST_EVENT_TIMEOUT_SECONDS,
+ ):
+ """Initialize ODTC backend.
+
+ Args:
+ odtc_ip: IP address of the ODTC device.
+ variant: Well count (96, 384) or ODTC variant code (960000, 384000, 3840000).
+ Accepted 96/384 are normalized to 960000/384000. Used for default config
+ and constraints (e.g. max slopes, lid temp).
+ client_ip: IP address of this client (auto-detected if None).
+ logger: Logger instance (creates one if None).
+ poll_interval: Seconds between GetStatus calls in the async completion polling fallback (SiLA2 subscribe_by_polling style). Default 5.0.
+ lifetime_of_execution: Max seconds to wait for async command completion (SiLA2 deadline). If None, uses 3 hours. Protocol execution is always bounded.
+ on_response_event_missing: When completion is detected via polling but ResponseEvent was not received: "warn_and_continue" (resolve with None, log warning) or "error" (set exception). Default "warn_and_continue".
+ progress_log_interval: Seconds between progress log lines during wait. None or 0 to disable. Default 150.0 (2.5 min); suitable for protocols from minutes to 1–2+ hours.
+ progress_callback: Optional callback(ODTCProgress) called each progress_log_interval during wait.
+ data_event_log_path: Optional path to append full DataEvent payloads (one JSON line per event) for debugging and API discovery.
+ first_event_timeout_seconds: Timeout for waiting for first DataEvent (ExecuteMethod) and default lifetime/eta for status-driven commands (e.g. OpenDoor). Default 60 s.
+ """
+ super().__init__()
+ self._variant = normalize_variant(variant)
+ self._current_execution: Optional[ODTCExecution] = None
+ self._simulation_mode: bool = False
+ self._protocol_by_request_id: Dict[int, Union[Protocol, ODTCProtocol]] = {}
+ self.progress_log_interval: Optional[float] = progress_log_interval
+ self.progress_callback: Optional[Callable[..., None]] = progress_callback
+ self._sila = ODTCSiLAInterface(
+ machine_ip=odtc_ip,
+ client_ip=client_ip,
+ logger=logger,
+ poll_interval=poll_interval,
+ lifetime_of_execution=lifetime_of_execution,
+ on_response_event_missing=on_response_event_missing,
+ )
+ self._sila.data_event_log_path = data_event_log_path
+ self._first_event_timeout_seconds = first_event_timeout_seconds
+ self.logger = logger or logging.getLogger(__name__)
+
+ @property
+ def odtc_ip(self) -> str:
+ """IP address of the ODTC device."""
+ return self._sila._machine_ip
+
+ @property
+ def data_event_log_path(self) -> Optional[str]:
+ """Path where full DataEvent payloads are appended (one JSON line per event); None to disable."""
+ return self._sila.data_event_log_path
+
+ @data_event_log_path.setter
+ def data_event_log_path(self, path: Optional[str]) -> None:
+ self._sila.data_event_log_path = path
+
+ @property
+ def variant(self) -> int:
+ """ODTC variant code (960000 or 384000)."""
+ return self._variant
+
+ @property
+ def current_execution(self) -> Optional[ODTCExecution]:
+ """Current method execution handle (set when a method is started with wait=False or wait=True)."""
+ return self._current_execution
+
+ @property
+ def simulation_mode(self) -> bool:
+ """Whether the device is in simulation mode (from the last reset() call).
+
+ Reflects the last simulation_mode passed to reset(); valid once that Reset
+ has completed (or immediately if wait=True). Use this to check state without
+ calling reset again.
+ """
+ return self._simulation_mode
+
+ def _clear_current_execution_if(self, handle: ODTCExecution) -> None:
+ """Clear _current_execution only if it still refers to the given handle."""
+ if self._current_execution is handle:
+ self._current_execution = None
+
+ def _clear_execution_state_for_handle(self, handle: ODTCExecution) -> None:
+ """Clear current execution and protocol cache for this handle."""
+ self._clear_current_execution_if(handle)
+ self._protocol_by_request_id.pop(handle.request_id, None)
+
+ async def setup(
+ self,
+ full: bool = True,
+ simulation_mode: bool = False,
+ max_attempts: int = 3,
+ retry_backoff_base_seconds: float = 1.0,
+ ) -> None:
+ """Prepare the ODTC connection.
+
+ When full=True (default): full SiLA lifecycle (event receiver, Reset,
+ Initialize, verify idle), with optional retry and exponential backoff.
+ When full=False: only start the event receiver (reconnect without reset);
+ use after session loss so a running method is not aborted; then use
+ wait_for_completion_by_time() or a persisted handle's wait_resumable().
+
+ Args:
+ full: If True, run full lifecycle (event receiver + Reset + Initialize).
+ If False, only start event receiver; do not call Reset or Initialize.
+ simulation_mode: Used only when full=True; passed to reset(). When True,
+ device runs in SiLA simulation mode (commands return immediately with
+ estimated duration; valid until next Reset).
+ max_attempts: When full=True, number of attempts for the full path
+ (default 3). On failure, retry with exponential backoff.
+ retry_backoff_base_seconds: Base delay in seconds for backoff; delay
+ before attempt i (i > 0) is retry_backoff_base_seconds * (2 ** (i - 1)).
+ """
+ if not full:
+ await self._sila.setup()
+ return
+
+ last_error: Optional[Exception] = None
+ for attempt in range(max_attempts):
+ try:
+ await self._setup_full_path(simulation_mode)
+ 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:
+ """Run the full connection path: event receiver, Reset, Initialize, verify idle."""
+ await self._sila.setup()
+
+ event_receiver_uri = f"http://{self._sila._client_ip}:{self._sila.bound_port}/"
+ await self.reset(
+ device_id="ODTC",
+ event_receiver_uri=event_receiver_uri,
+ simulation_mode=simulation_mode,
+ )
+
+ status = await self.get_status()
+ self.logger.info(f"GetStatus returned raw state: {status!r} (type: {type(status).__name__})")
+
+ if status == SiLAState.STANDBY.value:
+ self.logger.info("Device is in standby state, calling Initialize...")
+ await self.initialize()
+
+ status_after_init = await self.get_status()
+
+ if status_after_init == SiLAState.IDLE.value:
+ self.logger.info("Device successfully initialized and is in idle state")
+ else:
+ raise RuntimeError(
+ f"Device is not in idle state after Initialize. Expected {SiLAState.IDLE.value!r}, "
+ f"but got {status_after_init!r}."
+ )
+ elif status == SiLAState.IDLE.value:
+ self.logger.info("Device already in idle state after Reset")
+ else:
+ raise RuntimeError(
+ f"Unexpected device state after Reset: {status!r}. Expected {SiLAState.STANDBY.value!r} or {SiLAState.IDLE.value!r}."
+ )
+
+ async def stop(self) -> None:
+ """Close the ODTC device connection."""
+ await self._sila.close()
+
+ def serialize(self) -> dict:
+ """Return serialized representation of the backend.
+
+ Only includes "port" when the SiLA event receiver has been started (e.g. after
+ setup()), so the visualizer can serialize the deck without connecting to the ODTC.
+ """
+ out = {
+ **super().serialize(),
+ "odtc_ip": self.odtc_ip,
+ "variant": self.variant,
+ }
+ try:
+ out["port"] = self._sila.bound_port
+ except RuntimeError:
+ # Server not started yet; omit port so deck can be serialized without connecting
+ pass
+ return out
+
+ def _get_effective_lifetime(self) -> float:
+ """Effective max wait for async command completion (seconds)."""
+ if self._sila._lifetime_of_execution is not None:
+ return self._sila._lifetime_of_execution
+ return DEFAULT_LIFETIME_OF_EXECUTION
+
+ async def _run_async_command(
+ self,
+ command_name: str,
+ wait: bool,
+ method_name: Optional[str] = None,
+ estimated_duration_s: Optional[float] = None,
+ **send_kwargs: Any,
+ ) -> Optional[ODTCExecution]:
+ """Run an async SiLA command; return None if wait else execution handle."""
+ if wait:
+ await self._sila.send_command(command_name, **send_kwargs)
+ return None
+ fut, request_id, started_at = await self._sila.start_command(command_name, **send_kwargs)
+ effective = self._get_effective_lifetime()
+ event_type = self._sila.get_first_event_type_for_command(command_name)
+
+ if event_type == FirstEventType.DATA_EVENT:
+ first_payload = await self._sila.wait_for_first_event(
+ request_id, FirstEventType.DATA_EVENT, self._first_event_timeout_seconds
+ )
+ progress = ODTCProgress.from_data_event(first_payload, None)
+ if estimated_duration_s is not None and estimated_duration_s > 0:
+ eta = max(0.0, estimated_duration_s - progress.elapsed_s)
+ lifetime = min(eta + LIFETIME_BUFFER_SECONDS, effective)
+ else:
+ eta = effective
+ lifetime = effective
+ self._sila.set_estimated_remaining_time(request_id, eta)
+ return ODTCExecution(
+ request_id=request_id,
+ command_name=command_name,
+ _future=fut,
+ backend=self,
+ estimated_remaining_time=eta,
+ started_at=started_at,
+ lifetime=lifetime,
+ method_name=method_name or "",
+ )
+
+ eta = self._first_event_timeout_seconds
+ lifetime = min(
+ self._first_event_timeout_seconds + LIFETIME_BUFFER_SECONDS,
+ effective,
+ )
+ self._sila.set_estimated_remaining_time(request_id, eta)
+ return ODTCExecution(
+ request_id=request_id,
+ command_name=command_name,
+ _future=fut,
+ backend=self,
+ estimated_remaining_time=eta,
+ started_at=started_at,
+ lifetime=lifetime,
+ )
+
+ async def execute(
+ self,
+ command: ODTCCommand,
+ wait: bool = True,
+ **kwargs: Any,
+ ) -> Optional[ODTCExecution]:
+ """Run an async SiLA command. All commands are fire-and-forget; wait controls whether we block or return a handle.
+
+ Args:
+ command: ODTCCommand (INITIALIZE, RESET, LOCK_DEVICE, UNLOCK_DEVICE, OPEN_DOOR, CLOSE_DOOR, STOP_METHOD, EXECUTE_METHOD).
+ wait: If True, block until completion and return None. If False, return execution handle.
+ **kwargs: Command-specific params. RESET: device_id, event_receiver_uri, simulation_mode.
+ LOCK_DEVICE: lock_id (required), lock_timeout. EXECUTE_METHOD: method_name (required), priority, protocol.
+
+ Returns:
+ If wait=True: None. If wait=False: execution handle (awaitable). EXECUTE_METHOD always returns handle (never None).
+ """
+ if command == ODTCCommand.RESET:
+ self._simulation_mode = kwargs.get("simulation_mode", False)
+ event_receiver_uri = kwargs.get("event_receiver_uri")
+ if event_receiver_uri is None:
+ event_receiver_uri = f"http://{self._sila._client_ip}:{self._sila.bound_port}/"
+ return await self._run_async_command(
+ "Reset",
+ wait,
+ deviceId=kwargs.get("device_id", "ODTC"),
+ eventReceiverURI=event_receiver_uri,
+ simulationMode=self._simulation_mode,
+ )
+ if command == ODTCCommand.LOCK_DEVICE:
+ lock_id = kwargs.get("lock_id")
+ if lock_id is None:
+ raise ValueError("lock_id required for LOCK_DEVICE")
+ params: dict = {"lockId": lock_id, "PMSId": "PyLabRobot"}
+ if kwargs.get("lock_timeout") is not None:
+ params["lockTimeout"] = kwargs["lock_timeout"]
+ return await self._run_async_command("LockDevice", wait, lock_id=lock_id, **params)
+ if command == ODTCCommand.UNLOCK_DEVICE:
+ if self._sila._lock_id is None:
+ raise RuntimeError("Device is not locked")
+ return await self._run_async_command("UnlockDevice", wait, lock_id=self._sila._lock_id)
+ if command == ODTCCommand.EXECUTE_METHOD:
+ method_name = kwargs.get("method_name")
+ if not method_name:
+ raise ValueError("method_name required for EXECUTE_METHOD")
+ self._current_execution = None
+ params = {"methodName": method_name}
+ if kwargs.get("priority") is not None:
+ params["priority"] = kwargs["priority"]
+ protocol_to_register: Optional[Union[Protocol, ODTCProtocol]] = None
+ _, premethods = await self.list_methods()
+ if method_name in premethods:
+ estimated_duration_s = PREMETHOD_ESTIMATED_DURATION_SECONDS
+ method_set = await self.get_method_set()
+ resolved = get_method_by_name(method_set, method_name)
+ if resolved is not None:
+ protocol_to_register = resolved
+ elif kwargs.get("protocol") is not None:
+ protocol = kwargs["protocol"]
+ config = self.get_default_config()
+ odtc = protocol_to_odtc_protocol(protocol, config=config)
+ estimated_duration_s = estimate_odtc_protocol_duration_seconds(odtc)
+ protocol_to_register = protocol
+ else:
+ fetched = await self.get_protocol(method_name)
+ if fetched is not None:
+ estimated_duration_s = estimate_odtc_protocol_duration_seconds(fetched)
+ protocol_to_register = fetched
+ else:
+ estimated_duration_s = self._get_effective_lifetime()
+ handle = await self._run_async_command(
+ "ExecuteMethod",
+ False,
+ method_name=method_name,
+ estimated_duration_s=estimated_duration_s,
+ **params,
+ )
+ assert handle is not None
+ handle._future.add_done_callback(lambda _: self._clear_execution_state_for_handle(handle))
+ if protocol_to_register is not None:
+ self._protocol_by_request_id[handle.request_id] = protocol_to_register
+ self._current_execution = handle
+ if wait:
+ await handle.wait()
+ return handle
+ return await self._run_async_command(command.value, wait)
+
+ # ============================================================================
+ # Request + normalized response
+ # ============================================================================
+
+ async def _request(self, command: str, **kwargs: Any) -> _NormalizedSiLAResponse:
+ """Send command and return normalized response (dict or ET wrapped)."""
+ raw = await self._sila.send_command(command, **kwargs)
+ return _NormalizedSiLAResponse.from_raw(raw, command)
+
+ async def _get_method_set_xml(self) -> str:
+ """Get MethodsXML parameter string from GetParameters response."""
+ resp = await self._request("GetParameters")
+ return resp.get_parameter_string("MethodsXML")
+
+ # ============================================================================
+ # Basic ODTC Commands
+ # ============================================================================
+
+ async def get_status(self) -> str:
+ """Get device status state.
+
+ Returns:
+ Device state string (e.g., "idle", "busy", "standby").
+
+ Raises:
+ ValueError: If response format is unexpected and state cannot be extracted.
+ """
+ resp = await self._request("GetStatus")
+ state = resp.get_value("GetStatusResponse", "state")
+ return str(state)
+
+ async def initialize(self, wait: bool = True) -> Optional[ODTCExecution]:
+ """Initialize the device (SiLA command: standby -> idle). See execute(ODTCCommand.INITIALIZE)."""
+ return await self.execute(ODTCCommand.INITIALIZE, wait=wait)
+
+ async def reset(
+ self,
+ device_id: str = "ODTC",
+ event_receiver_uri: Optional[str] = None,
+ simulation_mode: bool = False,
+ wait: bool = True,
+ ) -> Optional[ODTCExecution]:
+ """Reset the device (SiLA: startup -> standby, register event receiver). See execute(ODTCCommand.RESET)."""
+ return await self.execute(
+ ODTCCommand.RESET,
+ wait=wait,
+ device_id=device_id,
+ event_receiver_uri=event_receiver_uri,
+ simulation_mode=simulation_mode,
+ )
+
+ async def get_device_identification(self) -> dict:
+ """Get device identification information.
+
+ Returns:
+ Device identification dictionary.
+ """
+ resp = await self._request("GetDeviceIdentification")
+ result = resp.get_value(
+ "GetDeviceIdentificationResponse",
+ "GetDeviceIdentificationResult",
+ required=False,
+ )
+ return result if isinstance(result, dict) else {}
+
+ async def lock_device(
+ self, lock_id: str, lock_timeout: Optional[float] = None, wait: bool = True
+ ) -> Optional[ODTCExecution]:
+ """Lock the device for exclusive access (SiLA: LockDevice). See execute(ODTCCommand.LOCK_DEVICE)."""
+ return await self.execute(
+ ODTCCommand.LOCK_DEVICE, wait=wait, lock_id=lock_id, lock_timeout=lock_timeout
+ )
+
+ async def unlock_device(self, wait: bool = True) -> Optional[ODTCExecution]:
+ """Unlock the device (SiLA: UnlockDevice). See execute(ODTCCommand.UNLOCK_DEVICE)."""
+ return await self.execute(ODTCCommand.UNLOCK_DEVICE, wait=wait)
+
+ # Door control commands (SiLA: OpenDoor, CloseDoor; thermocycler: lid)
+ async def open_door(self, wait: bool = True) -> Optional[ODTCExecution]:
+ """Open the door (thermocycler lid). SiLA: OpenDoor. See execute(ODTCCommand.OPEN_DOOR)."""
+ return await self.execute(ODTCCommand.OPEN_DOOR, wait=wait)
+
+ async def close_door(self, wait: bool = True) -> Optional[ODTCExecution]:
+ """Close the door (thermocycler lid). SiLA: CloseDoor. See execute(ODTCCommand.CLOSE_DOOR)."""
+ return await self.execute(ODTCCommand.CLOSE_DOOR, wait=wait)
+
+ async def read_temperatures(self) -> ODTCSensorValues:
+ """Read all temperature sensors.
+
+ Returns:
+ ODTCSensorValues with temperatures in °C.
+ """
+ resp = await self._request("ReadActualTemperature")
+ sensor_xml = resp.get_parameter_string("SensorValues", allow_root_fallback=True)
+ sensor_values = parse_sensor_values(sensor_xml)
+ self.logger.debug("ReadActualTemperature: %s", sensor_values.format_compact())
+ return sensor_values
+
+ async def get_last_data(self) -> str:
+ """Get temperature trace of last executed method (CSV format).
+
+ Returns:
+ CSV string with temperature trace data.
+ """
+ resp = await self._request("GetLastData")
+ return str(resp.raw())
+
+ # Method control commands (SiLA: ExecuteMethod; method = runnable protocol)
+ async def execute_method(
+ self,
+ method_name: str,
+ priority: Optional[int] = None,
+ wait: bool = False,
+ protocol: Optional[Protocol] = None,
+ ) -> ODTCExecution:
+ """Execute a method or premethod by name (SiLA: ExecuteMethod). See execute(ODTCCommand.EXECUTE_METHOD)."""
+ result = await self.execute(
+ ODTCCommand.EXECUTE_METHOD,
+ wait=wait,
+ method_name=method_name,
+ priority=priority,
+ protocol=protocol,
+ )
+ assert result is not None
+ return result
+
+ async def stop_method(self, wait: bool = True) -> Optional[ODTCExecution]:
+ """Stop the currently running method (SiLA: StopMethod). See execute(ODTCCommand.STOP_METHOD)."""
+ return await self.execute(ODTCCommand.STOP_METHOD, wait=wait)
+
+ # --- Method running and completion ---
+
+ async def is_method_running(self) -> bool:
+ """Check if a method is currently running.
+
+ Uses GetStatus to check device state. Returns True if state is 'busy',
+ indicating a method execution is in progress.
+
+ Returns:
+ True if method is running (state is 'busy'), False otherwise.
+ """
+ status = await self.get_status()
+ return status == SiLAState.BUSY.value
+
+ async def wait_for_method_completion(
+ self,
+ poll_interval: float = 5.0,
+ timeout: Optional[float] = None,
+ ) -> None:
+ """Wait until method execution completes.
+
+ Uses current execution handle (lifetime/eta) when present; otherwise
+ polls GetStatus at poll_interval until state returns to 'idle'.
+
+ Args:
+ poll_interval: Seconds between status checks (used by handle.wait_resumable
+ or fallback poll). Default 5.0.
+ timeout: Maximum seconds to wait (fallback poll only). None for no timeout.
+
+ Raises:
+ TimeoutError: If timeout is exceeded (fallback poll only).
+ """
+ import time
+
+ if self._current_execution is not None:
+ if self._current_execution.done:
+ return
+ await self._current_execution.wait_resumable(poll_interval=poll_interval)
+ return
+ start_time = time.time()
+ while await self.is_method_running():
+ if timeout is not None:
+ elapsed = time.time() - start_time
+ if elapsed > timeout:
+ raise TimeoutError(f"Method execution did not complete within {timeout}s")
+ await asyncio.sleep(poll_interval)
+
+ async def wait_for_completion_by_time(
+ self,
+ request_id: int,
+ started_at: float,
+ estimated_remaining_time: Optional[float],
+ lifetime: float,
+ poll_interval: float = 5.0,
+ terminal_state: str = "idle",
+ progress_log_interval: Optional[float] = None,
+ progress_callback: Optional[Callable[..., None]] = None,
+ ) -> None:
+ """Wait for async command completion using only wall-clock and GetStatus (resumable).
+
+ Does not require the in-memory Future. Use after restart: persist request_id,
+ started_at, estimated_remaining_time, lifetime from the handle, then call this
+ with a reconnected backend.
+
+ (a) Waits until time.time() >= started_at + estimated_remaining_time + buffer.
+ (b) Then polls GetStatus every poll_interval until state == terminal_state or
+ time.time() - started_at >= lifetime (then raises TimeoutError).
+ When progress_log_interval is set, logs and/or calls progress_callback at that interval.
+
+ Args:
+ request_id: SiLA request ID (for logging and DataEvent lookup).
+ started_at: time.time() when the command was sent.
+ estimated_remaining_time: Device-estimated duration in seconds (or None).
+ lifetime: Max seconds to wait (e.g. from handle.lifetime).
+ poll_interval: Seconds between GetStatus calls.
+ terminal_state: Device state that indicates command finished (default "idle").
+ progress_log_interval: Seconds between progress reports; None/0 to disable. Uses backend default if None.
+ progress_callback: Called with ODTCProgress each interval; uses backend default if None.
+
+ Raises:
+ TimeoutError: If lifetime exceeded before terminal state.
+ """
+ import time
+
+ interval = (
+ progress_log_interval if progress_log_interval is not None else self.progress_log_interval
+ )
+ callback = progress_callback if progress_callback is not None else self.progress_callback
+ buffer = POLLING_START_BUFFER
+ eta = estimated_remaining_time or 0.0
+ while True:
+ now = time.time()
+ elapsed = now - started_at
+ if elapsed >= lifetime:
+ raise TimeoutError(f"Command (request_id={request_id}) did not complete within {lifetime}s")
+ # Don't start polling until estimated time + buffer has passed
+ remaining_wait = started_at + eta + buffer - now
+ if remaining_wait > 0:
+ await asyncio.sleep(min(remaining_wait, poll_interval))
+ continue
+
+ # From here: poll until terminal_state or timeout; progress via same loop as wait()
+ async def _is_done() -> bool:
+ status = await self.get_status()
+ return status == terminal_state or (time.time() - started_at) >= lifetime
+
+ progress_task: Optional[asyncio.Task[None]] = None
+ if interval and interval > 0:
+ progress_task = asyncio.create_task(
+ self._run_progress_loop_until(request_id, interval, _is_done, callback)
+ )
+ try:
+ while True:
+ status = await self.get_status()
+ if status == terminal_state:
+ return
+ if (time.time() - started_at) >= lifetime:
+ raise TimeoutError(
+ f"Command (request_id={request_id}) did not complete within {lifetime}s"
+ )
+ await asyncio.sleep(poll_interval)
+ finally:
+ if progress_task is not None:
+ progress_task.cancel()
+ try:
+ await progress_task
+ except asyncio.CancelledError:
+ pass
+
+ async def get_data_events(
+ self, request_id: Optional[int] = None
+ ) -> Dict[int, List[Dict[str, Any]]]:
+ """Get collected DataEvents.
+
+ Args:
+ request_id: If provided, return events for this request_id only.
+ If None, return all collected events.
+
+ Returns:
+ Dict mapping request_id to list of DataEvent payloads.
+ """
+ all_events = self._sila._data_events_by_request_id.copy()
+
+ if request_id is not None:
+ return {request_id: all_events.get(request_id, [])}
+
+ return all_events
+
+ async def get_method_set(self) -> ODTCMethodSet:
+ """Get the full MethodSet from the device.
+
+ Returns:
+ ODTCMethodSet containing all methods and premethods.
+
+ Raises:
+ ValueError: If response is empty or MethodsXML parameter not found.
+ """
+ method_set_xml = await self._get_method_set_xml()
+ return parse_method_set(method_set_xml)
+
+ async def get_protocol(self, name: str) -> Optional[ODTCProtocol]:
+ """Get a stored protocol by name (runnable methods only; premethods return None).
+
+ Returns ODTCProtocol if a runnable method exists. Nested-loop validation
+ runs only when converting to Protocol view (e.g. odtc_protocol_to_protocol).
+
+ Args:
+ name: Protocol name to retrieve.
+
+ Returns:
+ ODTCProtocol if a runnable method exists, None otherwise.
+ """
+ method_set = await self.get_method_set()
+ resolved = get_method_by_name(method_set, name)
+ if resolved is None or resolved.kind == "premethod":
+ return None
+ return resolved
+
+ async def list_protocols(self) -> ProtocolList:
+ """List all protocol names (methods and premethods) on the device.
+
+ Returns:
+ ProtocolList with .methods, .premethods, .all (flat list), and a __str__
+ that prints Methods and PreMethods in clear sections. Iteration yields
+ all names (methods then premethods).
+ """
+ method_set = await self.get_method_set()
+ return ProtocolList(
+ methods=list_method_names(method_set),
+ premethods=list_premethod_names(method_set),
+ )
+
+ async def list_methods(self) -> Tuple[List[str], List[str]]:
+ """List method names and premethod names separately.
+
+ Returns:
+ Tuple of (method_names, premethod_names). Methods are runnable protocols;
+ premethods are setup-only (e.g. set block/lid temperature).
+ """
+ method_set = await self.get_method_set()
+ return (list_method_names(method_set), list_premethod_names(method_set))
+
+ def get_default_config(self, **kwargs) -> ODTCConfig:
+ """Get a default ODTCConfig with variant set to this backend's variant.
+
+ Args:
+ **kwargs: Additional parameters to override defaults (e.g. name, lid_temperature).
+
+ Returns:
+ ODTCConfig with variant matching this backend (96 or 384-well).
+ """
+ return ODTCConfig(variant=self._variant, **kwargs)
+
+ def get_constraints(self) -> ODTCHardwareConstraints:
+ """Get hardware constraints for this backend's variant.
+
+ Returns:
+ ODTCHardwareConstraints for the current variant (96 or 384-well).
+ """
+ return get_constraints(self._variant)
+
+ # --- Protocol upload and run ---
+
+ async def _upload_odtc_protocol(
+ self,
+ odtc: ODTCProtocol,
+ allow_overwrite: bool = False,
+ execute: bool = False,
+ wait: bool = True,
+ debug_xml: bool = False,
+ xml_output_path: Optional[str] = None,
+ ) -> Optional[ODTCExecution]:
+ """Upload a single ODTCProtocol (method or premethod) and optionally execute.
+
+ Internal single entrypoint: builds one-item ODTCMethodSet and calls
+ upload_method_set, then execute_method if execute=True.
+
+ Returns:
+ ODTCExecution if execute=True and wait=False; None otherwise.
+ """
+ resolved_name = resolve_protocol_name(odtc.name)
+ is_scratch = not odtc.name or odtc.name == ""
+ resolved_datetime = odtc.datetime or generate_odtc_timestamp()
+
+ if is_scratch and allow_overwrite is False:
+ allow_overwrite = True
+ if not odtc.name:
+ self.logger.warning(
+ "ODTCProtocol name resolved to scratch name '%s'. " "Auto-enabling allow_overwrite=True.",
+ resolved_name,
+ )
+
+ odtc_copy = replace(odtc, name=resolved_name, datetime=resolved_datetime)
+ if odtc.kind == "method":
+ method_set = ODTCMethodSet(methods=[odtc_copy], premethods=[])
+ else:
+ method_set = ODTCMethodSet(methods=[], premethods=[odtc_copy])
+
+ await self.upload_method_set(
+ method_set,
+ allow_overwrite=allow_overwrite,
+ debug_xml=debug_xml,
+ xml_output_path=xml_output_path,
+ )
+
+ if execute:
+ handle = await self.execute_method(resolved_name, wait=wait)
+ protocol_view = odtc_protocol_to_protocol(odtc_copy)[0]
+ self._protocol_by_request_id[handle.request_id] = protocol_view
+ return handle
+ return None
+
+ async def upload_protocol(
+ self,
+ protocol: Union[Protocol, ODTCProtocol],
+ name: Optional[str] = None,
+ config: Optional[ODTCConfig] = None,
+ block_max_volume: Optional[float] = None,
+ allow_overwrite: bool = False,
+ debug_xml: bool = False,
+ xml_output_path: Optional[str] = None,
+ ) -> str:
+ """Upload a Protocol or ODTCProtocol to the device.
+
+ Args:
+ protocol: PyLabRobot Protocol or ODTCProtocol to upload.
+ name: Method name. If None, uses scratch name "plr_currentProtocol".
+ config: Optional ODTCConfig (used only when protocol is Protocol). If None,
+ uses variant-aware defaults; if block_max_volume is provided and in 0–100 µL,
+ sets fluid_quantity from it.
+ block_max_volume: Optional volume in µL. If provided and config is None,
+ used to set fluid_quantity. If config is provided, validates volume
+ matches fluid_quantity.
+ allow_overwrite: If False, raise ValueError if method name already exists.
+ debug_xml: If True, log generated XML at DEBUG.
+ xml_output_path: Optional path to save MethodSet XML.
+
+ Returns:
+ Resolved method name (string).
+
+ Raises:
+ ValueError: If allow_overwrite=False and method name already exists.
+ ValueError: If block_max_volume > 100 µL.
+ """
+ if isinstance(protocol, ODTCProtocol):
+ odtc = replace(protocol, name=name or protocol.name) if name is not None else protocol
+ else:
+ if config is None:
+ if block_max_volume is not None and block_max_volume > 0 and block_max_volume <= 100:
+ fluid_quantity = volume_to_fluid_quantity(block_max_volume)
+ config = self.get_default_config(fluid_quantity=fluid_quantity)
+ else:
+ config = self.get_default_config()
+ elif block_max_volume is not None and block_max_volume > 0:
+ validate_volume_fluid_quantity(
+ block_max_volume, config.fluid_quantity, is_premethod=False, logger=self.logger
+ )
+ if name is not None:
+ config = replace(config, name=name)
+ odtc = protocol_to_odtc_protocol(protocol, config=config)
+
+ await self._upload_odtc_protocol(
+ odtc,
+ allow_overwrite=allow_overwrite,
+ execute=False,
+ debug_xml=debug_xml,
+ xml_output_path=xml_output_path,
+ )
+ return resolve_protocol_name(odtc.name)
+
+ async def run_stored_protocol(self, name: str, wait: bool = False, **kwargs) -> ODTCExecution:
+ """Execute a stored protocol by name (single SiLA ExecuteMethod call).
+
+ No fetch or round-trip; calls the instrument execute-by-name directly.
+ Handle lifetime/ETA are event-driven (first DataEvent).
+
+ Args:
+ name: Name of the stored protocol (method) to run.
+ wait: If False (default), start and return handle. If True, block until
+ completion then return the (completed) handle.
+ **kwargs: Ignored (for API compatibility with base backend).
+
+ Returns:
+ Execution handle (completed if wait=True).
+ """
+ method_set = await self.get_method_set()
+ resolved = get_method_by_name(method_set, name)
+ protocol_view = odtc_protocol_to_protocol(resolved)[0] if resolved else None
+ return await self.execute_method(name, wait=wait, protocol=protocol_view)
+
+ async def upload_method_set(
+ self,
+ method_set: ODTCMethodSet,
+ allow_overwrite: bool = False,
+ debug_xml: bool = False,
+ xml_output_path: Optional[str] = None,
+ ) -> None:
+ """Upload a MethodSet to the device.
+
+ Args:
+ method_set: ODTCMethodSet to upload.
+ allow_overwrite: If False, raise ValueError if any method/premethod name
+ already exists on the device. If True, allow overwriting existing methods/premethods.
+ debug_xml: If True, log the generated XML to the logger at DEBUG level.
+ Useful for troubleshooting validation errors.
+ xml_output_path: Optional file path to save the generated MethodSet XML.
+ If provided, the XML will be written to this file before upload.
+ Useful for comparing with example XML files or debugging.
+
+ Raises:
+ ValueError: If allow_overwrite=False and any method/premethod name already exists
+ on the device (checking both methods and premethods for conflicts).
+ """
+ # Check for name conflicts if overwrite not allowed
+ if not allow_overwrite:
+ existing_method_set = await self.get_method_set()
+ conflicts = []
+
+ def _existing_item_type(existing: ODTCProtocol) -> str:
+ return "PreMethod" if existing.kind == "premethod" else "Method"
+
+ # Check all method names (unified search)
+ for method in method_set.methods:
+ existing_method = get_method_by_name(existing_method_set, method.name)
+ if existing_method is not None:
+ conflicts.append(
+ f"Method '{method.name}' already exists as {_existing_item_type(existing_method)}"
+ )
+
+ # Check all premethod names (unified search)
+ for premethod in method_set.premethods:
+ existing_method = get_method_by_name(existing_method_set, premethod.name)
+ if existing_method is not None:
+ conflicts.append(
+ f"Method '{premethod.name}' already exists as {_existing_item_type(existing_method)}"
+ )
+
+ if conflicts:
+ conflict_msg = "\n".join(f" - {c}" for c in conflicts)
+ raise ValueError(
+ f"Cannot upload MethodSet: name conflicts detected.\n{conflict_msg}\n"
+ f"Set allow_overwrite=True to overwrite existing methods."
+ )
+
+ method_set_xml = method_set_to_xml(method_set)
+
+ # Debug XML output if requested
+ if debug_xml or xml_output_path:
+ import xml.dom.minidom
+
+ # Pretty-print for readability
+ try:
+ dom = xml.dom.minidom.parseString(method_set_xml)
+ pretty_xml = dom.toprettyxml(indent=" ")
+ except Exception:
+ # Fallback to original if pretty-printing fails
+ pretty_xml = method_set_xml
+
+ if debug_xml:
+ self.logger.debug("Generated MethodSet XML:\n%s", pretty_xml)
+
+ if xml_output_path:
+ try:
+ with open(xml_output_path, "w", encoding="utf-8") as f:
+ f.write(pretty_xml)
+ self.logger.info("MethodSet XML saved to: %s", xml_output_path)
+ except Exception as e:
+ self.logger.warning("Failed to save XML to %s: %s", xml_output_path, e)
+
+ # SetParameters expects paramsXML in ResponseType_1.2.xsd format
+ # Format: ...
+ import xml.etree.ElementTree as ET
+
+ param_set = ET.Element("ParameterSet")
+ param = ET.SubElement(param_set, "Parameter", parameterType="String", name="MethodsXML")
+ string_elem = ET.SubElement(param, "String")
+ # XML needs to be escaped for embedding in another XML
+ string_elem.text = method_set_xml
+
+ params_xml = ET.tostring(param_set, encoding="unicode", xml_declaration=False)
+
+ if debug_xml:
+ self.logger.debug("Wrapped ParameterSet XML (sent to device):\n%s", params_xml)
+
+ await self._sila.send_command("SetParameters", paramsXML=params_xml)
+
+ async def upload_method_set_from_file(
+ self,
+ filepath: str,
+ allow_overwrite: bool = False,
+ ) -> None:
+ """Load and upload a MethodSet XML file to the device.
+
+ Args:
+ filepath: Path to MethodSet XML file.
+ allow_overwrite: If False, raise ValueError if any method/premethod name
+ already exists on the device. If True, allow overwriting existing methods/premethods.
+
+ Raises:
+ ValueError: If allow_overwrite=False and any method/premethod name already exists
+ on the device (checking both methods and premethods for conflicts).
+ """
+ method_set = parse_method_set_file(filepath)
+ await self.upload_method_set(method_set, allow_overwrite=allow_overwrite)
+
+ async def save_method_set_to_file(self, filepath: str) -> None:
+ """Download methods from device and save to file.
+
+ Args:
+ filepath: Path to save MethodSet XML file.
+ """
+ method_set_xml = await self._get_method_set_xml()
+ with open(filepath, "w", encoding="utf-8") as f:
+ f.write(method_set_xml)
+
+ # ============================================================================
+ # ThermocyclerBackend Abstract Methods
+ # ============================================================================
+
+ async def open_lid(self, wait: bool = True, **kwargs: Any):
+ """Open the thermocycler lid (ODTC SiLA: OpenDoor)."""
+ return await self.open_door(wait=wait)
+
+ async def close_lid(self, wait: bool = True, **kwargs: Any):
+ """Close the thermocycler lid (ODTC SiLA: CloseDoor)."""
+ return await self.close_door(wait=wait)
+
+ async def set_block_temperature(
+ self,
+ temperature: List[float],
+ lid_temperature: Optional[float] = None,
+ wait: bool = False,
+ debug_xml: bool = False,
+ xml_output_path: Optional[str] = None,
+ **kwargs: Any,
+ ):
+ """Set block (mount) temperature and hold it via PreMethod.
+
+ ODTC has no direct SetBlockTemperature command; this creates and runs a
+ PreMethod to set block and lid temperatures.
+
+ Args:
+ temperature: Target block temperature(s) in °C (ODTC single zone: use temperature[0]).
+ lid_temperature: Optional lid temperature in °C. If None, uses hardware max_lid_temp.
+ wait: If True, block until set. If False (default), return execution handle.
+ debug_xml: If True, log generated XML at DEBUG.
+ xml_output_path: Optional path to save MethodSet XML.
+ **kwargs: Ignored (for API compatibility).
+
+ Returns:
+ If wait=True: None. If wait=False: execution handle.
+ """
+ if not temperature:
+ raise ValueError("At least one block temperature required")
+ block_temp = temperature[0]
+ if lid_temperature is not None:
+ target_lid_temp = lid_temperature
+ else:
+ constraints = self.get_constraints()
+ target_lid_temp = constraints.max_lid_temp
+
+ resolved_name = resolve_protocol_name(None)
+ odtc = ODTCProtocol(
+ stages=[],
+ kind="premethod",
+ name=resolved_name,
+ target_block_temperature=block_temp,
+ target_lid_temperature=target_lid_temp,
+ datetime=generate_odtc_timestamp(),
+ )
+ await self._upload_odtc_protocol(
+ odtc,
+ allow_overwrite=True,
+ debug_xml=debug_xml,
+ xml_output_path=xml_output_path,
+ )
+ handle = await self.execute_method(resolved_name, wait=wait)
+ # Register ODTCProtocol so progress shows correct target_block_temperature
+ # (device DataEvent often reports current setpoint during premethod ramp, not final target).
+ self._protocol_by_request_id[handle.request_id] = odtc
+ return handle
+
+ async def set_lid_temperature(self, temperature: List[float]) -> None:
+ """Set lid temperature.
+
+ ODTC does not have a direct SetLidTemperature command; lid temperature is
+ set per protocol (ODTCConfig.lid_temperature) or inside a Method. Use
+ run_protocol() or run_stored_protocol() instead of run_pcr_profile().
+
+ Args:
+ temperature: Target temperature(s) in °C.
+
+ Raises:
+ NotImplementedError: ODTC does not support direct lid temperature setting.
+ """
+ raise NotImplementedError(
+ "ODTC does not support set_lid_temperature; lid temperature is set per "
+ "protocol or via ODTCConfig. Use run_protocol() or run_stored_protocol() "
+ "instead of run_pcr_profile()."
+ )
+
+ async def deactivate_block(self) -> None:
+ """Deactivate block (maps to StopMethod)."""
+ await self.stop_method()
+
+ async def deactivate_lid(self) -> None:
+ """Deactivate lid (maps to StopMethod)."""
+ await self.stop_method()
+
+ async def run_protocol(
+ self,
+ protocol: Union[Protocol, ODTCProtocol],
+ block_max_volume: float,
+ **kwargs: Any,
+ ) -> ODTCExecution:
+ """Execute thermocycler protocol (convert if needed, upload, execute).
+
+ Accepts Protocol or ODTCProtocol. Converts Protocol to ODTCProtocol when
+ needed, uploads, then executes by name. Always returns immediately with a
+ Execution handle; to block until completion, await handle.wait() or
+ use wait_for_profile_completion(). Config is derived from block_max_volume
+ and backend variant when protocol is Protocol and config is not provided.
+
+ Args:
+ protocol: Protocol or ODTCProtocol to execute.
+ block_max_volume: Maximum block volume (µL) for safety; used to set
+ fluid_quantity when protocol is Protocol and config is None.
+ **kwargs: Backend-specific options. ODTC accepts ``config`` (ODTCConfig,
+ optional); used only when protocol is Protocol.
+
+ Returns:
+ Execution handle. Caller can await handle.wait() or
+ wait_for_profile_completion() to block until done.
+ """
+ if isinstance(protocol, ODTCProtocol):
+ odtc = protocol
+ if odtc.kind != "method":
+ raise ValueError("run_protocol requires a method (ODTCProtocol with kind='method')")
+ else:
+ config = kwargs.pop("config", None)
+ if config is None:
+ if block_max_volume > 0 and block_max_volume <= 100:
+ fluid_quantity = volume_to_fluid_quantity(block_max_volume)
+ config = self.get_default_config(fluid_quantity=fluid_quantity)
+ else:
+ config = self.get_default_config()
+ if block_max_volume > 0 and block_max_volume <= 100:
+ validate_volume_fluid_quantity(
+ block_max_volume, config.fluid_quantity, is_premethod=False, logger=self.logger
+ )
+ else:
+ if block_max_volume > 0:
+ validate_volume_fluid_quantity(
+ block_max_volume, config.fluid_quantity, is_premethod=False, logger=self.logger
+ )
+ odtc = protocol_to_odtc_protocol(protocol, config=config)
+
+ await self._upload_odtc_protocol(odtc, allow_overwrite=True, execute=False)
+ resolved_name = resolve_protocol_name(odtc.name)
+ handle = await self.execute_method(resolved_name, wait=False)
+ protocol_view = odtc_protocol_to_protocol(odtc)[0]
+ self._protocol_by_request_id[handle.request_id] = protocol_view
+ return handle
+
+ # --- Temperatures and lid/block status ---
+
+ async def get_block_current_temperature(self) -> List[float]:
+ """Get current block temperature.
+
+ Returns:
+ List of block temperatures in °C (single zone for ODTC).
+ """
+ sensor_values = await self.read_temperatures()
+ return [sensor_values.mount]
+
+ async def get_block_target_temperature(self) -> List[float]:
+ """Get block target temperature.
+
+ Returns:
+ List of target temperatures in °C.
+
+ Raises:
+ RuntimeError: If no target is set.
+ """
+ # ODTC does not expose block target temperature; use is_method_running() or
+ # wait_for_method_completion() to monitor execution.
+ raise RuntimeError(
+ "ODTC does not report block target temperature; method execution state "
+ "not tracked. Use backend.is_method_running() or wait_for_method_completion() "
+ "to monitor execution."
+ )
+
+ async def get_lid_current_temperature(self) -> List[float]:
+ """Get current lid temperature.
+
+ Returns:
+ List of lid temperatures in °C (single zone for ODTC).
+ """
+ sensor_values = await self.read_temperatures()
+ return [sensor_values.lid]
+
+ async def get_lid_target_temperature(self) -> List[float]:
+ """Get lid target temperature.
+
+ Returns:
+ List of target temperatures in °C.
+
+ Raises:
+ RuntimeError: If no target is set.
+ """
+ # ODTC does not expose lid target temperature; use is_method_running() or
+ # wait_for_method_completion() to monitor execution.
+ raise RuntimeError(
+ "ODTC does not report lid target temperature; method execution state "
+ "not tracked. Use backend.is_method_running() or wait_for_method_completion() "
+ "to monitor execution."
+ )
+
+ async def get_lid_open(self) -> bool:
+ """Check if lid is open.
+
+ ODTC does not expose door open/closed state. Use open_door()/close_door() to
+ control the door; there is no query for current state.
+
+ Returns:
+ True if lid/door is open.
+
+ Raises:
+ NotImplementedError: ODTC does not support querying lid/door open state.
+ """
+ raise NotImplementedError(
+ "ODTC does not support get_lid_open; door status is not reported. Use "
+ "open_door() or close_door() to control the door."
+ )
+
+ async def get_lid_status(self) -> LidStatus:
+ """Get lid temperature status.
+
+ Returns:
+ LidStatus enum value.
+ """
+ # Simplified: if we can read temperature, assume it's holding
+ try:
+ await self.read_temperatures()
+ return LidStatus.HOLDING_AT_TARGET
+ except Exception:
+ return LidStatus.IDLE
+
+ async def get_block_status(self) -> BlockStatus:
+ """Get block temperature status.
+
+ Returns:
+ BlockStatus enum value.
+ """
+ # Simplified: if we can read temperature, assume it's holding
+ try:
+ await self.read_temperatures()
+ return BlockStatus.HOLDING_AT_TARGET
+ except Exception:
+ return BlockStatus.IDLE
+
+ # --- Progress and step/cycle (DataEvent) ---
+
+ async def _report_progress_once(
+ self,
+ request_id: int,
+ callback: Optional[Callable[..., None]] = None,
+ ) -> None:
+ """Fetch latest DataEvent for request_id, update snapshot, and log or invoke progress_callback.
+
+ Used by wait_for_completion_by_time and by ODTCExecution.wait() (background task).
+ No-op if _get_progress returns None (e.g. non-method command).
+ callback: Override for this call; if None, uses self.progress_callback.
+ """
+ progress = await self._get_progress(request_id)
+ if progress is None:
+ return
+ cb = callback if callback is not None else self.progress_callback
+ if cb is not None:
+ try:
+ cb(progress)
+ except Exception: # noqa: S110
+ pass
+ else:
+ self.logger.info(progress.format_progress_log_message())
+
+ async def _run_progress_loop_until(
+ self,
+ request_id: int,
+ interval: float,
+ done_async: Callable[[], Awaitable[bool]],
+ callback: Optional[Callable[..., None]] = None,
+ ) -> None:
+ """Run progress reporting every interval until done_async() returns True.
+
+ Single definition dispatched by both Future-based wait() and polling-based
+ wait_for_completion_by_time. Stops when done_async() returns True (e.g.
+ future.done() or status == terminal_state or timeout).
+ """
+ while not (await done_async()):
+ await self._report_progress_once(request_id, callback=callback)
+ await asyncio.sleep(interval)
+
+ def _protocol_total_step_count(self, protocol: Protocol) -> int:
+ """Total expanded step count from Protocol (for display when device does not send it)."""
+ return sum(len(stage.steps) * stage.repeats for stage in protocol.stages)
+
+ def _stored_to_odtc_protocol(
+ self, stored: Union[Protocol, ODTCProtocol]
+ ) -> Optional[ODTCProtocol]:
+ """Normalize stored protocol to ODTCProtocol for position lookup."""
+ if isinstance(stored, ODTCProtocol):
+ return stored
+ if isinstance(stored, Protocol):
+ return protocol_to_odtc_protocol(stored, self.get_default_config())
+ return None
+
+ async def _get_progress(self, request_id: int) -> Optional[ODTCProgress]:
+ """Get progress from latest DataEvent (elapsed, temps, step/cycle/hold). Returns None if no protocol registered."""
+ stored = self._protocol_by_request_id.get(request_id)
+ if stored is None:
+ return None
+ events_dict = await self.get_data_events(request_id)
+ events = events_dict.get(request_id, [])
+ payload = events[-1] if events else None
+ odtc = self._stored_to_odtc_protocol(stored)
+ return ODTCProgress.from_data_event(payload, odtc=odtc)
+
+ def _request_id_for_get_progress(self) -> Optional[int]:
+ """Request ID of current execution for get_* methods; None if none or done."""
+ ex = self._current_execution
+ if ex is None or ex.done:
+ return None
+ return ex.request_id
+
+ async def get_progress_snapshot(self) -> Optional[ODTCProgress]:
+ """Get progress from the latest DataEvent for the current run (elapsed, temperatures, step/cycle/hold).
+
+ Returns None if no protocol is running. Returns ODTCProgress: elapsed, temperatures,
+ and step/cycle/hold derived from elapsed time and the run's protocol when registered.
+ """
+ request_id = self._request_id_for_get_progress()
+ if request_id is None:
+ return None
+ return await self._get_progress(request_id)
+
+ async def get_hold_time(self) -> float:
+ """Get remaining hold time in seconds for the current step."""
+ request_id = self._request_id_for_get_progress()
+ if request_id is None:
+ raise RuntimeError("No profile running; get_hold_time requires an active method execution.")
+ progress = await self._get_progress(request_id)
+ if progress is None:
+ raise RuntimeError(
+ "No protocol associated with this run; get_hold_time requires a registered protocol."
+ )
+ return progress.remaining_hold_s if progress.remaining_hold_s is not None else 0.0
+
+ async def get_current_cycle_index(self) -> int:
+ """Get zero-based current cycle index."""
+ request_id = self._request_id_for_get_progress()
+ if request_id is None:
+ raise RuntimeError(
+ "No profile running; get_current_cycle_index requires an active method execution."
+ )
+ progress = await self._get_progress(request_id)
+ if progress is None:
+ raise RuntimeError(
+ "No protocol associated with this run; get_current_cycle_index requires a registered protocol."
+ )
+ return progress.current_cycle_index if progress.current_cycle_index is not None else 0
+
+ async def get_total_cycle_count(self) -> int:
+ """Get total cycle count for the current stage."""
+ request_id = self._request_id_for_get_progress()
+ if request_id is None:
+ raise RuntimeError(
+ "No profile running; get_total_cycle_count requires an active method execution."
+ )
+ progress = await self._get_progress(request_id)
+ if progress is None:
+ raise NotImplementedError(
+ "ODTC does not report total cycle count; no protocol associated with this run."
+ )
+ return progress.total_cycle_count if progress.total_cycle_count is not None else 0
+
+ async def get_current_step_index(self) -> int:
+ """Get zero-based current step index within the cycle."""
+ request_id = self._request_id_for_get_progress()
+ if request_id is None:
+ raise RuntimeError(
+ "No profile running; get_current_step_index requires an active method execution."
+ )
+ progress = await self._get_progress(request_id)
+ if progress is None:
+ raise RuntimeError(
+ "No protocol associated with this run; get_current_step_index requires a registered protocol."
+ )
+ return progress.current_step_index if progress.current_step_index is not None else 0
+
+ async def get_total_step_count(self) -> int:
+ """Get total number of steps in the current cycle."""
+ request_id = self._request_id_for_get_progress()
+ if request_id is None:
+ raise RuntimeError(
+ "No profile running; get_total_step_count requires an active method execution."
+ )
+ progress = await self._get_progress(request_id)
+ if progress is None:
+ raise NotImplementedError(
+ "ODTC does not report total step count; no protocol associated with this run."
+ )
+ return progress.total_step_count if progress.total_step_count is not None else 0
diff --git a/pylabrobot/thermocycling/inheco/odtc_model.py b/pylabrobot/thermocycling/inheco/odtc_model.py
new file mode 100644
index 00000000000..6f9fa0a3d6c
--- /dev/null
+++ b/pylabrobot/thermocycling/inheco/odtc_model.py
@@ -0,0 +1,1964 @@
+"""
+ODTC model: domain types, XML serialization, and Protocol conversion.
+
+Defines ODTC dataclasses (ODTCProtocol, ODTCConfig, etc.), schema-driven
+XML serialization for MethodSet, and conversion between PyLabRobot Protocol
+and ODTC representation. Methods and premethods are consolidated as ODTCProtocol
+(kind='method' | 'premethod').
+"""
+
+from __future__ import annotations
+
+import html
+import logging
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass, field, fields, replace
+from datetime import datetime
+from enum import Enum
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Dict,
+ Iterator,
+ List,
+ Literal,
+ Optional,
+ Tuple,
+ Type,
+ TypeVar,
+ Union,
+ cast,
+ get_args,
+ get_origin,
+ get_type_hints,
+)
+
+from pylabrobot.thermocycling.standard import Protocol, Stage, Step
+
+if TYPE_CHECKING:
+ pass # Protocol used at runtime for ODTCProtocol base
+
+logger = logging.getLogger(__name__)
+
+T = TypeVar("T")
+
+
+# =============================================================================
+# Scratch Method Names
+# =============================================================================
+
+SCRATCH_PROTOCOL_NAME = "plr_currentProtocol"
+
+
+# =============================================================================
+# Timestamp Generation
+# =============================================================================
+
+
+def generate_odtc_timestamp() -> str:
+ """Generate ISO 8601 timestamp in ODTC format.
+
+ Returns:
+ Timestamp string in ISO 8601 format: YYYY-MM-DDTHH:MM:SS.ffffff (6 decimal places).
+ Example: "2026-01-26T14:30:45.123456"
+
+ Note:
+ Uses Python's standard ISO 8601 formatting with microseconds (6 decimal places).
+ ODTC accepts timestamps with 0-7 decimal places, so this standard format is compatible.
+ """
+ return datetime.now().isoformat(timespec="microseconds")
+
+
+def resolve_protocol_name(name: Optional[str]) -> str:
+ """Resolve protocol name, using scratch name if not provided.
+
+ Args:
+ name: Protocol name (may be None or empty string).
+
+ Returns:
+ Resolved name. If name is None or empty, returns scratch name.
+ """
+ if not name: # None or empty string
+ return SCRATCH_PROTOCOL_NAME
+ return name
+
+
+# =============================================================================
+# Hardware Constraints
+# =============================================================================
+
+
+@dataclass(frozen=True)
+class ODTCDimensions:
+ """ODTC footprint dimensions (mm). Single source of truth for resource sizing."""
+
+ x: float
+ y: float
+ z: float
+
+
+ODTC_DIMENSIONS = ODTCDimensions(x=156.5, y=248.0, z=124.3)
+
+# PreMethod estimated duration (10 min) when DynamicPreMethodDuration is off (ODTC Firmware doc).
+PREMETHOD_ESTIMATED_DURATION_SECONDS: float = 600.0
+
+
+@dataclass(frozen=True)
+class ODTCHardwareConstraints:
+ """Hardware limits for ODTC variants - immutable reference data.
+
+ These values are derived from Inheco documentation and Script Editor defaults.
+ Note: Actual achievable rates may vary based on fluid quantity and target temperature.
+ """
+
+ variant: int
+ variant_name: str
+ min_block_temp: float = 4.0
+ max_block_temp: float = 99.0
+ 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, ...] = (-1, 0, 1, 2) # -1 = verification tool
+ valid_plate_types: Tuple[int, ...] = (0,)
+ max_steps_per_method: int = 100
+
+
+ODTC_96_CONSTRAINTS = ODTCHardwareConstraints(
+ variant=960000,
+ variant_name="ODTC 96",
+ max_heating_slope=4.4,
+ max_lid_temp=110.0,
+ valid_fluid_quantities=(-1, 0, 1, 2),
+)
+
+ODTC_384_CONSTRAINTS = ODTCHardwareConstraints(
+ variant=384000,
+ variant_name="ODTC 384",
+ max_heating_slope=5.0,
+ max_lid_temp=115.0,
+ valid_fluid_quantities=(-1, 0, 1, 2), # Same as 96-well per XML samples
+ valid_plate_types=(0, 2), # Only 0 observed in XML samples
+)
+
+_CONSTRAINTS_MAP: Dict[int, ODTCHardwareConstraints] = {
+ 960000: ODTC_96_CONSTRAINTS,
+ 384000: ODTC_384_CONSTRAINTS,
+ 3840000: ODTC_384_CONSTRAINTS, # Alias
+}
+
+
+def get_constraints(variant: int) -> ODTCHardwareConstraints:
+ """Get hardware constraints for a variant.
+
+ Args:
+ variant: ODTC variant code (960000 for 96-well, 384000 for 384-well).
+
+ Returns:
+ ODTCHardwareConstraints for the specified variant.
+
+ Raises:
+ ValueError: If variant is unknown.
+ """
+ if variant not in _CONSTRAINTS_MAP:
+ raise ValueError(f"Unknown variant {variant}. Valid: {list(_CONSTRAINTS_MAP.keys())}")
+ return _CONSTRAINTS_MAP[variant]
+
+
+_VALID_VARIANTS = (96, 384, 960000, 384000, 3840000)
+
+
+def normalize_variant(variant: int) -> int:
+ """Normalize variant to ODTC device code.
+
+ Accepts well count (96, 384) or device codes (960000, 384000, 3840000).
+ Maps 96 -> 960000, 384 -> 384000; passes through 960000, 384000, 3840000 unchanged.
+
+ Args:
+ variant: Well count (96, 384) or ODTC variant code (960000, 384000, 3840000).
+
+ Returns:
+ ODTC variant code: 960000 or 384000.
+
+ Raises:
+ ValueError: If variant is not one of 96, 384, 960000, 384000, 3840000.
+ """
+ if variant == 96:
+ return 960000
+ if variant in (384, 3840000):
+ return 384000
+ if variant in (960000, 384000):
+ return variant
+ raise ValueError(f"Unknown variant {variant}. Valid: {list(_VALID_VARIANTS)}")
+
+
+# =============================================================================
+# Volume / fluid quantity (ODTC domain rule: µL -> fluid_quantity code)
+# =============================================================================
+
+
+def volume_to_fluid_quantity(volume_ul: float) -> int:
+ """Map volume in µL to ODTC fluid_quantity code.
+
+ Args:
+ volume_ul: Volume in microliters.
+
+ Returns:
+ fluid_quantity code: 0 (10-29ul), 1 (30-74ul), or 2 (75-100ul).
+
+ Raises:
+ ValueError: If volume > 100 µL.
+ """
+ if volume_ul > 100:
+ raise ValueError(
+ f"Volume {volume_ul} µL exceeds ODTC maximum of 100 µL. "
+ "Please use a volume between 0-100 µL."
+ )
+ if volume_ul <= 29:
+ return 0 # 10-29ul
+ if volume_ul <= 74:
+ return 1 # 30-74ul
+ return 2 # 75-100ul
+
+
+def validate_volume_fluid_quantity(
+ volume_ul: float,
+ fluid_quantity: int,
+ is_premethod: bool = False,
+ logger: Optional[logging.Logger] = None,
+) -> None:
+ """Validate that volume matches fluid_quantity and warn if mismatch.
+
+ Args:
+ volume_ul: Volume in microliters.
+ fluid_quantity: ODTC fluid_quantity code (0, 1, or 2).
+ is_premethod: If True, suppress warnings for volume=0 (premethods don't need volume).
+ logger: Logger for warnings (uses module logger if None).
+ """
+ log = logger if logger is not None else logging.getLogger(__name__)
+ if volume_ul <= 0:
+ if not is_premethod:
+ log.warning(
+ "block_max_volume=%s µL is invalid. Using default fluid_quantity=1 (30-74ul). "
+ "Please provide a valid volume for accurate thermal calibration.",
+ volume_ul,
+ )
+ return
+ if volume_ul > 100:
+ raise ValueError(
+ f"Volume {volume_ul} µL exceeds ODTC maximum of 100 µL. "
+ "Please use a volume between 0-100 µL."
+ )
+ expected = volume_to_fluid_quantity(volume_ul)
+ if fluid_quantity != expected:
+ volume_ranges = {0: "10-29 µL", 1: "30-74 µL", 2: "75-100 µL"}
+ log.warning(
+ "Volume mismatch: block_max_volume=%s µL suggests fluid_quantity=%s (%s), "
+ "but config has fluid_quantity=%s (%s). This may affect thermal calibration accuracy.",
+ volume_ul,
+ expected,
+ volume_ranges[expected],
+ fluid_quantity,
+ volume_ranges.get(fluid_quantity, "unknown"),
+ )
+
+
+# =============================================================================
+# XML Field Metadata
+# =============================================================================
+
+
+class XMLFieldType(Enum):
+ """How a field maps to XML."""
+
+ ELEMENT = "element" # value
+ ATTRIBUTE = "attribute" #
+ CHILD_LIST = "child_list" # List of child elements
+
+
+@dataclass(frozen=True)
+class XMLField:
+ """Metadata for XML field mapping."""
+
+ tag: Optional[str] = None # XML tag name (defaults to field name)
+ field_type: XMLFieldType = XMLFieldType.ELEMENT
+ default: Any = None # Default value if missing
+ scale: float = 1.0 # For unit conversion (e.g., 1/100°C -> °C)
+
+
+def xml_field(
+ tag: Optional[str] = None,
+ field_type: XMLFieldType = XMLFieldType.ELEMENT,
+ default: Any = None,
+ scale: float = 1.0,
+) -> Any:
+ """Create a dataclass field with XML metadata."""
+ 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:
+ """Shorthand for an XML attribute field."""
+ return xml_field(tag=tag, field_type=XMLFieldType.ATTRIBUTE, default=default)
+
+
+def xml_child_list(tag: Optional[str] = None) -> Any:
+ """Shorthand for a list of child elements."""
+ metadata = {"xml": XMLField(tag=tag, field_type=XMLFieldType.CHILD_LIST, default=None)}
+ return field(default_factory=list, metadata=metadata)
+
+
+# =============================================================================
+# ODTC Data Classes with XML Schema
+# =============================================================================
+
+
+@dataclass
+class ODTCStep(Step):
+ """A single step in an ODTC method. Subclasses Step; ODTC params are canonical."""
+
+ # Step requires temperature, hold_seconds, rate; we give defaults and sync from ODTC in __post_init__
+ temperature: List[float] = field(default_factory=lambda: [0.0])
+ hold_seconds: float = 0.0
+ rate: Optional[float] = None
+ number: int = xml_field(tag="Number", default=0)
+ slope: float = xml_field(tag="Slope", default=0.0)
+ plateau_temperature: float = xml_field(tag="PlateauTemperature", default=0.0)
+ plateau_time: float = xml_field(tag="PlateauTime", default=0.0)
+ overshoot_slope1: float = xml_field(tag="OverShootSlope1", default=0.1)
+ overshoot_temperature: float = xml_field(tag="OverShootTemperature", default=0.0)
+ overshoot_time: float = xml_field(tag="OverShootTime", default=0.0)
+ overshoot_slope2: float = xml_field(tag="OverShootSlope2", default=0.1)
+ goto_number: int = xml_field(tag="GotoNumber", default=0)
+ loop_number: int = xml_field(tag="LoopNumber", default=0)
+ pid_number: int = xml_field(tag="PIDNumber", default=1)
+ lid_temp: float = xml_field(tag="LidTemp", default=110.0)
+
+ def __post_init__(self) -> None:
+ # Keep Step interface in sync with ODTC canonical params
+ self.temperature = [self.plateau_temperature]
+ self.hold_seconds = self.plateau_time
+ self.rate = self.slope
+
+ @classmethod
+ def from_step(
+ cls,
+ step: Step,
+ number: int = 0,
+ goto_number: int = 0,
+ loop_number: int = 0,
+ ) -> "ODTCStep":
+ """Build ODTCStep from a generic Step (e.g. when serializing plain Stage); uses ODTC defaults for overshoot/lid/pid."""
+ temp = step.temperature[0] if step.temperature else 25.0
+ return cls(
+ number=number,
+ slope=step.rate if step.rate is not None else 0.1,
+ plateau_temperature=temp,
+ plateau_time=step.hold_seconds,
+ overshoot_slope1=0.1,
+ overshoot_temperature=0.0,
+ overshoot_time=0.0,
+ overshoot_slope2=0.1,
+ goto_number=goto_number,
+ loop_number=loop_number,
+ pid_number=1,
+ lid_temp=110.0,
+ )
+
+
+@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 as ODTCProtocol (kind='method' | 'premethod')."""
+
+ delete_all_methods: bool = False
+ premethods: List[ODTCProtocol] = field(default_factory=list)
+ methods: List[ODTCProtocol] = field(default_factory=list)
+
+
+@dataclass
+class ODTCSensorValues:
+ """Temperature sensor readings from ODTC.
+
+ Note: Raw values from device are in 1/100°C, but are automatically
+ converted to °C by the scale parameter.
+ """
+
+ 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:
+ """Human-readable labeled temperatures in °C (multi-line for display/notebooks)."""
+ 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:
+ """Single-line format for logs and parsing (one reading per log line)."""
+ 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}"
+
+
+# =============================================================================
+# DataEvent payload parsing (private; used only inside ODTCProgress.from_data_event)
+# =============================================================================
+
+
+def _parse_data_event_series_value(series_elem: Any) -> Optional[float]:
+ """Extract last integerValue from a dataSeries element as 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]) -> Optional[Dict[str, Any]]:
+ """Parse a single DataEvent payload into a dict (elapsed_s, temps, etc.).
+
+ Used only inside ODTCProgress.from_data_event. Input: dict with 'dataValue'
+ (string of XML, possibly double-escaped). Returns dict with keys:
+ elapsed_s, target_temp_c, current_temp_c, lid_temp_c, current_step_index,
+ total_step_count, current_cycle_index, total_cycle_count, remaining_hold_s.
+ Returns None on parse error.
+ """
+ if not isinstance(payload, dict):
+ return None
+ data_value = payload.get("dataValue")
+ if not data_value or not isinstance(data_value, str):
+ return None
+ try:
+ outer = ET.fromstring(data_value)
+ except ET.ParseError:
+ return None
+ any_data = outer.find(".//{*}AnyData") or outer.find(".//AnyData")
+ if any_data is None or any_data.text is None:
+ return None
+ inner_xml = any_data.text.strip()
+ if not inner_xml:
+ return None
+ if "<" in inner_xml or ">" in inner_xml:
+ inner_xml = html.unescape(inner_xml)
+ try:
+ inner = ET.fromstring(inner_xml)
+ except ET.ParseError:
+ return None
+ elapsed_s = 0.0
+ target_temp_c: Optional[float] = None
+ current_temp_c: Optional[float] = None
+ lid_temp_c: Optional[float] = None
+ current_step_index: Optional[int] = None
+ total_step_count: Optional[int] = None
+ current_cycle_index: Optional[int] = None
+ total_cycle_count: Optional[int] = None
+ remaining_hold_s: 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
+ elif name_id == "Step":
+ current_step_index = max(0, int(raw) - 1)
+ elif name_id == "Total steps":
+ total_step_count = max(0, int(raw))
+ elif name_id == "Cycle":
+ current_cycle_index = max(0, int(raw) - 1)
+ elif name_id == "Total cycles":
+ total_cycle_count = max(0, int(raw))
+ elif name_id == "Hold remaining" or name_id == "Remaining hold":
+ remaining_hold_s = raw / 1000.0 if unit == "ms" else float(raw)
+ if current_step_index is None:
+ for elem in inner.iter():
+ if elem.tag.endswith("experimentStep"):
+ seq = elem.get("sequence")
+ if seq is not None:
+ try:
+ current_step_index = max(0, int(seq) - 1)
+ except ValueError:
+ pass
+ break
+ return {
+ "elapsed_s": elapsed_s,
+ "target_temp_c": target_temp_c,
+ "current_temp_c": current_temp_c,
+ "lid_temp_c": lid_temp_c,
+ "current_step_index": current_step_index,
+ "total_step_count": total_step_count,
+ "current_cycle_index": current_cycle_index,
+ "total_cycle_count": total_cycle_count,
+ "remaining_hold_s": remaining_hold_s,
+ }
+
+
+# =============================================================================
+# Protocol Conversion Config Classes
+# =============================================================================
+
+
+@dataclass
+class ODTCStepSettings:
+ """Per-step ODTC parameters for Protocol to ODTCProtocol conversion.
+
+ When converting ODTCProtocol to Protocol, these capture the original values.
+ When converting Protocol to ODTCProtocol, these override defaults.
+ """
+
+ slope: Optional[float] = None
+ overshoot_slope1: Optional[float] = None
+ overshoot_temperature: Optional[float] = None
+ overshoot_time: Optional[float] = None
+ overshoot_slope2: Optional[float] = None
+ lid_temp: Optional[float] = None
+ pid_number: Optional[int] = None
+
+
+@dataclass
+class ODTCConfig:
+ """ODTC-specific configuration for running a Protocol.
+
+ This class serves two purposes:
+ 1. When creating new protocols: Specify ODTC-specific parameters
+ 2. When extracting from ODTCProtocol: Captures all params for lossless round-trip
+
+ Validation is performed on construction by default. Set _validate=False to skip
+ validation (useful when reading data from a trusted source like the device).
+ """
+
+ # Method identification/metadata
+ name: Optional[str] = None
+ creator: Optional[str] = None
+ description: Optional[str] = None
+ datetime: Optional[str] = None
+
+ # Device calibration
+ fluid_quantity: int = 1 # -1=verification, 0=10-29ul, 1=30-74ul, 2=75-100ul
+ variant: int = 960000 # 96-well ODTC
+ plate_type: int = 0
+
+ # Temperature settings
+ lid_temperature: float = 110.0
+ start_lid_temperature: Optional[float] = None # If different from lid_temperature
+ post_heating: bool = True
+
+ # Default ramp rates (°C/s) - defaults to hardware max for fastest transitions
+ # Used when Step.rate is None and no step_settings override
+ default_heating_slope: float = 4.4 # Will be validated against variant constraints
+ default_cooling_slope: float = 2.2 # Will be validated against variant constraints
+
+ # PID configuration (full set for round-trip preservation)
+ pid_set: List[ODTCPID] = field(default_factory=lambda: [ODTCPID(number=1)])
+
+ # Per-step overrides/captures (keyed by step index, 0-based)
+ step_settings: Dict[int, ODTCStepSettings] = field(default_factory=dict)
+
+ # Validation control - set to False to skip validation on construction
+ _validate: bool = field(default=True, repr=False)
+
+ def __post_init__(self):
+ if self._validate:
+ self.validate()
+
+ @property
+ def constraints(self) -> ODTCHardwareConstraints:
+ """Get hardware constraints for this config's variant."""
+ return get_constraints(self.variant)
+
+ def validate(self) -> List[str]:
+ """Validate config against hardware constraints.
+
+ Returns:
+ List of validation error messages (empty if valid).
+
+ Raises:
+ ValueError: If any validation fails.
+ """
+ errors: List[str] = []
+ c = self.constraints
+
+ # Validate fluid_quantity
+ 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 {c.variant_name}. "
+ f"Valid: {c.valid_fluid_quantities}"
+ )
+
+ # Validate plate_type
+ if self.plate_type not in c.valid_plate_types:
+ errors.append(
+ f"plate_type={self.plate_type} invalid for {c.variant_name}. "
+ f"Valid: {c.valid_plate_types}"
+ )
+
+ # Validate lid_temperature
+ if not c.min_lid_temp <= self.lid_temperature <= c.max_lid_temp:
+ errors.append(
+ f"lid_temperature={self.lid_temperature}°C outside range "
+ f"[{c.min_lid_temp}, {c.max_lid_temp}] for {c.variant_name}"
+ )
+
+ # Validate default slopes
+ if self.default_heating_slope > c.max_heating_slope:
+ errors.append(
+ f"default_heating_slope={self.default_heating_slope}°C/s exceeds max "
+ f"{c.max_heating_slope}°C/s for {c.variant_name}"
+ )
+ if self.default_cooling_slope > c.max_cooling_slope:
+ errors.append(
+ f"default_cooling_slope={self.default_cooling_slope}°C/s exceeds max "
+ f"{c.max_cooling_slope}°C/s for {c.variant_name}"
+ )
+
+ # Validate step_settings
+ for idx, settings in self.step_settings.items():
+ if settings.lid_temp is not None:
+ if not c.min_lid_temp <= settings.lid_temp <= c.max_lid_temp:
+ errors.append(
+ f"step_settings[{idx}].lid_temp={settings.lid_temp}°C outside range "
+ f"[{c.min_lid_temp}, {c.max_lid_temp}]"
+ )
+ if settings.slope is not None:
+ # Can't easily check heating vs cooling without knowing step sequence,
+ # so just check against the higher max
+ max_slope = max(c.max_heating_slope, c.max_cooling_slope)
+ if settings.slope > max_slope:
+ errors.append(
+ f"step_settings[{idx}].slope={settings.slope}°C/s exceeds max {max_slope}°C/s"
+ )
+
+ if errors:
+ raise ValueError("ODTCConfig validation failed:\n - " + "\n - ".join(errors))
+
+ return errors
+
+
+# =============================================================================
+# ODTCStage (Stage with optional nested inner_stages for loop tree)
+# =============================================================================
+
+
+@dataclass
+class ODTCStage(Stage):
+ """Stage with optional inner_stages for nested loops.
+
+ Execution: steps and inner_stages are interleaved (steps[0], inner_stages[0],
+ steps[1], inner_stages[1], ...); then the whole block repeats `repeats` times.
+ So for outer 1-5 with inner 2-4: steps=[step1, step5], inner_stages=[ODTCStage(2-4, 5)].
+ At runtime steps are ODTCStep; we cast to List[Step] at construction so Stage.steps stays List[Step].
+ """
+
+ inner_stages: Optional[List["ODTCStage"]] = None
+
+
+# =============================================================================
+# ODTCProtocol (protocol + config; subclasses Protocol for resource API)
+# =============================================================================
+
+
+@dataclass
+class ODTCProtocol(Protocol):
+ """ODTC runnable unit: protocol + config (method or premethod).
+
+ Subclasses Protocol so Thermocycler.run_protocol(protocol, ...) accepts
+ ODTCProtocol. For kind='method', stages is the cycle; for kind='premethod',
+ pass stages=[] (premethods run by name only).
+ """
+
+ kind: Literal["method", "premethod"] = "method"
+ name: str = ""
+ creator: Optional[str] = None
+ description: Optional[str] = None
+ datetime: Optional[str] = None
+ target_block_temperature: float = 0.0
+ target_lid_temperature: float = 0.0
+ variant: int = 960000
+ plate_type: int = 0
+ fluid_quantity: int = 0
+ post_heating: bool = False
+ start_block_temperature: float = 0.0
+ start_lid_temperature: float = 0.0
+ steps: List[ODTCStep] = field(default_factory=list)
+ pid_set: List[ODTCPID] = field(default_factory=lambda: [ODTCPID(number=1)])
+ step_settings: Dict[int, ODTCStepSettings] = field(default_factory=dict)
+ default_heating_slope: float = 4.4
+ default_cooling_slope: float = 2.2
+
+ def __str__(self) -> str:
+ """Human-readable summary: name, kind, steps or target temps, key config."""
+ lines: List[str] = [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:
+ steps = self.steps
+ if not steps:
+ lines.append(" 0 steps")
+ else:
+ lines.append(f" {len(steps)} step(s)")
+ for s in steps:
+ hold_str = f"{s.plateau_time:.1f}s" if s.plateau_time != float("inf") else "∞"
+ loop_str = (
+ f" goto={s.goto_number} loop={s.loop_number}" if s.goto_number or s.loop_number else ""
+ )
+ lines.append(
+ f" step {s.number}: {s.plateau_temperature:.1f}°C hold {hold_str}{loop_str}"
+ )
+ lines.append(f" start_block_temperature={self.start_block_temperature:.1f}°C")
+ lines.append(f" start_lid_temperature={self.start_lid_temperature:.1f}°C")
+ if self.variant is not None:
+ lines.append(f" variant={self.variant}")
+ return "\n".join(lines)
+
+
+def protocol_to_odtc_protocol(
+ protocol: "Protocol",
+ config: Optional[ODTCConfig] = None,
+) -> ODTCProtocol:
+ """Convert a standard Protocol to ODTCProtocol (kind='method').
+
+ Args:
+ protocol: Standard Protocol with stages and steps.
+ config: Optional ODTC config; if None, defaults are used.
+
+ Returns:
+ ODTCProtocol (kind='method') ready for upload or run. Steps are authoritative;
+ stages=[] so the stage view is derived via odtc_method_to_protocol(odtc) when needed.
+ """
+ if config is None:
+ config = ODTCConfig()
+
+ odtc_steps: List[ODTCStep] = []
+ step_number = 1
+
+ # Track previous temperature for slope calculation
+ # Start from room temperature - first step needs to ramp from ambient
+ prev_temp = 25.0
+
+ for stage_idx, stage in enumerate(protocol.stages):
+ stage_start_step = step_number
+
+ for step_idx, step in enumerate(stage.steps):
+ # Get the target temperature (use first zone for ODTC single-zone)
+ target_temp = step.temperature[0] if step.temperature else 25.0
+
+ # Calculate slope
+ slope = _calculate_slope(prev_temp, target_temp, step.rate, config)
+
+ # Get step settings overrides if any
+ # Use global step index (across all stages)
+ global_step_idx = step_number - 1
+ step_setting = config.step_settings.get(global_step_idx, ODTCStepSettings())
+
+ # Create ODTC step with defaults or overrides
+ odtc_step = ODTCStep(
+ number=step_number,
+ slope=step_setting.slope if step_setting.slope is not None else slope,
+ plateau_temperature=target_temp,
+ plateau_time=step.hold_seconds,
+ overshoot_slope1=(
+ step_setting.overshoot_slope1 if step_setting.overshoot_slope1 is not None else 0.1
+ ),
+ overshoot_temperature=(
+ step_setting.overshoot_temperature
+ if step_setting.overshoot_temperature is not None
+ else 0.0
+ ),
+ overshoot_time=(
+ step_setting.overshoot_time if step_setting.overshoot_time is not None else 0.0
+ ),
+ overshoot_slope2=(
+ step_setting.overshoot_slope2 if step_setting.overshoot_slope2 is not None else 0.1
+ ),
+ goto_number=0, # Will be set below for loops
+ loop_number=0, # Will be set below for loops
+ pid_number=step_setting.pid_number if step_setting.pid_number is not None else 1,
+ lid_temp=(
+ step_setting.lid_temp if step_setting.lid_temp is not None else config.lid_temperature
+ ),
+ )
+
+ odtc_steps.append(odtc_step)
+ prev_temp = target_temp
+ step_number += 1
+
+ # If stage has repeats > 1, add loop on the last step of the stage
+ if stage.repeats > 1 and odtc_steps:
+ last_step = odtc_steps[-1]
+ last_step.goto_number = stage_start_step
+ last_step.loop_number = stage.repeats # LoopNumber = actual repeat count (per loaded_set.xml)
+
+ # Determine start temperatures
+ start_block_temp = protocol.stages[0].steps[0].temperature[0] if protocol.stages else 25.0
+ start_lid_temp = (
+ config.start_lid_temperature
+ if config.start_lid_temperature is not None
+ else config.lid_temperature
+ )
+
+ # Resolve method name (use scratch name if not provided)
+ resolved_name = resolve_protocol_name(config.name)
+
+ # Generate timestamp if not already set
+ resolved_datetime = config.datetime if config.datetime else generate_odtc_timestamp()
+
+ return ODTCProtocol(
+ kind="method",
+ name=resolved_name,
+ variant=config.variant,
+ plate_type=config.plate_type,
+ fluid_quantity=config.fluid_quantity,
+ post_heating=config.post_heating,
+ start_block_temperature=start_block_temp,
+ start_lid_temperature=start_lid_temp,
+ steps=odtc_steps,
+ pid_set=list(config.pid_set),
+ creator=config.creator,
+ description=config.description,
+ datetime=resolved_datetime,
+ stages=[], # Steps are authoritative; stage view via odtc_method_to_protocol(odtc)
+ )
+
+
+def odtc_protocol_to_protocol(odtc: ODTCProtocol) -> Tuple["Protocol", ODTCProtocol]:
+ """Convert ODTCProtocol to Protocol view and return (protocol, odtc).
+
+ For kind='method', builds Protocol from steps; for kind='premethod',
+ returns Protocol(stages=[]) since premethods have no cycle.
+
+ Returns:
+ Tuple of (Protocol, ODTCProtocol). The ODTCProtocol is the same object
+ for convenience (e.g. config fields).
+ """
+ if odtc.kind == "method":
+ protocol, _ = odtc_method_to_protocol(odtc)
+ return (protocol, odtc)
+ return (Protocol(stages=[]), odtc)
+
+
+def estimate_odtc_protocol_duration_seconds(odtc: ODTCProtocol) -> float:
+ """Estimate total run duration for an ODTCProtocol.
+
+ Premethods use PREMETHOD_ESTIMATED_DURATION_SECONDS; methods use
+ step/loop-based estimation. For estimation/tooling only; the ODTC backend
+ does not use this for handle lifetime/eta (event-driven).
+
+ Returns:
+ Estimated duration in seconds.
+ """
+ if odtc.kind == "premethod":
+ return PREMETHOD_ESTIMATED_DURATION_SECONDS
+ return estimate_method_duration_seconds(odtc)
+
+
+# =============================================================================
+# Generic XML Serialization/Deserialization
+# =============================================================================
+
+
+def _get_xml_meta(f) -> XMLField:
+ """Get XMLField metadata from a dataclass field, or create default."""
+ if "xml" in f.metadata:
+ return cast(XMLField, f.metadata["xml"])
+ # Default: element with field name as tag
+ return XMLField(tag=None, field_type=XMLFieldType.ELEMENT)
+
+
+def _get_tag(f, meta: XMLField) -> str:
+ """Get the XML tag name for a field."""
+ return meta.tag if meta.tag else f.name
+
+
+def _get_inner_type(type_hint) -> Optional[Type[Any]]:
+ """Extract the inner type from List[T] or Optional[T]."""
+ 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:
+ # Optional[T] is Union[T, None]
+ 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:
+ """Check if a type is a dataclass."""
+ return hasattr(tp, "__dataclass_fields__")
+
+
+def _parse_value(text: Optional[str], field_type: Type, scale: float = 1.0) -> Any:
+ """Parse a string value to the appropriate Python type."""
+ 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:
+ """Format a Python value to string for XML."""
+ if isinstance(value, bool):
+ return "true" if value else "false"
+ if isinstance(value, float):
+ scaled = value / scale if scale != 1.0 else value
+ # Avoid unnecessary decimals for whole numbers
+ 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 instance.
+
+ Uses field metadata to map XML tags/attributes to fields.
+ """
+ if not _is_dataclass_type(cls):
+ raise TypeError(f"{cls} is not a dataclass")
+
+ kwargs: Dict[str, Any] = {}
+
+ # Use get_type_hints to resolve string annotations to actual types
+ type_hints = get_type_hints(cls)
+
+ # For ODTCStep, only read ODTC XML tags (not Step's temperature/hold_seconds/rate)
+ step_field_names = {"temperature", "hold_seconds", "rate"} if cls is ODTCStep else set()
+
+ # Type narrowing: we've verified cls is a dataclass, so fields() is safe
+ for f in fields(cls): # type: ignore[arg-type]
+ if f.name in step_field_names:
+ continue
+ meta = _get_xml_meta(f)
+ tag = _get_tag(f, meta)
+ field_type = type_hints.get(f.name, f.type)
+
+ # Handle Optional types
+ 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:
+ # Read from element 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:
+ # Read from child element text
+ 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:
+ # Read list of child elements
+ 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] = []
+
+ if cls is ODTCStep:
+ kwargs["temperature"] = [kwargs.get("plateau_temperature", 0.0)]
+ kwargs["hold_seconds"] = kwargs.get("plateau_time", 0.0)
+ kwargs["rate"] = kwargs.get("slope", 0.0)
+
+ return cls(**kwargs)
+
+
+def to_xml(
+ obj: Any, tag_name: Optional[str] = None, parent: Optional[ET.Element] = None
+) -> ET.Element:
+ """
+ Serialize a dataclass instance to an XML element.
+
+ Uses field metadata to map fields to XML tags/attributes.
+ """
+ if not _is_dataclass_type(type(obj)):
+ raise TypeError(f"{type(obj)} is not a dataclass")
+
+ # Determine element tag name
+ if tag_name is None:
+ tag_name = type(obj).__name__
+
+ # Create element
+ if parent is not None:
+ elem = ET.SubElement(parent, tag_name)
+ else:
+ elem = ET.Element(tag_name)
+
+ # For ODTCStep, only serialize ODTC fields (not Step's temperature/hold_seconds/rate)
+ skip_fields = {"temperature", "hold_seconds", "rate"} if type(obj) is ODTCStep else set()
+
+ for f in fields(type(obj)):
+ if f.name in skip_fields:
+ continue
+ meta = _get_xml_meta(f)
+ tag = _get_tag(f, meta)
+ value = getattr(obj, f.name)
+
+ # Skip None values
+ 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
+
+
+# =============================================================================
+# MethodSet-specific parsing: XML <-> ODTCProtocol (no ODTCMethod/ODTCPreMethod)
+# =============================================================================
+
+
+def _read_opt_attr(elem: ET.Element, key: str, default: Optional[str] = None) -> Optional[str]:
+ """Read optional attribute from element."""
+ return elem.attrib.get(key, default)
+
+
+def _read_opt_elem(
+ elem: ET.Element, tag: str, default: Any = None, parse_float: bool = False
+) -> Any:
+ """Read optional child element text. If parse_float, return float; else str or default."""
+ 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_method_element_to_odtc_protocol(elem: ET.Element) -> ODTCProtocol:
+ """Parse a element into ODTCProtocol (kind='method', stages=[]). No nested-loop validation."""
+ name = _read_opt_attr(elem, "methodName") or ""
+ creator = _read_opt_attr(elem, "creator")
+ description = _read_opt_attr(elem, "description")
+ datetime_ = _read_opt_attr(elem, "dateTime")
+ 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)
+ steps = [from_xml(step_elem, ODTCStep) for step_elem in elem.findall("Step")]
+ 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",
+ name=name,
+ 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,
+ steps=steps,
+ pid_set=pid_set,
+ stages=[], # Not built on parse; built on demand in odtc_protocol_to_protocol
+ )
+
+
+def _parse_premethod_element_to_odtc_protocol(elem: ET.Element) -> ODTCProtocol:
+ """Parse a element into ODTCProtocol (kind='premethod')."""
+ name = _read_opt_attr(elem, "methodName") or ""
+ creator = _read_opt_attr(elem, "creator")
+ description = _read_opt_attr(elem, "description")
+ datetime_ = _read_opt_attr(elem, "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(
+ kind="premethod",
+ name=name,
+ creator=creator,
+ description=description,
+ datetime=datetime_,
+ target_block_temperature=target_block_temperature,
+ target_lid_temperature=target_lid_temperature,
+ stages=[],
+ )
+
+
+def _get_steps_for_serialization(odtc: ODTCProtocol) -> List[ODTCStep]:
+ """Return canonical ODTCStep list for serializing an ODTCProtocol (kind='method').
+
+ Uses odtc.steps when present; otherwise builds from odtc.stages via _odtc_stages_to_steps.
+ """
+ if odtc.steps:
+ return odtc.steps
+ if odtc.stages:
+ stages_as_odtc = []
+ for s in odtc.stages:
+ if isinstance(s, ODTCStage):
+ stages_as_odtc.append(s)
+ else:
+ steps_odtc = [st if isinstance(st, ODTCStep) else ODTCStep.from_step(st) for st in s.steps]
+ stages_as_odtc.append(
+ ODTCStage(steps=cast(List[Step], steps_odtc), repeats=s.repeats, inner_stages=None)
+ )
+ return _odtc_stages_to_steps(stages_as_odtc)
+ return []
+
+
+def _odtc_protocol_to_method_xml(odtc: ODTCProtocol, parent: ET.Element) -> ET.Element:
+ """Serialize ODTCProtocol (kind='method') to XML."""
+ if odtc.kind != "method":
+ raise ValueError("ODTCProtocol must have kind='method' to serialize as Method")
+ steps_to_serialize = _get_steps_for_serialization(odtc)
+ elem = ET.SubElement(parent, "Method")
+ elem.set("methodName", odtc.name)
+ if odtc.creator:
+ elem.set("creator", odtc.creator)
+ if odtc.description:
+ elem.set("description", odtc.description)
+ if odtc.datetime:
+ elem.set("dateTime", odtc.datetime)
+ ET.SubElement(elem, "Variant").text = str(odtc.variant)
+ ET.SubElement(elem, "PlateType").text = str(odtc.plate_type)
+ ET.SubElement(elem, "FluidQuantity").text = str(odtc.fluid_quantity)
+ ET.SubElement(elem, "PostHeating").text = "true" if odtc.post_heating else "false"
+ ET.SubElement(elem, "StartBlockTemperature").text = _format_value(odtc.start_block_temperature)
+ ET.SubElement(elem, "StartLidTemperature").text = _format_value(odtc.start_lid_temperature)
+ for step in steps_to_serialize:
+ to_xml(step, "Step", elem)
+ if odtc.pid_set:
+ pid_set_elem = ET.SubElement(elem, "PIDSet")
+ for pid in odtc.pid_set:
+ to_xml(pid, "PID", pid_set_elem)
+ return elem
+
+
+def _odtc_protocol_to_premethod_xml(odtc: ODTCProtocol, parent: ET.Element) -> ET.Element:
+ """Serialize ODTCProtocol (kind='premethod') to XML."""
+ if odtc.kind != "premethod":
+ raise ValueError("ODTCProtocol must have kind='premethod' to serialize as PreMethod")
+ elem = ET.SubElement(parent, "PreMethod")
+ elem.set("methodName", odtc.name)
+ if odtc.creator:
+ elem.set("creator", odtc.creator)
+ if odtc.description:
+ elem.set("description", odtc.description)
+ if odtc.datetime:
+ elem.set("dateTime", odtc.datetime)
+ ET.SubElement(elem, "TargetBlockTemperature").text = _format_value(odtc.target_block_temperature)
+ ET.SubElement(elem, "TargetLidTemp").text = _format_value(odtc.target_lid_temperature)
+ return elem
+
+
+# =============================================================================
+# Convenience Functions
+# =============================================================================
+
+
+def parse_method_set_from_root(root: ET.Element) -> ODTCMethodSet:
+ """Parse a MethodSet from an XML root element into ODTCProtocol only.
+
+ Methods and premethods are parsed directly to ODTCProtocol (stages=[] for
+ methods so list_protocols does not trigger nested-loop validation).
+ """
+ delete_elem = root.find("DeleteAllMethods")
+ delete_all = False
+ if delete_elem is not None and delete_elem.text:
+ delete_all = delete_elem.text.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 (ODTCProtocol -> Method/PreMethod elements)."""
+ 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)
+
+
+# =============================================================================
+# Method Lookup Helpers
+# =============================================================================
+
+
+def get_premethod_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCProtocol]:
+ """Find a premethod by name."""
+ return next((pm for pm in method_set.premethods if pm.name == name), None)
+
+
+def _get_method_only_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCProtocol]:
+ """Find a method by name (methods only, not premethods)."""
+ return next((m for m in method_set.methods if m.name == name), None)
+
+
+def get_method_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCProtocol]:
+ """Find a method or premethod by name. Returns ODTCProtocol or None."""
+ m = _get_method_only_by_name(method_set, name)
+ if m is not None:
+ return m
+ return get_premethod_by_name(method_set, name)
+
+
+def list_method_names(method_set: ODTCMethodSet) -> List[str]:
+ """Get all method names (methods only, not premethods)."""
+ return [m.name for m in method_set.methods]
+
+
+def list_premethod_names(method_set: ODTCMethodSet) -> List[str]:
+ """Get all premethod names."""
+ return [pm.name for pm in method_set.premethods]
+
+
+class ProtocolList:
+ """Result of list_protocols(): methods and premethods with nice __str__ and backward-compat .all / iteration."""
+
+ def __init__(self, methods: List[str], premethods: List[str]) -> None:
+ self.methods = list(methods)
+ self.premethods = list(premethods)
+
+ @property
+ def all(self) -> List[str]:
+ """Flat list of all protocol names (methods then premethods), for backward compatibility."""
+ return self.methods + self.premethods
+
+ def __iter__(self) -> Iterator[str]:
+ yield from self.all
+
+ def __str__(self) -> str:
+ lines: List[str] = ["Methods (runnable protocols):"]
+ if self.methods:
+ for name in self.methods:
+ lines.append(f" - {name}")
+ else:
+ lines.append(" (none)")
+ lines.append("PreMethods (setup-only, e.g. set temperature):")
+ if self.premethods:
+ for name in self.premethods:
+ lines.append(f" - {name}")
+ else:
+ lines.append(" (none)")
+ return "\n".join(lines)
+
+ def __eq__(self, other: object) -> bool:
+ if isinstance(other, list):
+ return self.all == other
+ if isinstance(other, ProtocolList):
+ return self.methods == other.methods and self.premethods == other.premethods
+ return NotImplemented
+
+
+# =============================================================================
+# Protocol Conversion Functions
+# =============================================================================
+
+
+def _calculate_slope(
+ from_temp: float,
+ to_temp: float,
+ rate: Optional[float],
+ config: ODTCConfig,
+) -> float:
+ """Calculate and validate slope (ramp rate) for temperature transition.
+
+ Both Protocol.Step.rate and ODTC slope represent the same thing: ramp rate in °C/s.
+ This function validates against hardware limits and clamps if necessary.
+
+ Args:
+ from_temp: Starting temperature in °C.
+ to_temp: Target temperature in °C.
+ rate: Optional rate from Protocol Step (°C/s). Same units as ODTC slope.
+ config: ODTC config with default slopes and variant.
+
+ Returns:
+ Slope value in °C/s, clamped to hardware limits if necessary.
+ """
+ constraints = get_constraints(config.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 rate is not None:
+ # User provided an explicit rate - validate and clamp if needed
+ if rate > max_slope:
+ logger.warning(
+ "Requested %s rate %.2f °C/s exceeds hardware maximum %.2f °C/s. "
+ "Clamping to maximum. Temperature transition: %.1f°C → %.1f°C",
+ direction,
+ rate,
+ max_slope,
+ from_temp,
+ to_temp,
+ )
+ return max_slope
+ return rate
+
+ # No rate specified - use config defaults (which should already be within limits)
+ default_slope = config.default_heating_slope if is_heating else config.default_cooling_slope
+
+ # Validate config defaults too (in case user configured invalid defaults)
+ if default_slope > max_slope:
+ logger.warning(
+ "Config default_%s_slope %.2f °C/s exceeds hardware maximum %.2f °C/s. "
+ "Clamping to maximum.",
+ direction,
+ default_slope,
+ max_slope,
+ )
+ return max_slope
+
+ return default_slope
+
+
+def _analyze_loop_structure(
+ steps: List[ODTCStep],
+) -> List[Tuple[int, int, int]]:
+ """Analyze loop structure in ODTC steps.
+
+ Args:
+ steps: List of ODTCStep objects.
+
+ Returns:
+ List of (start_step, end_step, repeat_count) tuples, sorted by end position.
+ Step numbers are 1-based as in the XML.
+ """
+ loops = []
+ for step in steps:
+ if step.goto_number > 0:
+ # LoopNumber in XML is actual repeat count (per loaded_set.xml / firmware doc)
+ loops.append((step.goto_number, step.number, step.loop_number))
+ return sorted(loops, key=lambda x: x[1]) # Sort by end position
+
+
+def _build_one_odtc_stage_for_range(
+ steps_by_num: Dict[int, ODTCStep],
+ loops: List[Tuple[int, int, int]],
+ start: int,
+ end: int,
+ repeats: int,
+) -> ODTCStage:
+ """Build one ODTCStage for step range [start, end] with repeats; recurse for inner loops."""
+ # Loops strictly inside (start, end): contained if start <= s and e <= end and (start,end) != (s,e)
+ 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:
+ # Flat: all steps in range are one stage (use ODTCStep directly)
+ stage_steps = [steps_by_num[n] for n in range(start, end + 1) if n in steps_by_num]
+ return ODTCStage(steps=cast(List[Step], stage_steps), repeats=repeats, inner_stages=None)
+
+ # Nested: partition range into step-only segments and inner loops; interleave steps and inner_stages
+ 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)
+ sorted(step_nums_in_range)
+
+ # Groups: steps before first inner, between inners, after last inner
+ 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[ODTCStep] = []
+ inner_stages_list: List[ODTCStage] = []
+ for gi, (is_, ie, ir) in enumerate(inner_loops_sorted):
+ if gi < len(step_groups):
+ steps_list.extend(steps_by_num[n] for n in step_groups[gi])
+ inner_stages_list.append(_build_one_odtc_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] for n in step_groups[len(inner_loops_sorted)])
+ return ODTCStage(
+ steps=cast(List[Step], steps_list), repeats=repeats, inner_stages=inner_stages_list
+ )
+
+
+def _odtc_stage_to_steps_impl(
+ stage: "ODTCStage",
+ start_number: int,
+) -> Tuple[List[ODTCStep], int]:
+ """Convert one ODTCStage to ODTCSteps with step numbers; return (steps, next_number)."""
+ inner_stages = stage.inner_stages or []
+ out: List[ODTCStep] = []
+ num = start_number
+ first_step_num = start_number
+
+ for i, step in enumerate(stage.steps):
+ # stage.steps are ODTCStep (or Step when from plain Stage); copy and assign number
+ if isinstance(step, ODTCStep):
+ step_copy = replace(step, number=num)
+ else:
+ step_copy = ODTCStep.from_step(step, number=num)
+ out.append(step_copy)
+ num += 1
+ if i < len(inner_stages):
+ inner_steps, num = _odtc_stage_to_steps_impl(inner_stages[i], num)
+ out.extend(inner_steps)
+
+ if stage.repeats > 1 and out:
+ out[-1].goto_number = first_step_num
+ out[-1].loop_number = stage.repeats
+ return (out, num)
+
+
+def _odtc_stages_to_steps(stages: List["ODTCStage"]) -> List[ODTCStep]:
+ """Convert ODTCStage tree to flat List[ODTCStep] with correct step numbers and goto/loop."""
+ result: List[ODTCStep] = []
+ num = 1
+ for stage in stages:
+ steps, num = _odtc_stage_to_steps_impl(stage, num)
+ result.extend(steps)
+ return result
+
+
+def _build_odtc_stages_from_steps(steps: List[ODTCStep]) -> List[ODTCStage]:
+ """Build ODTCStage tree from ODTC steps (handles flat and nested loops).
+
+ Uses _analyze_loop_structure for (start, end, repeat_count). No loops -> one stage
+ with all steps, repeats=1. We only emit for top-level loops (loops not contained in
+ any other), so outer 1-5 x 30 with inner 2-4 x 5 produces one ODTCStage with inner_stages.
+ """
+ if not steps:
+ return []
+ steps_by_num = {s.number: s for s in steps}
+ loops = _analyze_loop_structure(steps)
+ max_step = max(s.number for s in steps)
+
+ if not loops:
+ flat = [steps_by_num[n] for n in range(1, max_step + 1) if n in steps_by_num]
+ return [ODTCStage(steps=cast(List[Step], flat), repeats=1, inner_stages=None)]
+
+ 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()
+ for s, e, _ in top_level:
+ for n in range(s, e + 1):
+ step_nums_in_top_level.add(n)
+
+ stages: List[ODTCStage] = []
+ 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 run of steps not in any top-level loop (use ODTCStep directly)
+ flat_steps: List[ODTCStep] = []
+ 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])
+ i += 1
+ if flat_steps:
+ stages.append(ODTCStage(steps=cast(List[Step], flat_steps), repeats=1, inner_stages=None))
+ continue
+ # i is inside some top-level loop; find the loop that ends at the smallest end >= i
+ for start, end, repeats in top_level:
+ if start <= i <= end:
+ stages.append(_build_one_odtc_stage_for_range(steps_by_num, loops, start, end, repeats))
+ i = end + 1
+ break
+ else:
+ i += 1
+
+ return stages
+
+
+def _expand_step_sequence(
+ steps: List[ODTCStep],
+ loops: List[Tuple[int, int, int]],
+) -> List[int]:
+ """Return step numbers (1-based) in execution order with loops expanded."""
+ if not steps:
+ return []
+ steps_by_num = {s.number: s for s in steps}
+ max_step = max(s.number for s in 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 steps_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 steps_by_num:
+ expanded.append(j)
+ i += 1
+ return expanded
+
+
+def odtc_expanded_step_count(odtc: ODTCProtocol) -> int:
+ """Return total step count in execution order (loops expanded). Used for progress display when device does not send it."""
+ if not odtc.steps:
+ return 0
+ loops = _analyze_loop_structure(odtc.steps)
+ return len(_expand_step_sequence(odtc.steps, loops))
+
+
+def odtc_cycle_count(odtc: ODTCProtocol) -> int:
+ """Return cycle count from ODTC loop structure (main/top-level loop repeat count). Used for progress when device does not send it."""
+ if not odtc.steps:
+ return 0
+ loops = _analyze_loop_structure(odtc.steps)
+ if not loops:
+ return 1
+ # Top-level loop(s): not contained in any other; take the outermost (largest span) as main cycle count.
+ top_level = [
+ (start, end, count)
+ for (start, end, count) in loops
+ if not any((s, e, _) != (start, end, count) and s <= start and end <= e for (s, e, _) in loops)
+ ]
+ if not top_level:
+ return 0
+ # Single top-level loop (typical PCR) -> its repeat count; else outermost span's repeat count.
+ main = max(top_level, key=lambda x: x[1] - x[0])
+ return main[2]
+
+
+def estimate_method_duration_seconds(odtc: ODTCProtocol) -> float:
+ """Estimate total method duration from steps (ramp + plateau + overshoot, with loops).
+
+ Per ODTC Firmware Command Set: duration is slope time + overshoot time + plateau
+ time per step in consideration of the loops. For estimation/tooling only; the ODTC
+ backend does not use this for handle lifetime/eta (event-driven).
+
+ Args:
+ odtc: ODTCProtocol (kind='method') with steps and start_block_temperature.
+
+ Returns:
+ Estimated duration in seconds.
+ """
+ if not odtc.steps:
+ return 0.0
+ loops = _analyze_loop_structure(odtc.steps)
+ step_nums = _expand_step_sequence(odtc.steps, loops)
+ steps_by_num = {s.number: s for s in odtc.steps}
+
+ total = 0.0
+ prev_temp = odtc.start_block_temperature
+ min_slope = 0.1
+
+ for step_num in step_nums:
+ step = steps_by_num[step_num]
+ slope = max(abs(step.slope), min_slope)
+ ramp_time = abs(step.plateau_temperature - prev_temp) / slope
+ total += ramp_time + step.plateau_time + step.overshoot_time
+ prev_temp = step.plateau_temperature
+
+ return total
+
+
+# =============================================================================
+# Protocol position from elapsed time (private; used only inside ODTCProgress.from_data_event)
+# =============================================================================
+
+
+def _build_protocol_timeline(
+ odtc: ODTCProtocol,
+) -> List[Tuple[float, float, int, int, float, float]]:
+ """Build timeline segments for an ODTCProtocol (method or premethod).
+
+ Returns a list of (t_start, t_end, step_index, cycle_index, setpoint_c, plateau_end_t).
+ step_index is 0-based within cycle; cycle_index is 0-based.
+ plateau_end_t is the time at which the plateau (hold) ends for remaining_hold_s.
+ """
+ if odtc.kind == "premethod":
+ duration = PREMETHOD_ESTIMATED_DURATION_SECONDS
+ setpoint = odtc.target_block_temperature
+ return [(0.0, duration, 0, 0, setpoint, duration)]
+
+ if not odtc.steps:
+ return []
+
+ loops = _analyze_loop_structure(odtc.steps)
+ step_nums = _expand_step_sequence(odtc.steps, loops)
+ steps_by_num = {s.number: s for s in odtc.steps}
+ total_expanded = len(step_nums)
+ total_cycles = odtc_cycle_count(odtc)
+ 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.start_block_temperature
+ min_slope = 0.1
+
+ for flat_index, step_num in enumerate(step_nums):
+ step = steps_by_num[step_num]
+ slope = max(abs(step.slope), min_slope)
+ ramp_time = abs(step.plateau_temperature - prev_temp) / slope
+ plateau_end_t = t + ramp_time + step.plateau_time
+ segment_end = t + ramp_time + step.plateau_time + step.overshoot_time
+
+ cycle_index = flat_index // steps_per_cycle
+ step_index = flat_index % steps_per_cycle
+ setpoint = step.plateau_temperature
+
+ segments.append((t, segment_end, step_index, cycle_index, setpoint, plateau_end_t))
+ t = segment_end
+ prev_temp = step.plateau_temperature
+
+ return segments
+
+
+def _protocol_position_from_elapsed(odtc: ODTCProtocol, elapsed_s: float) -> Dict[str, Any]:
+ """Compute protocol position (step, cycle, setpoint, remaining hold) from elapsed time.
+
+ Used only inside ODTCProgress.from_data_event. Returns dict with keys:
+ step_index, cycle_index, setpoint_c, remaining_hold_s, total_steps, total_cycles.
+ """
+ if elapsed_s < 0:
+ elapsed_s = 0.0
+
+ segments = _build_protocol_timeline(odtc)
+ if not segments:
+ total_steps = odtc_expanded_step_count(odtc) if odtc.steps else 0
+ total_cycles = odtc_cycle_count(odtc) if odtc.steps else 1
+ return {
+ "step_index": 0,
+ "cycle_index": 0,
+ "setpoint_c": odtc.start_block_temperature
+ if hasattr(odtc, "start_block_temperature")
+ else None,
+ "remaining_hold_s": 0.0,
+ "total_steps": total_steps,
+ "total_cycles": total_cycles,
+ }
+
+ if odtc.kind == "method" and odtc.steps:
+ total_expanded = len(_expand_step_sequence(odtc.steps, _analyze_loop_structure(odtc.steps)))
+ total_cycles = odtc_cycle_count(odtc)
+ 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:
+ remaining = max(0.0, plateau_end_t - elapsed_s)
+ return {
+ "step_index": step_index,
+ "cycle_index": cycle_index,
+ "setpoint_c": setpoint_c,
+ "remaining_hold_s": remaining,
+ "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,
+ }
+
+
+# =============================================================================
+# ODTCProgress (raw DataEvent payload + optional protocol -> progress for interface)
+# =============================================================================
+
+
+@dataclass
+class ODTCProgress:
+ """Progress for a run: built from raw DataEvent payload and optional ODTCProtocol.
+
+ Single type for all progress/duration. Event-derived: elapsed_s, temps (from
+ parsing payload). Protocol-derived: step/cycle/setpoint/hold (from timeline lookup).
+ estimated_duration_s is our protocol-based total; remaining_duration_s is always
+ max(0, estimated_duration_s - elapsed_s). Device never sends estimated or remaining duration.
+ Returned from get_progress_snapshot and passed to the progress callback.
+ str(progress) or format_progress_log_message() gives the standard progress line (same as logged every progress_log_interval).
+ """
+
+ 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
+
+ @classmethod
+ def from_data_event(
+ cls,
+ payload: Optional[Dict[str, Any]],
+ odtc: Optional[ODTCProtocol] = None,
+ ) -> "ODTCProgress":
+ """Build ODTCProgress from raw DataEvent payload and optional protocol.
+
+ payload: Raw DataEvent dict (or None for no events yet). Parsed via _parse_data_event_payload.
+ odtc: When not None, position (step/cycle/setpoint/hold) and estimated/remaining duration
+ are computed from protocol; remaining_duration_s = max(0, estimated_duration_s - elapsed_s).
+ """
+ parsed = _parse_data_event_payload(payload) if payload is not None else None
+ if parsed is None:
+ elapsed_s = 0.0
+ target_temp_c: Optional[float] = None
+ current_temp_c: Optional[float] = None
+ lid_temp_c: Optional[float] = None
+ step_idx = 0
+ step_count = 0
+ cycle_idx = 0
+ cycle_count = 0
+ hold_s = 0.0
+ else:
+ 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")
+ step_idx = parsed["current_step_index"] if parsed.get("current_step_index") is not None else 0
+ step_count = parsed["total_step_count"] if parsed.get("total_step_count") is not None else 0
+ cycle_idx = (
+ parsed["current_cycle_index"] if parsed.get("current_cycle_index") is not None else 0
+ )
+ cycle_count = (
+ parsed["total_cycle_count"] if parsed.get("total_cycle_count") is not None else 0
+ )
+ hold_s = parsed["remaining_hold_s"] if parsed.get("remaining_hold_s") is not None else 0.0
+
+ if odtc is None:
+ est_s: Optional[float] = None
+ rem_s = 0.0
+ return cls(
+ elapsed_s=elapsed_s,
+ target_temp_c=target_temp_c,
+ current_temp_c=current_temp_c,
+ lid_temp_c=lid_temp_c,
+ current_step_index=step_idx,
+ total_step_count=step_count,
+ current_cycle_index=cycle_idx,
+ total_cycle_count=cycle_count,
+ remaining_hold_s=hold_s,
+ estimated_duration_s=est_s,
+ remaining_duration_s=rem_s,
+ )
+
+ position = _protocol_position_from_elapsed(odtc, elapsed_s)
+ target = target_temp_c
+ if odtc.kind == "premethod":
+ target = odtc.target_block_temperature
+ elif position.get("setpoint_c") is not None and target is None:
+ target = position["setpoint_c"]
+
+ if odtc.kind == "premethod":
+ est_s = PREMETHOD_ESTIMATED_DURATION_SECONDS
+ else:
+ est_s = estimate_odtc_protocol_duration_seconds(odtc)
+ rem_s = max(0.0, est_s - elapsed_s)
+
+ return cls(
+ 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,
+ )
+
+ def format_progress_log_message(self) -> str:
+ """Return the progress log message (elapsed, step/cycle/setpoint when present, temps)."""
+ 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:
+ """Same as format_progress_log_message(); use for consistent printing and progress reporting."""
+ return self.format_progress_log_message()
+
+
+def odtc_method_to_protocol(odtc: ODTCProtocol) -> Tuple["Protocol", ODTCConfig]:
+ """Convert an ODTCProtocol (kind='method') to a Protocol with companion config.
+
+ Args:
+ odtc: The ODTCProtocol to convert.
+
+ Returns:
+ Tuple of (Protocol, ODTCConfig) where the config captures
+ all ODTC-specific parameters needed to reconstruct the original method.
+ """
+ from pylabrobot.thermocycling.standard import Protocol
+
+ step_settings: Dict[int, ODTCStepSettings] = {}
+ for i, step in enumerate(odtc.steps):
+ step_settings[i] = ODTCStepSettings(
+ slope=step.slope,
+ overshoot_slope1=step.overshoot_slope1,
+ overshoot_temperature=step.overshoot_temperature,
+ overshoot_time=step.overshoot_time,
+ overshoot_slope2=step.overshoot_slope2,
+ lid_temp=step.lid_temp,
+ pid_number=step.pid_number,
+ )
+
+ config = ODTCConfig(
+ name=odtc.name,
+ creator=odtc.creator,
+ description=odtc.description,
+ datetime=odtc.datetime,
+ fluid_quantity=odtc.fluid_quantity,
+ variant=odtc.variant,
+ plate_type=odtc.plate_type,
+ lid_temperature=odtc.start_lid_temperature,
+ start_lid_temperature=odtc.start_lid_temperature,
+ post_heating=odtc.post_heating,
+ pid_set=list(odtc.pid_set) if odtc.pid_set else [ODTCPID(number=1)],
+ step_settings=step_settings,
+ _validate=False,
+ )
+
+ stages = _build_odtc_stages_from_steps(odtc.steps)
+ return Protocol(stages=cast(List[Stage], stages)), config
diff --git a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py
new file mode 100644
index 00000000000..740dce97b02
--- /dev/null
+++ b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py
@@ -0,0 +1,1143 @@
+"""ODTC-specific SiLA interface with parallelism, state management, and lockId validation.
+
+This module extends InhecoSiLAInterface to support ODTC-specific requirements:
+- Multiple in-flight commands with parallelism enforcement
+- State machine tracking and command allowability checks
+- LockId validation (defaults to None, validates when device is locked)
+- Proper return code handling (including device-specific codes)
+- All event types (ResponseEvent, StatusEvent, DataEvent, ErrorEvent)
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import time
+import urllib.request
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass
+from enum import Enum
+from typing import Any, Dict, List, Literal, Optional, Set
+
+from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface
+from pylabrobot.storage.inheco.scila.soap import XSI, soap_decode, soap_encode
+
+from .odtc_model import ODTCProgress
+
+# -----------------------------------------------------------------------------
+# SiLA/ODTC exceptions (typed command and device errors)
+# -----------------------------------------------------------------------------
+
+
+class SiLAError(RuntimeError):
+ """Base exception for SiLA command and device errors. Use .code for return-code-specific handling (4=busy, 5=lock, 6=requestId, 9=state, 11=parameter, 1000+=device)."""
+
+ def __init__(self, msg: str, code: Optional[int] = None) -> None:
+ super().__init__(msg)
+ self.code = code
+
+
+class SiLATimeoutError(SiLAError):
+ """Command timed out: lifetime_of_execution exceeded or ResponseEvent not received."""
+
+ pass
+
+
+class FirstEventTimeout(SiLAError):
+ """No first event received within timeout (e.g. no DataEvent for ExecuteMethod)."""
+
+ pass
+
+
+# -----------------------------------------------------------------------------
+# SOAP responses for events
+# -----------------------------------------------------------------------------
+
+SOAP_RESPONSE_ResponseEventResponse = """
+
+
+
+ 1
+ Success
+ PT0.0006262S
+ 0
+
+
+
+"""
+
+SOAP_RESPONSE_StatusEventResponse = """
+
+
+
+ 1
+ Success
+ PT0.0005967S
+ 0
+
+
+
+"""
+
+SOAP_RESPONSE_DataEventResponse = """
+
+
+
+ 1
+ Success
+ PT0.0005967S
+ 0
+
+
+
+"""
+
+SOAP_RESPONSE_ErrorEventResponse = """
+
+
+
+ 1
+ Success
+ PT0.0005967S
+ 0
+
+
+
+"""
+
+
+class SiLAState(str, Enum):
+ """SiLA device states per specification.
+
+ Note: State values match what the ODTC device actually returns.
+ Based on SCILABackend comment, devices return: "standby", "inError", "startup" (mixed/lowercase).
+ However, the actual ODTC device returns "standby" (all lowercase) as seen in practice.
+ """
+
+ STARTUP = "startup"
+ STANDBY = "standby" # Device returns "standby" (all lowercase) or "standBy" (camelCase)
+ INITIALIZING = "initializing"
+ IDLE = "idle"
+ BUSY = "busy"
+ PAUSED = "paused"
+ ERRORHANDLING = (
+ "errorHandling" # Device returns "errorHandling" (camelCase per SCILABackend pattern)
+ )
+ INERROR = "inError" # Device returns "inError" (camelCase per SCILABackend comment)
+
+
+class FirstEventType(str, Enum):
+ """Event type to wait for before handing off an async command handle.
+
+ Per SiLA Device Control & Data Interface Spec and ODTC TD_SILA_FWCommandSet:
+ - DataEvent: transmission of data during async command execution (has requestId).
+ Used by ExecuteMethod (methods and premethods) per ODTC firmware.
+ - StatusEvent: unsolicited device state changes. Used by OpenDoor, CloseDoor,
+ Initialize, Reset, LockDevice, UnlockDevice, StopMethod (no DataEvent stream).
+ """
+
+ DATA_EVENT = "DataEvent"
+ STATUS_EVENT = "StatusEvent"
+
+
+# Command -> event type to wait for (first event before returning handle).
+# Verified against SiLA_Device_Control__Data__Interface_Specification_V1.2.01 and
+# TD_SILA_FWCommandSet (ODTC): only ExecuteMethod sends DataEvents; others use StatusEvent.
+COMMAND_FIRST_EVENT_TYPE: Dict[str, FirstEventType] = {
+ "ExecuteMethod": FirstEventType.DATA_EVENT,
+ "OpenDoor": FirstEventType.STATUS_EVENT,
+ "CloseDoor": FirstEventType.STATUS_EVENT,
+ "Initialize": FirstEventType.STATUS_EVENT,
+ "Reset": FirstEventType.STATUS_EVENT,
+ "LockDevice": FirstEventType.STATUS_EVENT,
+ "UnlockDevice": FirstEventType.STATUS_EVENT,
+ "StopMethod": FirstEventType.STATUS_EVENT,
+}
+
+
+# Default max wait for async command completion (3 hours). SiLA2-aligned: protocol execution always bounded.
+DEFAULT_LIFETIME_OF_EXECUTION: float = 10800.0
+
+# Default timeout for waiting for first DataEvent (ExecuteMethod) and default lifetime/eta for status-driven commands.
+DEFAULT_FIRST_EVENT_TIMEOUT_SECONDS: float = 60.0
+
+# Delay (seconds) after command start before starting GetStatus polling loop.
+POLLING_START_BUFFER: float = 10.0
+
+
+@dataclass(frozen=True)
+class PendingCommand:
+ """Tracks a pending async command."""
+
+ name: str
+ request_id: int
+ fut: asyncio.Future[Any]
+ started_at: float
+ lock_id: Optional[str] = None # LockId sent with LockDevice command (for tracking)
+
+
+class ODTCSiLAInterface(InhecoSiLAInterface):
+ """ODTC-specific SiLA interface with parallelism, state tracking, and lockId validation.
+
+ Extends InhecoSiLAInterface to support:
+ - Multiple in-flight commands with parallelism enforcement per ODTC doc section 3
+ - State machine tracking and command allowability per ODTC doc section 4
+ - LockId validation (defaults to None, validates when device is locked)
+ - Proper return code handling including device-specific codes (1000-2010)
+ - All event types: ResponseEvent, StatusEvent, DataEvent, ErrorEvent
+ """
+
+ # Parallelism table from ODTC doc section 3
+ # Format: {command: {other_command: "P" (parallel) or "S" (sequential)}}
+ # Commands: SP=SetParameters, GP=GetParameters, OD=OpenDoor, CD=CloseDoor,
+ # RAT=ReadActualTemperature, EM=ExecuteMethod, SM=StopMethod, GLD=GetLastData
+ PARALLELISM_TABLE: Dict[str, Dict[str, str]] = {
+ "SetParameters": {
+ "SetParameters": "S",
+ "GetParameters": "S",
+ "OpenDoor": "P",
+ "CloseDoor": "P",
+ "ReadActualTemperature": "P",
+ "ExecuteMethod": "S",
+ "StopMethod": "P",
+ "GetLastData": "S",
+ },
+ "GetParameters": {
+ "SetParameters": "P",
+ "GetParameters": "S",
+ "OpenDoor": "P",
+ "CloseDoor": "P",
+ "ReadActualTemperature": "P",
+ "ExecuteMethod": "S",
+ "StopMethod": "P",
+ "GetLastData": "S",
+ },
+ "OpenDoor": {
+ "SetParameters": "P",
+ "GetParameters": "P",
+ "OpenDoor": "S",
+ "CloseDoor": "S",
+ "ReadActualTemperature": "P",
+ "ExecuteMethod": "P",
+ "StopMethod": "P",
+ "GetLastData": "P",
+ },
+ "CloseDoor": {
+ "SetParameters": "P",
+ "GetParameters": "P",
+ "OpenDoor": "S",
+ "CloseDoor": "S",
+ "ReadActualTemperature": "P",
+ "ExecuteMethod": "P",
+ "StopMethod": "P",
+ "GetLastData": "P",
+ },
+ "ReadActualTemperature": {
+ "SetParameters": "P",
+ "GetParameters": "P",
+ "OpenDoor": "P",
+ "CloseDoor": "P",
+ "ReadActualTemperature": "P",
+ "ExecuteMethod": "P",
+ "StopMethod": "P",
+ "GetLastData": "P",
+ },
+ "ExecuteMethod": {
+ "SetParameters": "S",
+ "GetParameters": "S",
+ "OpenDoor": "P",
+ "CloseDoor": "P",
+ "ReadActualTemperature": "P",
+ "ExecuteMethod": "S",
+ "StopMethod": "P",
+ "GetLastData": "S",
+ },
+ "StopMethod": {
+ "SetParameters": "P",
+ "GetParameters": "P",
+ "OpenDoor": "P",
+ "CloseDoor": "P",
+ "ReadActualTemperature": "P",
+ "ExecuteMethod": "S",
+ "StopMethod": "S",
+ "GetLastData": "P",
+ },
+ "GetLastData": {
+ "SetParameters": "S",
+ "GetParameters": "S",
+ "OpenDoor": "P",
+ "CloseDoor": "P",
+ "ReadActualTemperature": "P",
+ "ExecuteMethod": "S",
+ "StopMethod": "P",
+ "GetLastData": "S",
+ },
+ }
+
+ # State allowability table from ODTC doc section 4
+ # Format: {command: {state: True if allowed}}
+ STATE_ALLOWABILITY: Dict[str, Dict[SiLAState, bool]] = {
+ "Abort": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: False,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "CloseDoor": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: False,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "DoContinue": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: False,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "ExecuteMethod": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: False,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "GetConfiguration": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: True,
+ SiLAState.IDLE: False,
+ SiLAState.BUSY: False,
+ },
+ "GetParameters": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: False,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "GetDeviceIdentification": {
+ SiLAState.STARTUP: True,
+ SiLAState.STANDBY: True,
+ SiLAState.INITIALIZING: True,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "GetLastData": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: False,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "GetStatus": {
+ SiLAState.STARTUP: True,
+ SiLAState.STANDBY: True,
+ SiLAState.INITIALIZING: True,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "Initialize": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: True,
+ SiLAState.IDLE: False,
+ SiLAState.BUSY: False,
+ },
+ "LockDevice": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: True,
+ SiLAState.IDLE: False,
+ SiLAState.BUSY: False,
+ },
+ "OpenDoor": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: False,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "Pause": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: False,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "PrepareForInput": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: False,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "PrepareForOutput": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: False,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "ReadActualTemperature": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: False,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "Reset": {
+ SiLAState.STARTUP: True,
+ SiLAState.STANDBY: True,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "SetConfiguration": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: True,
+ SiLAState.IDLE: False,
+ SiLAState.BUSY: False,
+ },
+ "SetParameters": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: False,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "StopMethod": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: False,
+ SiLAState.IDLE: True,
+ SiLAState.BUSY: True,
+ },
+ "UnlockDevice": {
+ SiLAState.STARTUP: False,
+ SiLAState.STANDBY: True,
+ SiLAState.IDLE: False,
+ SiLAState.BUSY: False,
+ },
+ }
+
+ # Synchronous commands (return code 1, no ResponseEvent)
+ SYNCHRONOUS_COMMANDS: Set[str] = {"GetStatus", "GetDeviceIdentification"}
+
+ # Device-specific return codes that indicate DeviceError (InError state)
+ DEVICE_ERROR_CODES: Set[int] = {1000, 2000, 2001, 2007}
+
+ # Terminal state for polling fallback: command name -> expected state when command is done (per STATE_ALLOWABILITY).
+ ASYNC_COMMAND_TERMINAL_STATE: Dict[str, str] = {
+ "Reset": "standby",
+ "Initialize": "idle",
+ "LockDevice": "standby",
+ "UnlockDevice": "standby",
+ }
+ # Default terminal state for other async commands (OpenDoor, CloseDoor, ExecuteMethod, StopMethod, etc.)
+ _DEFAULT_TERMINAL_STATE: str = "idle"
+
+ def __init__(
+ self,
+ machine_ip: str,
+ client_ip: Optional[str] = None,
+ logger: Optional[logging.Logger] = None,
+ poll_interval: float = 5.0,
+ lifetime_of_execution: Optional[float] = None,
+ on_response_event_missing: Literal["warn_and_continue", "error"] = "warn_and_continue",
+ ) -> None:
+ """Initialize ODTC SiLA interface.
+
+ Args:
+ machine_ip: IP address of the ODTC device.
+ client_ip: IP address of this client (auto-detected if None).
+ logger: Logger instance (creates one if None).
+ poll_interval: Seconds between GetStatus calls in the polling fallback (SiLA2 subscribe_by_polling style).
+ lifetime_of_execution: Max seconds to wait for async completion (SiLA2 deadline). If None, use 3 hours.
+ on_response_event_missing: When polling sees terminal state but ResponseEvent was not received:
+ "warn_and_continue" -> resolve with None and log warning; "error" -> set exception.
+ """
+ super().__init__(machine_ip=machine_ip, client_ip=client_ip, logger=logger)
+
+ self._poll_interval = poll_interval
+ self._lifetime_of_execution = lifetime_of_execution
+ self._on_response_event_missing = on_response_event_missing
+
+ # Multi-request tracking (replaces single _pending)
+ self._pending_by_id: Dict[int, PendingCommand] = {}
+ self._active_request_ids: Set[int] = set() # For duplicate detection
+
+ # State tracking
+ self._current_state: SiLAState = SiLAState.STARTUP
+ self._lock_id: Optional[str] = None # None = unlocked, str = locked with this ID
+
+ # Track currently executing commands for parallelism checking
+ self._executing_commands: Set[str] = set()
+
+ # Lock for parallelism checking (separate from base class's _making_request)
+ self._parallelism_lock = asyncio.Lock()
+
+ # DataEvent storage by request_id
+ self._data_events_by_request_id: Dict[int, List[Dict[str, Any]]] = {}
+ # Optional: when set, each received DataEvent payload is appended as one JSON line (for debugging / API discovery)
+ self.data_event_log_path: Optional[str] = None
+ # Estimated remaining time (s) per request_id; set by backend after first DataEvent so polling waits until eta+buffer.
+ self._estimated_remaining_by_request_id: Dict[int, float] = {}
+
+ def _check_state_allowability(self, command: str) -> bool:
+ """Check if command is allowed in current state.
+
+ Args:
+ command: Command name.
+
+ Returns:
+ True if command is allowed, False otherwise.
+ """
+ if command not in self.STATE_ALLOWABILITY:
+ # Unknown command - allow it (might be device-specific)
+ return True
+
+ state_rules = self.STATE_ALLOWABILITY[command]
+ return state_rules.get(self._current_state, False)
+
+ def _check_parallelism(self, command: str) -> bool:
+ """Check if command can run in parallel with currently executing commands.
+
+ Args:
+ command: Command name to check.
+
+ Returns:
+ True if command can run in parallel, False if it conflicts.
+ """
+ # If no commands executing, allow it
+ if not self._executing_commands:
+ return True
+
+ # Normalize the new command name
+ new_cmd = self._normalize_command_name(command)
+
+ # Check against each executing command
+ # The parallelism table is keyed by the EXECUTING command, then lists what can run in parallel
+ for executing_cmd in self._executing_commands:
+ # Normalize executing command name
+ exec_cmd = self._normalize_command_name(executing_cmd)
+
+ # Check parallelism table from the perspective of the EXECUTING command
+ if exec_cmd in self.PARALLELISM_TABLE:
+ if new_cmd in self.PARALLELISM_TABLE[exec_cmd]:
+ if self.PARALLELISM_TABLE[exec_cmd][new_cmd] == "S":
+ return False # Sequential required
+ # If "P" (parallel), continue checking other executing commands
+ else:
+ # New command not in executing command's table - default to sequential for safety
+ return False
+ else:
+ # Executing command not in parallelism table - default to sequential
+ return False
+
+ # All checks passed - can run in parallel with all executing commands
+ return True
+
+ def _normalize_command_name(self, command: str) -> str:
+ """Normalize command name for parallelism table lookup.
+
+ Handles command aliases (OpenDoor/PrepareForOutput, CloseDoor/PrepareForInput).
+
+ Args:
+ command: Command name.
+
+ Returns:
+ Normalized command name for table lookup.
+ """
+ # Handle aliases per ODTC doc section 8
+ if command in ("PrepareForOutput", "OpenDoor"):
+ return "OpenDoor"
+ if command in ("PrepareForInput", "CloseDoor"):
+ return "CloseDoor"
+ return command
+
+ def _get_terminal_state(self, command: str) -> str:
+ """Return the device state that indicates this async command has finished (for polling fallback)."""
+ return self.ASYNC_COMMAND_TERMINAL_STATE.get(command, self._DEFAULT_TERMINAL_STATE)
+
+ def get_first_event_type_for_command(self, command: str) -> FirstEventType:
+ """Return which event type to wait for before handing off the handle (per SiLA/ODTC docs)."""
+ normalized = self._normalize_command_name(command)
+ return COMMAND_FIRST_EVENT_TYPE.get(normalized, FirstEventType.STATUS_EVENT)
+
+ async def wait_for_first_event(
+ self,
+ request_id: int,
+ event_type: FirstEventType,
+ timeout_seconds: float,
+ ) -> Optional[Dict[str, Any]]:
+ """Wait for the first event of the given type for this request_id, or raise on timeout.
+
+ For DATA_EVENT: polls _data_events_by_request_id until at least one event or timeout.
+ For STATUS_EVENT: returns None immediately (StatusEvent has no requestId per SiLA spec).
+
+ Args:
+ request_id: SiLA request ID of the command.
+ event_type: FirstEventType.DATA_EVENT or FirstEventType.STATUS_EVENT.
+ timeout_seconds: Max seconds to wait (DATA_EVENT only).
+
+ Returns:
+ First event payload dict (DATA_EVENT) or None (STATUS_EVENT).
+
+ Raises:
+ FirstEventTimeout: If no DataEvent received within timeout_seconds.
+ """
+ if event_type == FirstEventType.STATUS_EVENT:
+ return None
+ started_at = time.time()
+ while True:
+ events = self._data_events_by_request_id.get(request_id) or []
+ if events:
+ return events[0]
+ if time.time() - started_at >= timeout_seconds:
+ raise FirstEventTimeout(
+ f"No DataEvent received for request_id {request_id} within {timeout_seconds}s"
+ )
+ await asyncio.sleep(0.2)
+
+ def _complete_pending(
+ self,
+ request_id: int,
+ result: Any = None,
+ exception: Optional[BaseException] = None,
+ update_lock_state: bool = True,
+ ) -> None:
+ """Complete a pending command: cleanup and resolve its Future (single place for ResponseEvent and polling).
+
+ Args:
+ request_id: Pending command request_id.
+ result: Value to set on Future (ignored if exception is set).
+ exception: If set, set_exception on Future instead of set_result(result).
+ update_lock_state: If True (ResponseEvent path), apply LockDevice/UnlockDevice/Reset lock updates.
+ """
+ pending = self._pending_by_id.get(request_id)
+ if pending is None or pending.fut.done():
+ return
+
+ if update_lock_state:
+ if pending.name == "LockDevice" and pending.lock_id is not None:
+ self._lock_id = pending.lock_id
+ self._logger.info(f"Device locked with lockId: {self._lock_id}")
+ elif pending.name == "UnlockDevice":
+ self._lock_id = None
+ self._logger.info("Device unlocked")
+ elif pending.name == "Reset":
+ self._lock_id = None
+ self._logger.info("Device reset (unlocked)")
+
+ self._pending_by_id.pop(request_id, None)
+ self._estimated_remaining_by_request_id.pop(request_id, None)
+ self._active_request_ids.discard(request_id)
+ normalized_cmd = self._normalize_command_name(pending.name)
+ self._executing_commands.discard(normalized_cmd)
+
+ if not self._executing_commands and self._current_state == SiLAState.BUSY:
+ self._current_state = SiLAState.IDLE
+
+ if exception is not None:
+ pending.fut.set_exception(exception)
+ else:
+ pending.fut.set_result(result)
+
+ def set_estimated_remaining_time(self, request_id: int, eta_seconds: float) -> None:
+ """Set device-estimated remaining time for a pending command so polling waits until eta+buffer.
+
+ Call after receiving the first DataEvent (ExecuteMethod) or when eta is known.
+ The _poll_until_complete task will not start GetStatus polling until
+ time.time() >= started_at + eta_seconds + POLLING_START_BUFFER.
+
+ Args:
+ request_id: Pending command request_id.
+ eta_seconds: Estimated remaining duration in seconds (0 = only wait POLLING_START_BUFFER).
+ """
+ self._estimated_remaining_by_request_id[request_id] = max(0.0, eta_seconds)
+
+ def _validate_lock_id(self, lock_id: Optional[str]) -> None:
+ """Validate lockId parameter.
+
+ Args:
+ lock_id: LockId to validate (None is allowed if device not locked).
+
+ Raises:
+ RuntimeError: If lockId validation fails (return code 5).
+ """
+ if self._lock_id is None:
+ # Device not locked - any lockId (including None) is fine
+ return
+
+ # Device is locked - must provide matching lockId
+ if lock_id != self._lock_id:
+ raise SiLAError(
+ f"Device is locked with lockId '{self._lock_id}', "
+ f"but command provided lockId '{lock_id}'. Return code: 5",
+ code=5,
+ )
+
+ def _update_state_from_status(self, state_str: str) -> None:
+ """Update internal state from GetStatus or StatusEvent response.
+
+ Args:
+ state_str: State string from device response. Must match enum values exactly.
+ """
+ if not state_str:
+ self._logger.debug("_update_state_from_status: Empty state string, skipping update")
+ return
+
+ self._logger.debug(
+ f"_update_state_from_status: Received state: {state_str!r} (type: {type(state_str).__name__})"
+ )
+
+ # Match exactly against enum values (no normalization - we want to see what device actually returns)
+ try:
+ self._current_state = SiLAState(state_str)
+ self._logger.debug(f"State updated to: {self._current_state.value}")
+ except ValueError:
+ self._logger.warning(
+ f"Unknown state received: {state_str!r}, keeping current state {self._current_state.value}. "
+ f"Expected one of: {[s.value for s in SiLAState]}"
+ )
+
+ def _handle_return_code(
+ self, return_code: int, message: str, command_name: str, request_id: int
+ ) -> None:
+ """Handle return code and update state accordingly.
+
+ Args:
+ return_code: Return code from device.
+ message: Return message.
+ command_name: Name of the command.
+ request_id: Request ID of the command.
+
+ Raises:
+ RuntimeError: For error return codes.
+ """
+ if return_code == 1:
+ # Success (synchronous commands)
+ return
+ elif return_code == 2:
+ # Asynchronous command accepted
+ return
+ elif return_code == 3:
+ # Asynchronous command finished (success) - handled in ResponseEvent
+ return
+ elif return_code == 4:
+ raise SiLAError(f"Command {command_name} rejected: Device is busy (return code 4)", code=4)
+ elif return_code == 5:
+ raise SiLAError(f"Command {command_name} rejected: LockId mismatch (return code 5)", code=5)
+ elif return_code == 6:
+ raise SiLAError(
+ f"Command {command_name} rejected: Invalid or duplicate requestId (return code 6)", code=6
+ )
+ elif return_code == 9:
+ raise SiLAError(
+ f"Command {command_name} not allowed in state {self._current_state.value} (return code 9)",
+ code=9,
+ )
+ elif return_code == 11:
+ raise SiLAError(
+ f"Command {command_name} rejected: Invalid parameter (return code 11): {message}", code=11
+ )
+ elif return_code == 12:
+ # Finished with warning
+ self._logger.warning(
+ f"Command {command_name} finished with warning (return code 12): {message}"
+ )
+ return
+ elif return_code >= 1000:
+ # Device-specific return code
+ if return_code in self.DEVICE_ERROR_CODES:
+ self._current_state = SiLAState.INERROR
+ raise SiLAError(
+ f"Command {command_name} failed with device error (return code {return_code}): {message}",
+ code=return_code,
+ )
+ else:
+ # Warning or recoverable error
+ self._logger.warning(
+ f"Command {command_name} returned device-specific code {return_code}: {message}"
+ )
+ # May transition to ErrorHandling if recoverable
+ if return_code not in {2005, 2006, 2008, 2009, 2010}: # These are warnings, not errors
+ # Recoverable error - transition to ErrorHandling
+ self._current_state = SiLAState.ERRORHANDLING
+ else:
+ # Unknown return code
+ raise SiLAError(f"Command {command_name} returned unknown code {return_code}: {message}")
+
+ async def _on_http(self, req: InhecoSiLAInterface._HTTPRequest) -> bytes:
+ """Handle incoming HTTP requests from device (events).
+
+ Overrides base class to support multiple pending requests and all event types.
+
+ Args:
+ req: HTTP request from device.
+
+ Returns:
+ SOAP response bytes.
+ """
+ body_str = req.body.decode("utf-8")
+ decoded = soap_decode(body_str)
+
+ # Handle ResponseEvent (async command completion)
+ if "ResponseEvent" in decoded:
+ response_event = decoded["ResponseEvent"]
+ request_id = response_event.get("requestId")
+ return_value = response_event.get("returnValue", {})
+ return_code = return_value.get("returnCode")
+ message = return_value.get("message", "")
+
+ if request_id is None:
+ self._logger.warning("ResponseEvent missing requestId")
+ return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8")
+
+ pending = self._pending_by_id.get(request_id)
+ if pending is None:
+ self._logger.warning(f"ResponseEvent for unknown requestId: {request_id}")
+ return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8")
+
+ if pending.fut.done():
+ self._logger.warning(f"ResponseEvent for already-completed requestId: {request_id}")
+ return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8")
+
+ # Code 3 = async finished (SUCCESS)
+ if return_code == 3:
+ response_data = response_event.get("responseData", "")
+ if response_data and response_data.strip():
+ try:
+ root = ET.fromstring(response_data)
+ self._complete_pending(request_id, result=root, update_lock_state=True)
+ 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}"),
+ update_lock_state=False,
+ )
+ else:
+ self._complete_pending(request_id, result=None, update_lock_state=True)
+ else:
+ err_msg = message.replace("\n", " ") if message else f"Unknown error (code {return_code})"
+ self._complete_pending(
+ request_id,
+ exception=RuntimeError(
+ f"Command {pending.name} failed with code {return_code}: '{err_msg}'"
+ ),
+ update_lock_state=False,
+ )
+
+ return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8")
+
+ # Handle StatusEvent (state changes)
+ if "StatusEvent" in decoded:
+ status_event = decoded["StatusEvent"]
+ event_description = status_event.get("eventDescription", {})
+ device_state = event_description.get("DeviceState")
+ if device_state:
+ self._update_state_from_status(device_state)
+ return SOAP_RESPONSE_StatusEventResponse.encode("utf-8")
+
+ # Handle DataEvent (intermediate data, e.g., during ExecuteMethod)
+ if "DataEvent" in decoded:
+ data_event = decoded["DataEvent"]
+ request_id = data_event.get("requestId")
+
+ if request_id is not None:
+ # Store the full DataEvent payload
+ 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)
+
+ self._logger.debug(
+ f"DataEvent received for requestId {request_id} "
+ f"(total: {len(self._data_events_by_request_id[request_id])})"
+ )
+ # One-line summary at DEBUG so default "waiting" shows only backend progress
+ # (every progress_log_interval, e.g. 150 s). Backend logs use correct target for premethods.
+ progress = ODTCProgress.from_data_event(data_event, None)
+ self._logger.debug(
+ "DataEvent requestId %s: elapsed %.0fs, block %.1f°C, target %.1f°C, lid %.1f°C",
+ request_id,
+ progress.elapsed_s,
+ progress.current_temp_c or 0.0,
+ progress.target_temp_c or 0.0,
+ progress.lid_temp_c or 0.0,
+ )
+
+ if self.data_event_log_path:
+ try:
+ with open(self.data_event_log_path, "a", encoding="utf-8") as f:
+ f.write(json.dumps(data_event, default=str) + "\n")
+ except OSError as e:
+ self._logger.warning(
+ "Failed to append DataEvent to %s: %s", self.data_event_log_path, e
+ )
+
+ return SOAP_RESPONSE_DataEventResponse.encode("utf-8")
+
+ # Handle ErrorEvent (recoverable errors with continuation tasks)
+ if "ErrorEvent" in decoded:
+ error_event = decoded["ErrorEvent"]
+ 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}"
+ )
+
+ self._current_state = SiLAState.ERRORHANDLING
+
+ err_msg = message.replace("\n", " ") if message else f"Error (code {return_code})"
+ pending_err = self._pending_by_id.get(req_id)
+ if pending_err and not pending_err.fut.done():
+ self._complete_pending(
+ req_id,
+ exception=RuntimeError(f"Command {pending_err.name} error: '{err_msg}'"),
+ update_lock_state=False,
+ )
+
+ return SOAP_RESPONSE_ErrorEventResponse.encode("utf-8")
+
+ # Unknown event type
+ self._logger.warning("Unknown event type received")
+ return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8")
+
+ async def _execute_command(
+ self,
+ command: str,
+ lock_id: Optional[str] = None,
+ **kwargs: Any,
+ ) -> Any | tuple[asyncio.Future[Any], int, float]:
+ """Execute a SiLA command; return decoded dict (sync) or (fut, request_id, started_at) (async).
+
+ Internal helper used by send_command and start_command. Callers should use
+ send_command (run and return result) or start_command (start and return handle).
+ """
+ if self._closed:
+ raise RuntimeError("Interface is closed")
+
+ if command != "GetStatus":
+ self._validate_lock_id(lock_id)
+
+ if not self._check_state_allowability(command):
+ raise SiLAError(
+ f"Command {command} not allowed in state {self._current_state.value} (return code 9)",
+ code=9,
+ )
+
+ if command not in self.SYNCHRONOUS_COMMANDS:
+ normalized_cmd = self._normalize_command_name(command)
+ if normalized_cmd in self.PARALLELISM_TABLE:
+ async with self._parallelism_lock:
+ if not self._check_parallelism(normalized_cmd):
+ raise SiLAError(
+ f"Command {command} cannot run in parallel with currently executing commands (return code 4)",
+ code=4,
+ )
+ else:
+ async with self._parallelism_lock:
+ if self._executing_commands:
+ raise SiLAError(
+ f"Command {command} not in parallelism table and device is busy (return code 4)",
+ code=4,
+ )
+ else:
+ normalized_cmd = self._normalize_command_name(command)
+
+ request_id = self._make_request_id()
+ if request_id in self._active_request_ids:
+ raise SiLAError(f"Duplicate requestId generated: {request_id} (return code 6)", code=6)
+
+ params: Dict[str, Any] = {"requestId": request_id, **kwargs}
+ if command != "GetStatus":
+ if self._lock_id is not None:
+ params["lockId"] = lock_id if lock_id is not None else self._lock_id
+ elif lock_id is not None:
+ params["lockId"] = lock_id
+
+ cmd_xml = soap_encode(
+ command,
+ params,
+ method_ns="http://sila.coop",
+ extra_method_xmlns={"i": XSI},
+ )
+
+ url = f"http://{self._machine_ip}:8080/"
+ req = urllib.request.Request(
+ url=url,
+ data=cmd_xml.encode("utf-8"),
+ method="POST",
+ headers={
+ "Content-Type": "text/xml; charset=utf-8",
+ "Content-Length": str(len(cmd_xml)),
+ "SOAPAction": f"http://sila.coop/{command}",
+ "Expect": "100-continue",
+ "Accept-Encoding": "gzip, deflate",
+ },
+ )
+
+ def _do_request() -> bytes:
+ with urllib.request.urlopen(req) as resp:
+ return resp.read() # type: ignore
+
+ 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)
+ self._logger.debug(f"Command {command} returned code {return_code}: {message}")
+
+ if return_code == 1:
+ if command == "GetStatus":
+ get_status_response = decoded.get("GetStatusResponse", {})
+ state = get_status_response.get("state")
+ if state:
+ self._update_state_from_status(state)
+ return decoded
+
+ if return_code == 2:
+ fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
+
+ pending_lock_id = None
+ if command == "LockDevice" and "lockId" in params:
+ pending_lock_id = params["lockId"]
+
+ started_at = time.time()
+ pending = PendingCommand(
+ name=command,
+ request_id=request_id,
+ fut=fut,
+ started_at=started_at,
+ lock_id=pending_lock_id,
+ )
+
+ self._pending_by_id[request_id] = pending
+ self._active_request_ids.add(request_id)
+ self._executing_commands.add(normalized_cmd)
+
+ if self._current_state == SiLAState.IDLE:
+ self._current_state = SiLAState.BUSY
+
+ effective_lifetime = (
+ self._lifetime_of_execution
+ if self._lifetime_of_execution is not None
+ else DEFAULT_LIFETIME_OF_EXECUTION
+ )
+
+ async def _poll_until_complete() -> None:
+ while True:
+ pending_ref = self._pending_by_id.get(request_id)
+ if pending_ref is None or pending_ref.fut.done():
+ break
+ eta = self._estimated_remaining_by_request_id.get(request_id) or 0.0
+ remaining_wait = started_at + eta + POLLING_START_BUFFER - time.time()
+ if remaining_wait > 0:
+ await asyncio.sleep(min(remaining_wait, self._poll_interval))
+ continue
+ break
+ while True:
+ pending_ref = self._pending_by_id.get(request_id)
+ if pending_ref is None:
+ break
+ if pending_ref.fut.done():
+ break
+ elapsed = time.time() - pending_ref.started_at
+ if elapsed >= effective_lifetime:
+ self._complete_pending(
+ request_id,
+ exception=SiLATimeoutError(
+ f"Command {pending_ref.name} timed out (lifetime_of_execution exceeded: {effective_lifetime}s)"
+ ),
+ update_lock_state=False,
+ )
+ break
+ try:
+ decoded_status = await self.send_command("GetStatus")
+ except Exception:
+ await asyncio.sleep(self._poll_interval)
+ continue
+ state = decoded_status.get("GetStatusResponse", {}).get("state")
+ if state:
+ self._update_state_from_status(state)
+ terminal_state = self._get_terminal_state(pending_ref.name)
+ if state == terminal_state:
+ if self._on_response_event_missing == "warn_and_continue":
+ self._logger.warning(
+ "ResponseEvent not received; completed via GetStatus polling (possible sleep/network loss)"
+ )
+ self._complete_pending(request_id, result=None, update_lock_state=False)
+ else:
+ self._complete_pending(
+ request_id,
+ exception=SiLATimeoutError(
+ "ResponseEvent not received; device reported idle. Possible callback loss (e.g. sleep/network)."
+ ),
+ update_lock_state=False,
+ )
+ break
+ await asyncio.sleep(self._poll_interval)
+
+ asyncio.create_task(_poll_until_complete())
+ return (fut, request_id, started_at)
+
+ self._handle_return_code(return_code, message, command, request_id)
+ raise SiLAError(f"Command {command} failed: {return_code} {message}")
+
+ async def send_command(
+ self,
+ command: str,
+ lock_id: Optional[str] = None,
+ **kwargs: Any,
+ ) -> Any:
+ """Run a SiLA command and return the result (blocking until done).
+
+ Use for any command when you want the decoded result. For async device
+ commands (e.g. OpenDoor, ExecuteMethod), this awaits completion and
+ returns the result (or raises). For sync commands (GetStatus,
+ GetDeviceIdentification), returns the decoded dict immediately.
+
+ Args:
+ command: Command name.
+ lock_id: LockId (defaults to None, validated if device is locked).
+ **kwargs: Additional command parameters.
+
+ Returns:
+ Decoded response dict (sync) or result after awaiting (async).
+
+ Raises:
+ SiLAError and subclasses: For validation, return code, or state violations.
+ """
+ result = await self._execute_command(command, lock_id=lock_id, **kwargs)
+ if isinstance(result, tuple):
+ return await result[0]
+ return result
+
+ async def start_command(
+ self,
+ command: str,
+ lock_id: Optional[str] = None,
+ **kwargs: Any,
+ ) -> tuple[asyncio.Future[Any], int, float]:
+ """Start a SiLA command and return a handle (future, request_id, started_at).
+
+ Use for async device commands (OpenDoor, CloseDoor, ExecuteMethod,
+ Initialize, Reset, LockDevice, UnlockDevice, StopMethod) when you want
+ a handle to await, poll, or compose with. Do not use for sync-only
+ commands (GetStatus, GetDeviceIdentification); use send_command instead.
+
+ Args:
+ command: Command name (must be an async command).
+ lock_id: LockId (defaults to None, validated if device is locked).
+ **kwargs: Additional command parameters (e.g. methodName, deviceId). Not sent to
+ device: requestId (injected), lockId when applicable.
+
+ Returns:
+ (future, request_id, started_at) tuple. ETA/lifetime come from the backend (event-driven).
+ Await the future for the result or to propagate exceptions.
+
+ Raises:
+ ValueError: If the device returned sync (return_code 1); start_command
+ is for async commands only.
+ SiLAError and subclasses: For validation, return code, or state violations.
+ """
+ result = await self._execute_command(command, lock_id=lock_id, **kwargs)
+ if isinstance(result, tuple):
+ return result
+ raise ValueError(
+ "start_command is for async commands only; device returned sync response (return_code 1)"
+ )
diff --git a/pylabrobot/thermocycling/inheco/odtc_tests.py b/pylabrobot/thermocycling/inheco/odtc_tests.py
new file mode 100644
index 00000000000..95d13de2fa5
--- /dev/null
+++ b/pylabrobot/thermocycling/inheco/odtc_tests.py
@@ -0,0 +1,1678 @@
+"""Tests for ODTC: backend, thermocycler resource, SiLA interface, and model utilities."""
+
+import asyncio
+import unittest
+import xml.etree.ElementTree as ET
+from typing import Any, Dict, List, Optional, cast
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from pylabrobot.resources import Coordinate
+from pylabrobot.thermocycling.inheco.odtc_backend import ODTCBackend, ODTCExecution
+from pylabrobot.thermocycling.inheco.odtc_model import (
+ ODTC_DIMENSIONS,
+ PREMETHOD_ESTIMATED_DURATION_SECONDS,
+ ODTCMethodSet,
+ ODTCProgress,
+ ODTCProtocol,
+ ODTCStage,
+ ODTCStep,
+ estimate_method_duration_seconds,
+ method_set_to_xml,
+ normalize_variant,
+ odtc_protocol_to_protocol,
+ parse_method_set,
+)
+from pylabrobot.thermocycling.inheco.odtc_sila_interface import (
+ FirstEventTimeout,
+ FirstEventType,
+ ODTCSiLAInterface,
+ SiLAState,
+ SiLATimeoutError,
+)
+from pylabrobot.thermocycling.inheco.odtc_thermocycler import ODTCThermocycler
+
+
+def _minimal_data_event_payload(remaining_s: float = 300.0) -> Dict[str, Any]:
+ """Minimal DataEvent payload (valid XML); ODTCProgress.from_data_event parses elapsed_s=0 when no Elapsed time."""
+ inner = (
+ f''
+ f"{int(remaining_s)}"
+ )
+ escaped = inner.replace("<", "<").replace(">", ">")
+ return {
+ "requestId": 12345,
+ "dataValue": f"{escaped}",
+ }
+
+
+def _data_event_payload_with_elapsed(elapsed_s: float, request_id: int = 12345) -> Dict[str, Any]:
+ """DataEvent payload with Elapsed time (ms) for progress/lookup tests."""
+ ms = int(elapsed_s * 1000)
+ inner = (
+ f''
+ f"{ms}"
+ )
+ escaped = inner.replace("<", "<").replace(">", ">")
+ return {
+ "requestId": request_id,
+ "dataValue": f"{escaped}",
+ }
+
+
+def _data_event_payload_with_elapsed_and_temps(
+ elapsed_s: float,
+ current_temp_c: Optional[float] = None,
+ lid_temp_c: Optional[float] = None,
+ target_temp_c: Optional[float] = None,
+ request_id: int = 12345,
+) -> Dict[str, Any]:
+ """DataEvent payload with Elapsed time and optional temperatures (1/100°C in XML)."""
+ parts = [
+ f''
+ f"{int(elapsed_s * 1000)}",
+ ]
+ if current_temp_c is not None:
+ parts.append(
+ f''
+ f"{int(current_temp_c * 100)}"
+ )
+ if lid_temp_c is not None:
+ parts.append(
+ f''
+ f"{int(lid_temp_c * 100)}"
+ )
+ if target_temp_c is not None:
+ parts.append(
+ f''
+ f"{int(target_temp_c * 100)}"
+ )
+ inner = "" + "".join(parts) + ""
+ escaped = inner.replace("<", "<").replace(">", ">")
+ return {
+ "requestId": request_id,
+ "dataValue": f"{escaped}",
+ }
+
+
+class TestNormalizeVariant(unittest.TestCase):
+ """Tests for normalize_variant (96/384 -> 960000/384000)."""
+
+ def test_96_maps_to_960000(self):
+ self.assertEqual(normalize_variant(96), 960000)
+
+ def test_384_maps_to_384000(self):
+ self.assertEqual(normalize_variant(384), 384000)
+
+ def test_3840000_normalizes_to_384000(self):
+ self.assertEqual(normalize_variant(3840000), 384000)
+
+ def test_invalid_raises(self):
+ with self.assertRaises(ValueError) as cm:
+ normalize_variant(123)
+ self.assertIn("123", str(cm.exception))
+ self.assertIn("Valid", str(cm.exception))
+
+
+class TestODTCProgressFromDataEventPayload(unittest.TestCase):
+ """Tests for ODTCProgress.from_data_event with raw payload (parsing covered indirectly)."""
+
+ def test_from_data_event_experiment_step_sequence_fallback(self):
+ """When no 'Step' dataSeries, current_step_index is taken from experimentStep @sequence (1-based)."""
+ inner = (
+ ''
+ '10000'
+ ""
+ )
+ escaped = inner.replace("<", "<").replace(">", ">")
+ payload = {"requestId": 1, "dataValue": f"{escaped}"}
+ progress = ODTCProgress.from_data_event(payload, None)
+ self.assertEqual(progress.current_step_index, 4) # 1-based sequence 5 -> 0-based index 4
+ self.assertEqual(progress.elapsed_s, 10.0)
+
+
+class TestEstimateMethodDurationSeconds(unittest.TestCase):
+ """Tests for estimate_method_duration_seconds (ODTC method duration from steps)."""
+
+ def test_premethod_constant(self):
+ """PREMETHOD_ESTIMATED_DURATION_SECONDS is 10 minutes."""
+ self.assertEqual(PREMETHOD_ESTIMATED_DURATION_SECONDS, 600.0)
+
+ def test_empty_method_returns_zero(self):
+ """Method with no steps has zero duration."""
+ odtc = ODTCProtocol(
+ kind="method", name="empty", start_block_temperature=20.0, steps=[], stages=[]
+ )
+ self.assertEqual(estimate_method_duration_seconds(odtc), 0.0)
+
+ def test_single_step_no_loop(self):
+ """Single step: ramp + plateau + overshoot. Ramp = |95 - 20| / 4.4 ≈ 17.045 s."""
+ odtc = ODTCProtocol(
+ kind="method",
+ name="single",
+ start_block_temperature=20.0,
+ steps=[
+ ODTCStep(
+ number=1,
+ slope=4.4,
+ plateau_temperature=95.0,
+ plateau_time=30.0,
+ overshoot_time=5.0,
+ goto_number=0,
+ loop_number=0,
+ ),
+ ],
+ stages=[],
+ )
+ # Ramp: 75 / 4.4 ≈ 17.045; plateau: 30; overshoot: 5
+ got = estimate_method_duration_seconds(odtc)
+ self.assertAlmostEqual(got, 17.045 + 30 + 5, places=1)
+
+ def test_single_step_zero_slope_clamped(self):
+ """Zero slope is clamped to avoid division by zero; duration is finite."""
+ odtc = ODTCProtocol(
+ kind="method",
+ name="zero_slope",
+ start_block_temperature=20.0,
+ steps=[
+ ODTCStep(
+ number=1,
+ slope=0.0,
+ plateau_temperature=95.0,
+ plateau_time=10.0,
+ overshoot_time=0.0,
+ goto_number=0,
+ loop_number=0,
+ ),
+ ],
+ stages=[],
+ )
+ # Ramp: 75 / 0.1 = 750 s (clamped); plateau: 10
+ got = estimate_method_duration_seconds(odtc)
+ self.assertAlmostEqual(got, 750 + 10, places=1)
+
+ def test_two_steps_with_loop(self):
+ """Two steps with loop: step 1 -> step 2 (goto 1, loop 2) = run 1,2,1,2."""
+ odtc = ODTCProtocol(
+ kind="method",
+ name="loop",
+ start_block_temperature=20.0,
+ steps=[
+ ODTCStep(
+ number=1,
+ slope=4.4,
+ plateau_temperature=95.0,
+ plateau_time=10.0,
+ overshoot_time=0.0,
+ goto_number=0,
+ loop_number=0,
+ ),
+ ODTCStep(
+ number=2,
+ slope=2.2,
+ plateau_temperature=60.0,
+ plateau_time=5.0,
+ overshoot_time=0.0,
+ goto_number=1,
+ loop_number=1, # repeat_count = 2
+ ),
+ ],
+ stages=[],
+ )
+ # Execution: step1, step2, step1, step2
+ got = estimate_method_duration_seconds(odtc)
+ self.assertGreater(got, 0)
+ self.assertLess(got, 1000)
+
+
+class TestODTCProgressPositionFromElapsed(unittest.TestCase):
+ """Tests for ODTCProgress.from_data_event(payload, odtc) position (timeline lookup from elapsed)."""
+
+ def test_premethod_elapsed_zero(self):
+ """Premethod at elapsed 0: step 0, cycle 0, setpoint = target_block_temperature."""
+ odtc = ODTCProtocol(
+ kind="premethod",
+ name="Pre37",
+ target_block_temperature=37.0,
+ stages=[],
+ )
+ payload = _data_event_payload_with_elapsed(0.0)
+ progress = ODTCProgress.from_data_event(payload, odtc)
+ self.assertEqual(progress.current_step_index, 0)
+ self.assertEqual(progress.current_cycle_index, 0)
+ self.assertEqual(progress.target_temp_c, 37.0)
+ self.assertEqual(progress.total_step_count, 1)
+ self.assertEqual(progress.total_cycle_count, 1)
+ self.assertGreater(progress.remaining_hold_s, 0)
+ self.assertEqual(progress.estimated_duration_s, PREMETHOD_ESTIMATED_DURATION_SECONDS)
+ self.assertEqual(progress.remaining_duration_s, PREMETHOD_ESTIMATED_DURATION_SECONDS)
+
+ def test_premethod_elapsed_mid_run(self):
+ """Premethod mid-run: same step/cycle/setpoint, remaining_hold decreases."""
+ odtc = ODTCProtocol(
+ kind="premethod",
+ name="Pre37",
+ target_block_temperature=37.0,
+ stages=[],
+ )
+ payload = _data_event_payload_with_elapsed(300.0)
+ progress = ODTCProgress.from_data_event(payload, odtc)
+ self.assertEqual(progress.current_step_index, 0)
+ self.assertEqual(progress.current_cycle_index, 0)
+ self.assertEqual(progress.target_temp_c, 37.0)
+ self.assertGreater(progress.remaining_hold_s, 0)
+ self.assertLess(progress.remaining_hold_s, PREMETHOD_ESTIMATED_DURATION_SECONDS - 300.0 + 1)
+ rem = progress.remaining_duration_s
+ self.assertIsNotNone(rem)
+ self.assertAlmostEqual(cast(float, rem), 300.0, delta=1.0)
+
+ def test_premethod_elapsed_beyond_duration(self):
+ """Premethod beyond estimated duration: remaining_hold_s = 0."""
+ odtc = ODTCProtocol(
+ kind="premethod",
+ name="Pre37",
+ target_block_temperature=37.0,
+ stages=[],
+ )
+ beyond = PREMETHOD_ESTIMATED_DURATION_SECONDS + 60.0
+ payload = _data_event_payload_with_elapsed(beyond)
+ progress = ODTCProgress.from_data_event(payload, odtc)
+ self.assertEqual(progress.current_step_index, 0)
+ self.assertEqual(progress.current_cycle_index, 0)
+ self.assertEqual(progress.target_temp_c, 37.0)
+ self.assertEqual(progress.remaining_hold_s, 0.0)
+ self.assertEqual(progress.remaining_duration_s, 0.0)
+
+ def test_method_no_steps(self):
+ """Method with no steps: step 0, cycle 0."""
+ odtc = ODTCProtocol(
+ kind="method",
+ name="empty",
+ start_block_temperature=20.0,
+ steps=[],
+ stages=[],
+ )
+ payload = _data_event_payload_with_elapsed(0.0)
+ progress = ODTCProgress.from_data_event(payload, odtc)
+ self.assertEqual(progress.current_step_index, 0)
+ self.assertEqual(progress.current_cycle_index, 0)
+ self.assertEqual(progress.total_step_count, 0)
+ self.assertEqual(progress.total_cycle_count, 1)
+
+ def test_method_single_step(self):
+ """Method with single step: step 0, cycle 0, setpoint from step."""
+ odtc = ODTCProtocol(
+ kind="method",
+ name="single",
+ start_block_temperature=20.0,
+ steps=[
+ ODTCStep(
+ number=1,
+ slope=4.4,
+ plateau_temperature=95.0,
+ plateau_time=30.0,
+ overshoot_time=5.0,
+ goto_number=0,
+ loop_number=0,
+ ),
+ ],
+ stages=[],
+ )
+ payload = _data_event_payload_with_elapsed(0.0)
+ progress = ODTCProgress.from_data_event(payload, odtc)
+ self.assertEqual(progress.current_step_index, 0)
+ self.assertEqual(progress.current_cycle_index, 0)
+ self.assertEqual(progress.target_temp_c, 95.0)
+ self.assertGreater(progress.remaining_hold_s, 0)
+ total_dur = estimate_method_duration_seconds(odtc)
+ payload_end = _data_event_payload_with_elapsed(total_dur + 10.0)
+ progress_end = ODTCProgress.from_data_event(payload_end, odtc)
+ self.assertEqual(progress_end.remaining_hold_s, 0.0)
+ self.assertEqual(progress_end.target_temp_c, 95.0)
+
+ def test_method_multi_step_with_loops(self):
+ """Method with 3 steps x 2 cycles: step_index and cycle_index advance with elapsed."""
+ odtc = ODTCProtocol(
+ kind="method",
+ name="pcr",
+ start_block_temperature=20.0,
+ steps=[
+ ODTCStep(
+ number=1,
+ slope=4.4,
+ plateau_temperature=95.0,
+ plateau_time=10.0,
+ overshoot_time=2.0,
+ goto_number=0,
+ loop_number=0,
+ ),
+ ODTCStep(
+ number=2,
+ slope=4.4,
+ plateau_temperature=55.0,
+ plateau_time=10.0,
+ overshoot_time=2.0,
+ goto_number=0,
+ loop_number=0,
+ ),
+ ODTCStep(
+ number=3,
+ slope=4.4,
+ plateau_temperature=72.0,
+ plateau_time=15.0,
+ overshoot_time=2.0,
+ goto_number=1,
+ loop_number=2,
+ ),
+ ],
+ stages=[],
+ )
+ payload0 = _data_event_payload_with_elapsed(0.0)
+ progress0 = ODTCProgress.from_data_event(payload0, odtc)
+ self.assertEqual(progress0.current_step_index, 0)
+ self.assertEqual(progress0.current_cycle_index, 0)
+ self.assertEqual(progress0.target_temp_c, 95.0)
+ self.assertEqual(progress0.total_step_count, 3)
+ self.assertEqual(progress0.total_cycle_count, 2)
+ total_dur = estimate_method_duration_seconds(odtc)
+ payload_end = _data_event_payload_with_elapsed(total_dur + 100.0)
+ progress_end = ODTCProgress.from_data_event(payload_end, odtc)
+ self.assertEqual(progress_end.current_step_index, 2)
+ self.assertEqual(progress_end.current_cycle_index, 1)
+ self.assertEqual(progress_end.target_temp_c, 72.0)
+ self.assertEqual(progress_end.remaining_hold_s, 0.0)
+
+ def test_elapsed_negative_treated_as_zero(self):
+ """Negative elapsed_s in payload is not possible (XML ms); from_data_event(None, odtc) gives elapsed_s=0."""
+ odtc = ODTCProtocol(
+ kind="premethod",
+ name="Pre37",
+ target_block_temperature=37.0,
+ stages=[],
+ )
+ progress = ODTCProgress.from_data_event(None, odtc)
+ self.assertEqual(progress.elapsed_s, 0.0)
+ self.assertEqual(progress.current_step_index, 0)
+ self.assertEqual(progress.current_cycle_index, 0)
+ self.assertEqual(progress.target_temp_c, 37.0)
+ self.assertEqual(progress.remaining_duration_s, PREMETHOD_ESTIMATED_DURATION_SECONDS)
+
+
+class TestODTCProgress(unittest.TestCase):
+ """Tests for ODTCProgress.from_data_event(payload, odtc) and format_progress_log_message."""
+
+ def test_from_data_event_none_none(self):
+ """from_data_event(None, None): elapsed_s=0, temps None, estimated_duration_s=None, remaining_duration_s=0."""
+ progress = ODTCProgress.from_data_event(None, None)
+ self.assertIsInstance(progress, ODTCProgress)
+ self.assertEqual(progress.elapsed_s, 0.0)
+ self.assertIsNone(progress.current_temp_c)
+ self.assertIsNone(progress.estimated_duration_s)
+ self.assertEqual(progress.remaining_duration_s, 0.0)
+
+ def test_from_data_event_payload_none(self):
+ """from_data_event(payload, None): elapsed_s and temps from payload; estimated/remaining duration 0."""
+ payload = _data_event_payload_with_elapsed_and_temps(50.0, current_temp_c=25.0, lid_temp_c=24.0)
+ progress = ODTCProgress.from_data_event(payload, None)
+ self.assertIsInstance(progress, ODTCProgress)
+ self.assertEqual(progress.elapsed_s, 50.0)
+ self.assertEqual(progress.current_temp_c, 25.0)
+ self.assertEqual(progress.lid_temp_c, 24.0)
+ self.assertEqual(progress.current_step_index, 0)
+ self.assertEqual(progress.current_cycle_index, 0)
+ self.assertEqual(progress.remaining_hold_s, 0.0)
+ self.assertIsNone(progress.estimated_duration_s)
+ self.assertEqual(progress.remaining_duration_s, 0.0)
+ msg = progress.format_progress_log_message()
+ self.assertIn("ODTC progress", msg)
+ self.assertIn("50", msg)
+ self.assertIn("25.0", msg)
+
+ def test_from_data_event_none_odtc(self):
+ """from_data_event(None, odtc): elapsed_s=0; estimated_duration_s set; remaining_duration_s = estimated."""
+ premethod = ODTCProtocol(
+ kind="premethod",
+ name="Pre37",
+ target_block_temperature=37.0,
+ stages=[],
+ )
+ progress = ODTCProgress.from_data_event(None, premethod)
+ self.assertEqual(progress.elapsed_s, 0.0)
+ self.assertEqual(progress.estimated_duration_s, PREMETHOD_ESTIMATED_DURATION_SECONDS)
+ self.assertEqual(progress.remaining_duration_s, PREMETHOD_ESTIMATED_DURATION_SECONDS)
+
+ def test_from_data_event_premethod(self):
+ """from_data_event(payload, premethod): step 0, cycle 0, setpoint; estimated/remaining duration."""
+ payload = _data_event_payload_with_elapsed_and_temps(100.0, current_temp_c=35.0)
+ premethod = ODTCProtocol(
+ kind="premethod",
+ name="Pre37",
+ target_block_temperature=37.0,
+ stages=[],
+ )
+ progress = ODTCProgress.from_data_event(payload, odtc=premethod)
+ self.assertIsInstance(progress, ODTCProgress)
+ self.assertEqual(progress.elapsed_s, 100.0)
+ self.assertEqual(progress.current_step_index, 0)
+ self.assertEqual(progress.current_cycle_index, 0)
+ self.assertEqual(progress.target_temp_c, 37.0)
+ self.assertGreater(progress.remaining_hold_s, 0)
+ self.assertEqual(progress.estimated_duration_s, PREMETHOD_ESTIMATED_DURATION_SECONDS)
+ rem = progress.remaining_duration_s
+ self.assertIsNotNone(rem)
+ self.assertAlmostEqual(cast(float, rem), 500.0, delta=1.0) # 600 - 100
+ msg = progress.format_progress_log_message()
+ self.assertIn("ODTC progress", msg)
+ self.assertIn("step", msg)
+ self.assertIn("cycle", msg)
+ self.assertIn("37.0", msg)
+
+ def test_from_data_event_method(self):
+ """from_data_event(payload, method): step_index, cycle_index, remaining_hold_s from position."""
+ payload = _data_event_payload_with_elapsed(0.0)
+ odtc = ODTCProtocol(
+ kind="method",
+ name="pcr",
+ start_block_temperature=20.0,
+ steps=[
+ ODTCStep(
+ number=1,
+ slope=4.4,
+ plateau_temperature=95.0,
+ plateau_time=10.0,
+ overshoot_time=2.0,
+ goto_number=0,
+ loop_number=0,
+ ),
+ ],
+ stages=[],
+ )
+ progress = ODTCProgress.from_data_event(payload, odtc=odtc)
+ self.assertIsInstance(progress, ODTCProgress)
+ self.assertEqual(progress.current_step_index, 0)
+ self.assertEqual(progress.current_cycle_index, 0)
+ self.assertEqual(progress.target_temp_c, 95.0)
+ self.assertGreater(progress.remaining_hold_s, 0)
+ self.assertIsNotNone(progress.estimated_duration_s)
+ rem = progress.remaining_duration_s
+ self.assertIsNotNone(rem)
+ self.assertGreater(cast(float, rem), 0)
+ msg = progress.format_progress_log_message()
+ self.assertIn("ODTC progress", msg)
+ self.assertIn("elapsed", msg)
+
+ def test_format_progress_log_message_includes_elapsed_and_temps(self):
+ """format_progress_log_message returns string with ODTC progress, elapsed_s, and temps."""
+ progress = ODTCProgress(
+ elapsed_s=120.0,
+ current_temp_c=72.0,
+ lid_temp_c=105.0,
+ target_temp_c=72.0,
+ )
+ msg = progress.format_progress_log_message()
+ self.assertIn("ODTC progress", msg)
+ self.assertIn("120", msg)
+ self.assertIn("72.0", msg)
+
+
+class TestODTCSiLAInterface(unittest.IsolatedAsyncioTestCase):
+ """Tests for ODTCSiLAInterface."""
+
+ def setUp(self):
+ """Set up test fixtures."""
+ self.interface = ODTCSiLAInterface(machine_ip="192.168.1.100", client_ip="127.0.0.1")
+
+ def test_normalize_command_name(self):
+ """Test command name normalization for aliases."""
+ self.assertEqual(self.interface._normalize_command_name("OpenDoor"), "OpenDoor")
+ self.assertEqual(self.interface._normalize_command_name("PrepareForOutput"), "OpenDoor")
+ self.assertEqual(self.interface._normalize_command_name("CloseDoor"), "CloseDoor")
+ self.assertEqual(self.interface._normalize_command_name("PrepareForInput"), "CloseDoor")
+ self.assertEqual(self.interface._normalize_command_name("ExecuteMethod"), "ExecuteMethod")
+
+ def test_check_state_allowability(self):
+ """Test state allowability checking."""
+ # GetStatus allowed in all states
+ self.interface._current_state = SiLAState.STARTUP
+ self.assertTrue(self.interface._check_state_allowability("GetStatus"))
+
+ self.interface._current_state = SiLAState.STANDBY
+ self.assertTrue(self.interface._check_state_allowability("GetStatus"))
+ self.assertTrue(self.interface._check_state_allowability("Initialize"))
+ self.assertFalse(self.interface._check_state_allowability("ExecuteMethod"))
+
+ # GetStatus allowed during initializing (needed for polling and post-Initialize verification)
+ self.interface._current_state = SiLAState.INITIALIZING
+ self.assertTrue(self.interface._check_state_allowability("GetStatus"))
+ self.assertTrue(self.interface._check_state_allowability("GetDeviceIdentification"))
+ self.assertFalse(self.interface._check_state_allowability("ExecuteMethod"))
+
+ self.interface._current_state = SiLAState.IDLE
+ self.assertTrue(self.interface._check_state_allowability("ExecuteMethod"))
+ self.assertFalse(self.interface._check_state_allowability("Initialize"))
+
+ def test_check_parallelism(self):
+ """Test parallelism checking."""
+ # No commands executing - should allow
+ self.assertTrue(self.interface._check_parallelism("ReadActualTemperature"))
+
+ # SetParameters executing - ReadActualTemperature can run in parallel
+ self.interface._executing_commands.add("SetParameters")
+ self.assertTrue(self.interface._check_parallelism("ReadActualTemperature"))
+
+ # ExecuteMethod executing - SetParameters cannot run in parallel (S)
+ self.interface._executing_commands.clear()
+ self.interface._executing_commands.add("ExecuteMethod")
+ self.assertFalse(self.interface._check_parallelism("SetParameters"))
+
+ # ExecuteMethod executing - ReadActualTemperature can run in parallel (P)
+ self.assertTrue(self.interface._check_parallelism("ReadActualTemperature"))
+
+ def test_validate_lock_id(self):
+ """Test lockId validation."""
+ # Device not locked - any lockId (including None) is fine
+ self.interface._lock_id = None
+ self.interface._validate_lock_id(None) # Should not raise
+ self.interface._validate_lock_id("some_id") # Should not raise
+
+ # Device locked - must provide matching lockId
+ self.interface._lock_id = "locked_id"
+ self.interface._validate_lock_id("locked_id") # Should not raise
+ with self.assertRaises(RuntimeError) as cm:
+ self.interface._validate_lock_id("wrong_id")
+ # Check that error mentions lockId mismatch
+ error_msg = str(cm.exception)
+ self.assertIn("locked", error_msg.lower())
+ self.assertIn("5", error_msg) # Return code 5
+
+ def test_update_state_from_status(self):
+ """Test state updates from status strings - exact matching only."""
+ # Test lowercase enum values (exact match required)
+ self.interface._update_state_from_status("idle")
+ self.assertEqual(self.interface._current_state, SiLAState.IDLE)
+
+ self.interface._update_state_from_status("busy")
+ self.assertEqual(self.interface._current_state, SiLAState.BUSY)
+
+ self.interface._update_state_from_status("standby")
+ self.assertEqual(self.interface._current_state, SiLAState.STANDBY)
+
+ # Test camelCase enum values (exact match required)
+ self.interface._update_state_from_status("inError")
+ self.assertEqual(self.interface._current_state, SiLAState.INERROR)
+
+ self.interface._update_state_from_status("errorHandling")
+ self.assertEqual(self.interface._current_state, SiLAState.ERRORHANDLING)
+
+ # Test that case mismatches are NOT accepted (exact matching only)
+ # These should keep the current state and log a warning
+ initial_state = self.interface._current_state
+ self.interface._update_state_from_status("Idle") # Wrong case
+ self.assertEqual(self.interface._current_state, initial_state) # Should remain unchanged
+
+ self.interface._update_state_from_status("BUSY") # Wrong case
+ self.assertEqual(self.interface._current_state, initial_state) # Should remain unchanged
+
+ self.interface._update_state_from_status("INERROR") # Wrong case
+ self.assertEqual(self.interface._current_state, initial_state) # Should remain unchanged
+
+ def test_handle_return_code(self):
+ """Test return code handling."""
+ # Code 1, 2, 3 should not raise
+ self.interface._handle_return_code(1, "Success", "GetStatus", 123)
+ self.interface._handle_return_code(2, "Accepted", "ExecuteMethod", 123)
+ self.interface._handle_return_code(3, "Finished", "ExecuteMethod", 123)
+
+ # Code 4 should raise
+ with self.assertRaises(RuntimeError) as cm:
+ self.interface._handle_return_code(4, "busy", "ExecuteMethod", 123)
+ self.assertIn("return code 4", str(cm.exception))
+
+ # Code 5 should raise
+ with self.assertRaises(RuntimeError) as cm:
+ self.interface._handle_return_code(5, "LockId error", "ExecuteMethod", 123)
+ self.assertIn("return code 5", str(cm.exception))
+
+ # Code 9 should raise
+ with self.assertRaises(RuntimeError) as cm:
+ self.interface._handle_return_code(9, "Not allowed", "ExecuteMethod", 123)
+ self.assertIn("return code 9", str(cm.exception))
+
+ # Device error code should transition to InError
+ with self.assertRaises(RuntimeError):
+ self.interface._handle_return_code(1000, "Device error", "ExecuteMethod", 123)
+ self.assertEqual(self.interface._current_state, SiLAState.INERROR)
+
+ def test_get_terminal_state(self):
+ """Test terminal state map for polling fallback."""
+ self.assertEqual(self.interface._get_terminal_state("Reset"), "standby")
+ self.assertEqual(self.interface._get_terminal_state("Initialize"), "idle")
+ self.assertEqual(self.interface._get_terminal_state("LockDevice"), "standby")
+ self.assertEqual(self.interface._get_terminal_state("UnlockDevice"), "standby")
+ self.assertEqual(self.interface._get_terminal_state("OpenDoor"), "idle")
+ self.assertEqual(self.interface._get_terminal_state("ExecuteMethod"), "idle")
+
+
+# Minimal SOAP responses for dual-track tests (return_code 2 = async accepted; no duration = poll immediately).
+_OPEN_DOOR_ASYNC_RESPONSE = b"""
+
+
+
+
+ 2
+ Accepted
+
+
+
+"""
+
+_GET_STATUS_IDLE_RESPONSE = b"""
+
+
+
+ idle
+
+ 1
+ Success
+
+
+
+"""
+
+_GET_STATUS_BUSY_RESPONSE = b"""
+
+
+
+ busy
+
+ 1
+ Success
+
+
+
+"""
+
+
+class TestODTCSiLADualTrack(unittest.IsolatedAsyncioTestCase):
+ """Tests for dual-track async completion (ResponseEvent + polling fallback)."""
+
+ async def test_polling_fallback_completes_when_no_response_event(self):
+ """When ResponseEvent never arrives, polling sees idle and completes Future (warn_and_continue)."""
+ call_count = 0
+
+ def mock_urlopen(req):
+ nonlocal call_count
+ call_count += 1
+ body = _OPEN_DOOR_ASYNC_RESPONSE if call_count == 1 else _GET_STATUS_IDLE_RESPONSE
+ resp = MagicMock()
+ resp.read.return_value = body
+ cm = MagicMock()
+ cm.__enter__.return_value = resp
+ cm.__exit__.return_value = None
+ return cm
+
+ with patch("urllib.request.urlopen", side_effect=mock_urlopen), patch(
+ "pylabrobot.thermocycling.inheco.odtc_sila_interface.POLLING_START_BUFFER", 0.05
+ ):
+ # Short POLLING_START_BUFFER in test so we don't wait 10s; lifetime still allows polling to run.
+ interface = ODTCSiLAInterface(
+ machine_ip="192.168.1.100",
+ client_ip="127.0.0.1",
+ poll_interval=0.02,
+ lifetime_of_execution=2.0,
+ on_response_event_missing="warn_and_continue",
+ )
+ interface._current_state = SiLAState.IDLE
+ # Do not call setup() so we avoid binding the HTTP server (sandbox/CI friendly).
+ result = await interface.send_command("OpenDoor")
+ self.assertIsNone(result)
+ self.assertGreaterEqual(call_count, 2)
+
+ async def test_lifetime_of_execution_exceeded_raises(self):
+ """When lifetime_of_execution is exceeded before terminal state, Future gets timeout exception."""
+ call_count = 0
+
+ def mock_urlopen(req):
+ nonlocal call_count
+ call_count += 1
+ body = _OPEN_DOOR_ASYNC_RESPONSE if call_count == 1 else _GET_STATUS_BUSY_RESPONSE
+ resp = MagicMock()
+ resp.read.return_value = body
+ cm = MagicMock()
+ cm.__enter__.return_value = resp
+ cm.__exit__.return_value = None
+ return cm
+
+ with patch("urllib.request.urlopen", side_effect=mock_urlopen), patch(
+ "pylabrobot.thermocycling.inheco.odtc_sila_interface.POLLING_START_BUFFER", 0.02
+ ):
+ # Short POLLING_START_BUFFER so timeout (0.5s) is hit quickly instead of waiting 10s.
+ interface = ODTCSiLAInterface(
+ machine_ip="192.168.1.100",
+ client_ip="127.0.0.1",
+ poll_interval=0.05,
+ lifetime_of_execution=0.2,
+ on_response_event_missing="warn_and_continue",
+ )
+ interface._current_state = SiLAState.IDLE
+ # Do not call setup() so we avoid binding the HTTP server (sandbox/CI friendly).
+ with self.assertRaises(SiLATimeoutError) as cm:
+ await interface.send_command("OpenDoor")
+ self.assertIn("lifetime_of_execution", str(cm.exception))
+
+
+class TestODTCBackend(unittest.IsolatedAsyncioTestCase):
+ """Tests for ODTCBackend."""
+
+ def setUp(self):
+ """Set up test fixtures."""
+ with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"):
+ self.backend = ODTCBackend(odtc_ip="192.168.1.100")
+ self.backend._sila = MagicMock(spec=ODTCSiLAInterface)
+ self.backend._sila.bound_port = 8080
+ self.backend._sila._machine_ip = "192.168.1.100"
+ self.backend._sila._lock_id = None
+ self.backend._sila._lifetime_of_execution = None
+ self.backend._sila._client_ip = "127.0.0.1"
+
+ def test_backend_odtc_ip_property(self):
+ """Backend.odtc_ip returns machine IP from sila."""
+ self.assertEqual(self.backend.odtc_ip, "192.168.1.100")
+
+ def test_backend_variant_property(self):
+ """Backend.variant returns normalized variant (default 960000)."""
+ self.assertEqual(self.backend.variant, 960000)
+
+ async def test_setup(self):
+ """Test backend setup (full path)."""
+ self.backend._sila.setup = AsyncMock() # type: ignore[method-assign]
+ self.backend._sila._client_ip = "192.168.1.1" # type: ignore[attr-defined]
+ setattr(self.backend._sila, "bound_port", 8080) # type: ignore[misc]
+ self.backend.reset = AsyncMock() # type: ignore[method-assign]
+ self.backend.get_status = AsyncMock(return_value="idle") # type: ignore[method-assign]
+ await self.backend.setup()
+ self.backend._sila.setup.assert_called_once()
+ self.backend.reset.assert_called_once()
+ call_kwargs = self.backend.reset.call_args[1]
+ self.assertFalse(call_kwargs.get("simulation_mode", False))
+
+ async def test_setup_full_false_only_sila_setup(self):
+ """Test setup(full=False) only calls _sila.setup(), not reset or initialize."""
+ self.backend._sila.setup = AsyncMock() # type: ignore[method-assign]
+ self.backend.reset = AsyncMock() # type: ignore[method-assign]
+ self.backend.get_status = AsyncMock() # type: ignore[method-assign]
+ await self.backend.setup(full=False)
+ self.backend._sila.setup.assert_called_once()
+ self.backend.reset.assert_not_called()
+ self.backend.get_status.assert_not_called()
+
+ async def test_setup_simulation_mode_passed_to_reset(self):
+ """Test setup(simulation_mode=True) passes simulation_mode to reset."""
+ self.backend._sila.setup = AsyncMock() # type: ignore[method-assign]
+ self.backend.reset = AsyncMock() # type: ignore[method-assign]
+ self.backend.get_status = AsyncMock(return_value="idle") # type: ignore[method-assign]
+ await self.backend.setup(simulation_mode=True)
+ self.backend.reset.assert_called_once()
+ self.assertTrue(self.backend.reset.call_args[1]["simulation_mode"])
+
+ async def test_reset_sets_simulation_mode(self):
+ """Test reset(simulation_mode=X) updates backend.simulation_mode."""
+ fut: asyncio.Future[Any] = asyncio.Future()
+ fut.set_result(None)
+ self.backend._sila.start_command = AsyncMock(return_value=(fut, 1, 0.0)) # type: ignore[method-assign]
+ self.assertFalse(self.backend.simulation_mode)
+ await self.backend.reset(simulation_mode=True)
+ self.assertTrue(self.backend.simulation_mode)
+ await self.backend.reset(simulation_mode=False)
+ self.assertFalse(self.backend.simulation_mode)
+
+ async def test_setup_retries_with_backoff(self):
+ """Test setup(full=True, max_attempts=3) retries on failure with backoff."""
+ self.backend._sila.setup = AsyncMock() # type: ignore[method-assign]
+ self.backend.reset = AsyncMock() # type: ignore[method-assign]
+ call_count = 0
+
+ async def mock_get_status():
+ nonlocal call_count
+ call_count += 1
+ if call_count < 3:
+ raise RuntimeError("transient")
+ return "idle"
+
+ self.backend.get_status = AsyncMock(side_effect=mock_get_status) # type: ignore[method-assign]
+ with patch("asyncio.sleep", new_callable=AsyncMock):
+ await self.backend.setup(full=True, max_attempts=3)
+ self.assertEqual(call_count, 3)
+ # Full path runs 3 times (fail twice, succeed on third)
+ self.assertEqual(self.backend._sila.setup.call_count, 3)
+ self.assertEqual(self.backend.reset.call_count, 3)
+
+ async def test_setup_raises_when_all_attempts_fail(self):
+ """Test setup(full=True, max_attempts=2) raises when all attempts fail."""
+ self.backend._sila.setup = AsyncMock() # type: ignore[method-assign]
+ self.backend.reset = AsyncMock() # type: ignore[method-assign]
+ self.backend.get_status = AsyncMock(side_effect=RuntimeError("fail")) # type: ignore[method-assign]
+ with patch("asyncio.sleep", new_callable=AsyncMock), self.assertRaises(RuntimeError) as cm:
+ await self.backend.setup(full=True, max_attempts=2)
+ self.assertIn("fail", str(cm.exception))
+
+ async def test_stop(self):
+ """Test backend stop."""
+ self.backend._sila.close = AsyncMock() # type: ignore[method-assign]
+ await self.backend.stop()
+ self.backend._sila.close.assert_called_once()
+
+ async def test_get_status(self):
+ """Test get_status."""
+ self.backend._sila.send_command = AsyncMock( # type: ignore[method-assign]
+ return_value={
+ "GetStatusResponse": {
+ "state": "idle",
+ "GetStatusResult": {"returnCode": 1, "message": "Success."},
+ }
+ }
+ )
+ status = await self.backend.get_status()
+ self.assertEqual(status, "idle")
+ self.backend._sila.send_command.assert_called_once_with("GetStatus")
+
+ async def test_open_door(self):
+ """Test open_door."""
+ self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign]
+ await self.backend.open_door()
+ self.backend._sila.send_command.assert_called_once_with("OpenDoor")
+
+ async def test_close_door(self):
+ """Test close_door."""
+ self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign]
+ await self.backend.close_door()
+ self.backend._sila.send_command.assert_called_once_with("CloseDoor")
+
+ async def test_read_temperatures(self):
+ """Test read_temperatures."""
+ # Mock response with SensorValues XML
+ sensor_xml = (
+ ''
+ "2463"
+ "2642"
+ "2575"
+ "2627"
+ "2450"
+ "3308"
+ "2596"
+ "2487"
+ ""
+ )
+
+ # Create mock ElementTree response
+ root = ET.Element("ResponseData")
+ param = ET.SubElement(root, "Parameter", name="SensorValues")
+ string_elem = ET.SubElement(param, "String")
+ string_elem.text = sensor_xml
+
+ self.backend._sila.send_command = AsyncMock(return_value=root) # type: ignore[method-assign]
+ sensor_values = await self.backend.read_temperatures()
+ self.assertAlmostEqual(sensor_values.mount, 24.63, places=2) # 2463 * 0.01
+ self.assertAlmostEqual(sensor_values.lid, 25.75, places=2) # 2575 * 0.01
+
+ async def test_execute_method(self):
+ """Test execute_method with wait=True; event-driven: wait_for_first_event then handle with eta from DataEvent."""
+ self.backend.list_methods = AsyncMock(return_value=([], [])) # type: ignore[method-assign]
+ self.backend.get_protocol = AsyncMock(return_value=None) # type: ignore[method-assign]
+ fut: asyncio.Future[Any] = asyncio.Future()
+ fut.set_result(None)
+ self.backend._sila.get_first_event_type_for_command = MagicMock( # type: ignore[method-assign]
+ return_value=FirstEventType.DATA_EVENT
+ )
+ self.backend._sila.start_command = AsyncMock( # type: ignore[method-assign]
+ return_value=(fut, 12345, 0.0)
+ )
+ self.backend._sila.wait_for_first_event = AsyncMock( # type: ignore[method-assign]
+ return_value=_minimal_data_event_payload(remaining_s=300.0)
+ )
+ result = await self.backend.execute_method("MyMethod", wait=True)
+ self.assertIsInstance(result, ODTCExecution)
+ self.assertEqual(result.method_name, "MyMethod")
+ self.backend._sila.start_command.assert_called_once()
+ call_kwargs = self.backend._sila.start_command.call_args[1]
+ self.assertEqual(call_kwargs["methodName"], "MyMethod")
+ self.assertNotIn("estimated_duration_seconds", call_kwargs)
+ # Device does not send remaining duration; we use estimated_duration_s - elapsed_s (get_protocol returns None so effective lifetime).
+ self.assertEqual(result.estimated_remaining_time, self.backend._get_effective_lifetime())
+
+ async def test_stop_method(self):
+ """Test stop_method."""
+ self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign]
+ await self.backend.stop_method()
+ self.backend._sila.send_command.assert_called_once_with("StopMethod")
+
+ async def test_lock_device(self):
+ """Test lock_device."""
+ self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign]
+ await self.backend.lock_device("my_lock_id")
+ self.backend._sila.send_command.assert_called_once()
+ call_kwargs = self.backend._sila.send_command.call_args[1]
+ self.assertEqual(call_kwargs["lock_id"], "my_lock_id")
+ self.assertEqual(call_kwargs["PMSId"], "PyLabRobot")
+
+ async def test_unlock_device(self):
+ """Test unlock_device."""
+ self.backend._sila._lock_id = "my_lock_id"
+ self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign]
+ await self.backend.unlock_device()
+ self.backend._sila.send_command.assert_called_once_with("UnlockDevice", lock_id="my_lock_id")
+
+ async def test_unlock_device_not_locked(self):
+ """Test unlock_device when device is not locked."""
+ self.backend._sila._lock_id = None
+ with self.assertRaises(RuntimeError) as cm:
+ await self.backend.unlock_device()
+ self.assertIn("not locked", str(cm.exception))
+
+ async def test_get_block_current_temperature(self):
+ """Test get_block_current_temperature."""
+ sensor_xml = "2500"
+ root = ET.Element("ResponseData")
+ param = ET.SubElement(root, "Parameter", name="SensorValues")
+ string_elem = ET.SubElement(param, "String")
+ string_elem.text = sensor_xml
+
+ self.backend._sila.send_command = AsyncMock(return_value=root) # type: ignore[method-assign]
+ temps = await self.backend.get_block_current_temperature()
+ self.assertEqual(len(temps), 1)
+ self.assertAlmostEqual(temps[0], 25.0, places=2)
+
+ async def test_get_lid_current_temperature(self):
+ """Test get_lid_current_temperature."""
+ sensor_xml = "2600"
+ root = ET.Element("ResponseData")
+ param = ET.SubElement(root, "Parameter", name="SensorValues")
+ string_elem = ET.SubElement(param, "String")
+ string_elem.text = sensor_xml
+
+ self.backend._sila.send_command = AsyncMock(return_value=root) # type: ignore[method-assign]
+ temps = await self.backend.get_lid_current_temperature()
+ self.assertEqual(len(temps), 1)
+ self.assertAlmostEqual(temps[0], 26.0, places=2)
+
+ async def test_get_progress_snapshot_with_registered_protocol_returns_enriched(self):
+ """With protocol registered and DataEvent with elapsed_s, get_progress_snapshot returns step/cycle/hold."""
+ request_id = 12345
+ premethod = ODTCProtocol(
+ kind="premethod",
+ name="Pre37",
+ target_block_temperature=37.0,
+ stages=[],
+ )
+ self.backend._protocol_by_request_id[request_id] = premethod
+ self.backend._sila._data_events_by_request_id = { # type: ignore[attr-defined]
+ request_id: [_data_event_payload_with_elapsed(100.0, request_id)],
+ }
+ fut: asyncio.Future[Any] = asyncio.Future()
+ self.backend._current_execution = ODTCExecution(
+ request_id=request_id,
+ command_name="ExecuteMethod",
+ _future=fut,
+ backend=self.backend,
+ estimated_remaining_time=600.0,
+ started_at=0.0,
+ lifetime=660.0,
+ method_name="Pre37",
+ )
+ try:
+ progress = await self.backend.get_progress_snapshot()
+ self.assertIsNotNone(progress)
+ assert progress is not None
+ self.assertIsInstance(progress, ODTCProgress)
+ self.assertEqual(progress.elapsed_s, 100.0)
+ self.assertEqual(progress.current_step_index, 0)
+ self.assertEqual(progress.current_cycle_index, 0)
+ self.assertGreater(progress.remaining_hold_s or 0, 0)
+ self.assertEqual(progress.target_temp_c, 37.0)
+ self.assertEqual(progress.estimated_duration_s, PREMETHOD_ESTIMATED_DURATION_SECONDS)
+ self.assertAlmostEqual(progress.remaining_duration_s or 0, 500.0, delta=1.0) # 600 - 100
+ finally:
+ self.backend._current_execution = None
+ self.backend._protocol_by_request_id.pop(request_id, None)
+
+ async def test_get_current_step_index_and_get_hold_time_with_registered_protocol(self):
+ """With protocol registered and DataEvent, get_current_step_index and get_hold_time return values."""
+ request_id = 12346
+ premethod = ODTCProtocol(
+ kind="premethod",
+ name="Pre37",
+ target_block_temperature=37.0,
+ stages=[],
+ )
+ self.backend._protocol_by_request_id[request_id] = premethod
+ self.backend._sila._data_events_by_request_id = { # type: ignore[attr-defined]
+ request_id: [_data_event_payload_with_elapsed(50.0, request_id)],
+ }
+ fut: asyncio.Future[Any] = asyncio.Future()
+ self.backend._current_execution = ODTCExecution(
+ request_id=request_id,
+ command_name="ExecuteMethod",
+ _future=fut,
+ backend=self.backend,
+ estimated_remaining_time=600.0,
+ started_at=0.0,
+ lifetime=660.0,
+ method_name="Pre37",
+ )
+ try:
+ step_idx = await self.backend.get_current_step_index()
+ self.assertEqual(step_idx, 0)
+ hold_s = await self.backend.get_hold_time()
+ self.assertGreaterEqual(hold_s, 0)
+ cycle_idx = await self.backend.get_current_cycle_index()
+ self.assertEqual(cycle_idx, 0)
+ finally:
+ self.backend._current_execution = None
+ self.backend._protocol_by_request_id.pop(request_id, None)
+
+ async def test_execute_method_wait_false(self):
+ """Test execute_method with wait=False (returns handle); eta from our estimated_duration_s - elapsed_s (no device remaining)."""
+ self.backend.list_methods = AsyncMock(return_value=([], [])) # type: ignore[method-assign]
+ self.backend.get_protocol = AsyncMock(return_value=None) # type: ignore[method-assign]
+ fut: asyncio.Future[Any] = asyncio.Future()
+ fut.set_result(None)
+ self.backend._sila.get_first_event_type_for_command = MagicMock( # type: ignore[method-assign]
+ return_value=FirstEventType.DATA_EVENT
+ )
+ self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, 0.0)) # type: ignore[method-assign]
+ self.backend._sila.wait_for_first_event = AsyncMock( # type: ignore[method-assign]
+ return_value=_minimal_data_event_payload(remaining_s=300.0)
+ )
+ execution = await self.backend.execute_method("PCR_30cycles", wait=False)
+ assert execution is not None # Type narrowing
+ self.assertIsInstance(execution, ODTCExecution)
+ self.assertEqual(execution.request_id, 12345)
+ self.assertEqual(execution.method_name, "PCR_30cycles")
+ self.backend._sila.start_command.assert_called_once()
+ call_kwargs = self.backend._sila.start_command.call_args[1]
+ self.assertEqual(call_kwargs["methodName"], "PCR_30cycles")
+ self.assertNotIn("estimated_duration_seconds", call_kwargs)
+ # get_protocol returns None so we use effective lifetime for eta (device does not send remaining).
+ self.assertEqual(execution.estimated_remaining_time, self.backend._get_effective_lifetime())
+
+ async def test_execute_method_premethod_registers_protocol(self):
+ """When executing a premethod, protocol is registered so progress/step/cycle/hold work."""
+ premethod = ODTCProtocol(
+ kind="premethod",
+ name="Pre37",
+ target_block_temperature=37.0,
+ stages=[],
+ )
+ method_set = ODTCMethodSet(methods=[], premethods=[premethod])
+ self.backend.list_methods = AsyncMock(return_value=([], ["Pre37"])) # type: ignore[method-assign]
+ self.backend.get_method_set = AsyncMock(return_value=method_set) # type: ignore[method-assign]
+ fut: asyncio.Future[Any] = asyncio.Future()
+ fut.set_result(None)
+ self.backend._sila.get_first_event_type_for_command = MagicMock( # type: ignore[method-assign]
+ return_value=FirstEventType.DATA_EVENT
+ )
+ self.backend._sila.start_command = AsyncMock(return_value=(fut, 99999, 0.0)) # type: ignore[method-assign]
+ self.backend._sila.wait_for_first_event = AsyncMock( # type: ignore[method-assign]
+ return_value=_minimal_data_event_payload(remaining_s=300.0)
+ )
+ execution = await self.backend.execute_method("Pre37", wait=False)
+ assert execution is not None
+ self.assertIn(execution.request_id, self.backend._protocol_by_request_id)
+ registered = self.backend._protocol_by_request_id[execution.request_id]
+ self.assertIsInstance(registered, ODTCProtocol)
+ reg_odtc = cast(ODTCProtocol, registered)
+ self.assertEqual(reg_odtc.kind, "premethod")
+ self.assertEqual(reg_odtc.name, "Pre37")
+ self.assertEqual(reg_odtc.target_block_temperature, 37.0)
+
+ async def test_execute_method_first_event_timeout(self):
+ """Test execute_method propagates FirstEventTimeout when no DataEvent received in time."""
+ self.backend.list_methods = AsyncMock(return_value=([], [])) # type: ignore[method-assign]
+ self.backend.get_protocol = AsyncMock(return_value=None) # type: ignore[method-assign]
+ fut: asyncio.Future[Any] = asyncio.Future()
+ fut.set_result(None)
+ self.backend._sila.get_first_event_type_for_command = MagicMock( # type: ignore[method-assign]
+ return_value=FirstEventType.DATA_EVENT
+ )
+ self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, 0.0)) # type: ignore[method-assign]
+ self.backend._sila.wait_for_first_event = AsyncMock( # type: ignore[method-assign]
+ side_effect=FirstEventTimeout("No DataEvent received for request_id 12345 within 60.0s")
+ )
+ with self.assertRaises(FirstEventTimeout) as cm:
+ await self.backend.execute_method("MyMethod", wait=False)
+ self.assertIn("12345", str(cm.exception))
+ self.assertIn("60", str(cm.exception))
+
+ async def test_method_execution_awaitable(self):
+ """Test that ODTCExecution is awaitable and wait() completes (returns None)."""
+ fut: asyncio.Future[Any] = asyncio.Future()
+ fut.set_result("success")
+ execution = ODTCExecution(
+ request_id=12345,
+ command_name="ExecuteMethod",
+ method_name="PCR_30cycles",
+ _future=fut,
+ backend=self.backend,
+ )
+ result = await execution
+ self.assertIsNone(result)
+ await execution.wait() # Should not raise
+
+ async def test_method_execution_is_running(self):
+ """Test ODTCExecution.is_running() for ExecuteMethod."""
+ fut: asyncio.Future[Any] = asyncio.Future()
+ execution = ODTCExecution(
+ request_id=12345,
+ command_name="ExecuteMethod",
+ method_name="PCR_30cycles",
+ _future=fut,
+ backend=self.backend,
+ )
+ self.backend.get_status = AsyncMock(return_value="busy") # type: ignore[method-assign]
+ is_running = await execution.is_running()
+ self.assertTrue(is_running)
+
+ async def test_method_execution_stop(self):
+ """Test ODTCExecution.stop() for ExecuteMethod."""
+ fut: asyncio.Future[Any] = asyncio.Future()
+ execution = ODTCExecution(
+ request_id=12345,
+ command_name="ExecuteMethod",
+ method_name="PCR_30cycles",
+ _future=fut,
+ backend=self.backend,
+ )
+ self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign]
+ await execution.stop()
+ self.backend._sila.send_command.assert_called_once_with("StopMethod")
+
+ async def test_odtc_execution_has_command_and_method(self):
+ """Test ODTCExecution has command_name and method_name when ExecuteMethod."""
+ fut: asyncio.Future[Any] = asyncio.Future()
+ fut.set_result(None)
+ execution = ODTCExecution(
+ request_id=12345,
+ command_name="ExecuteMethod",
+ method_name="PCR_30cycles",
+ _future=fut,
+ backend=self.backend,
+ )
+ self.assertEqual(execution.command_name, "ExecuteMethod")
+ self.assertEqual(execution.method_name, "PCR_30cycles")
+
+ async def test_command_execution_awaitable(self):
+ """Test that ODTCExecution is awaitable and wait() completes (returns None)."""
+ fut: asyncio.Future[Any] = asyncio.Future()
+ fut.set_result("success")
+ execution = ODTCExecution(
+ request_id=12345,
+ command_name="OpenDoor",
+ _future=fut,
+ backend=self.backend,
+ )
+ result = await execution
+ self.assertIsNone(result)
+ await execution.wait() # Should not raise
+
+ async def test_command_execution_get_data_events(self):
+ """Test ODTCExecution.get_data_events() method."""
+ fut: asyncio.Future[Any] = asyncio.Future()
+ fut.set_result(None)
+ execution = ODTCExecution(
+ request_id=12345,
+ command_name="OpenDoor",
+ _future=fut,
+ backend=self.backend,
+ )
+ self.backend._sila._data_events_by_request_id = {
+ 12345: [{"requestId": 12345, "data": "test1"}, {"requestId": 12345, "data": "test2"}],
+ 67890: [{"requestId": 67890, "data": "test3"}],
+ }
+ events = await execution.get_data_events()
+ self.assertEqual(len(events), 2)
+ self.assertEqual(events[0]["requestId"], 12345)
+
+ async def test_open_door_wait_false_returns_execution_handle(self):
+ """Test open_door with wait=False returns ODTCExecution; lifetime/eta from first_event_timeout."""
+ fut: asyncio.Future[Any] = asyncio.Future()
+ fut.set_result(None)
+ self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, 0.0)) # type: ignore[method-assign]
+ execution = await self.backend.open_door(wait=False)
+ assert execution is not None # Type narrowing
+ self.assertIsInstance(execution, ODTCExecution)
+ self.assertEqual(execution.request_id, 12345)
+ self.assertEqual(execution.command_name, "OpenDoor")
+ self.backend._sila.start_command.assert_called_once()
+ self.assertEqual(self.backend._sila.start_command.call_args[0][0], "OpenDoor")
+ self.assertNotIn("estimated_duration_seconds", self.backend._sila.start_command.call_args[1])
+ self.assertEqual(execution.estimated_remaining_time, 60.0)
+ self.assertEqual(execution.lifetime, 120.0)
+
+ async def test_reset_wait_false_returns_handle_with_kwargs(self):
+ """Test reset with wait=False returns ODTCExecution and passes deviceId/eventReceiverURI."""
+ fut: asyncio.Future[Any] = asyncio.Future()
+ fut.set_result(None)
+ self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, 0.0)) # type: ignore[method-assign]
+ execution = await self.backend.reset(wait=False)
+ assert execution is not None # Type narrowing
+ self.assertIsInstance(execution, ODTCExecution)
+ self.assertEqual(execution.request_id, 12345)
+ self.assertEqual(execution.command_name, "Reset")
+ self.backend._sila.start_command.assert_called_once()
+ call_kwargs = self.backend._sila.start_command.call_args[1]
+ self.assertEqual(call_kwargs["deviceId"], "ODTC")
+ self.assertEqual(call_kwargs["eventReceiverURI"], "http://127.0.0.1:8080/")
+ self.assertFalse(call_kwargs["simulationMode"])
+ self.assertFalse(self.backend.simulation_mode)
+
+ async def test_is_method_running(self):
+ """Test is_method_running()."""
+ with patch.object(ODTCBackend, "get_status", new_callable=AsyncMock, return_value="busy"):
+ self.assertTrue(await self.backend.is_method_running())
+
+ with patch.object(ODTCBackend, "get_status", new_callable=AsyncMock, return_value="idle"):
+ self.assertFalse(await self.backend.is_method_running())
+
+ with patch.object(ODTCBackend, "get_status", new_callable=AsyncMock, return_value="BUSY"):
+ # Backend compares to SiLAState.BUSY.value ("busy"), so uppercase is False
+ self.assertFalse(await self.backend.is_method_running())
+
+ async def test_wait_for_method_completion(self):
+ """Test wait_for_method_completion()."""
+ call_count = 0
+
+ async def mock_get_status():
+ nonlocal call_count
+ call_count += 1
+ if call_count < 3:
+ return "busy"
+ return "idle"
+
+ self.backend.get_status = AsyncMock(side_effect=mock_get_status) # type: ignore[method-assign]
+ await self.backend.wait_for_method_completion(poll_interval=0.1)
+ self.assertEqual(call_count, 3)
+
+ async def test_wait_for_method_completion_timeout(self):
+ """Test wait_for_method_completion() with timeout."""
+ self.backend.get_status = AsyncMock(return_value="busy") # type: ignore[method-assign]
+ with self.assertRaises(TimeoutError):
+ await self.backend.wait_for_method_completion(poll_interval=0.1, timeout=0.3)
+
+ async def test_get_data_events(self):
+ """Test get_data_events()."""
+ self.backend._sila._data_events_by_request_id = {
+ 12345: [{"requestId": 12345, "data": "test1"}, {"requestId": 12345, "data": "test2"}],
+ 67890: [{"requestId": 67890, "data": "test3"}],
+ }
+
+ # Get all events
+ all_events = await self.backend.get_data_events()
+ self.assertEqual(len(all_events), 2)
+ self.assertEqual(len(all_events[12345]), 2)
+
+ # Get events for specific request_id
+ events = await self.backend.get_data_events(request_id=12345)
+ self.assertEqual(len(events), 1)
+ self.assertEqual(len(events[12345]), 2)
+
+ # Get events for non-existent request_id
+ events = await self.backend.get_data_events(request_id=99999)
+ self.assertEqual(len(events), 1)
+ self.assertEqual(len(events[99999]), 0)
+
+ async def test_list_protocols(self):
+ """Test list_protocols returns method and premethod names."""
+ method_set = ODTCMethodSet(
+ methods=[ODTCProtocol(kind="method", name="PCR_30", stages=[])],
+ premethods=[ODTCProtocol(kind="premethod", name="Pre25", stages=[])],
+ )
+ self.backend.get_method_set = AsyncMock(return_value=method_set) # type: ignore[method-assign]
+ names = await self.backend.list_protocols()
+ self.assertEqual(names.all, ["PCR_30", "Pre25"])
+
+ async def test_list_methods(self):
+ """Test list_methods returns (method_names, premethod_names) and matches list_protocols."""
+ method_set = ODTCMethodSet(
+ methods=[
+ ODTCProtocol(kind="method", name="PCR_30", stages=[]),
+ ODTCProtocol(kind="method", name="PCR_35", stages=[]),
+ ],
+ premethods=[
+ ODTCProtocol(kind="premethod", name="Pre25", stages=[]),
+ ODTCProtocol(kind="premethod", name="Pre37", stages=[]),
+ ],
+ )
+ self.backend.get_method_set = AsyncMock(return_value=method_set) # type: ignore[method-assign]
+ methods, premethods = await self.backend.list_methods()
+ self.assertEqual(methods, ["PCR_30", "PCR_35"])
+ self.assertEqual(premethods, ["Pre25", "Pre37"])
+ protocol_list = await self.backend.list_protocols()
+ self.assertEqual(methods + premethods, protocol_list.all)
+
+ async def test_get_protocol_returns_none_for_missing(self):
+ """Test get_protocol returns None when name not found."""
+ self.backend.get_method_set = AsyncMock(return_value=ODTCMethodSet()) # type: ignore[method-assign]
+ result = await self.backend.get_protocol("nonexistent")
+ self.assertIsNone(result)
+
+ async def test_get_protocol_returns_none_for_premethod(self):
+ """Test get_protocol returns None for premethod names (runnable protocols only)."""
+ method_set = ODTCMethodSet(
+ methods=[],
+ premethods=[ODTCProtocol(kind="premethod", name="Pre25", stages=[])],
+ )
+ self.backend.get_method_set = AsyncMock(return_value=method_set) # type: ignore[method-assign]
+ result = await self.backend.get_protocol("Pre25")
+ self.assertIsNone(result)
+
+ async def test_get_protocol_returns_stored_for_method(self):
+ """Test get_protocol returns ODTCProtocol for runnable method."""
+ method_set = ODTCMethodSet(
+ methods=[
+ ODTCProtocol(
+ kind="method",
+ name="PCR_30",
+ steps=[ODTCStep(number=1, plateau_temperature=95.0, plateau_time=30.0)],
+ stages=[],
+ )
+ ],
+ premethods=[],
+ )
+ self.backend.get_method_set = AsyncMock(return_value=method_set) # type: ignore[method-assign]
+ result = await self.backend.get_protocol("PCR_30")
+ self.assertIsInstance(result, ODTCProtocol)
+ assert result is not None # narrow for type checker
+ self.assertEqual(result.name, "PCR_30")
+ protocol, _ = odtc_protocol_to_protocol(result)
+ self.assertEqual(len(protocol.stages), 1)
+ self.assertEqual(len(protocol.stages[0].steps), 1)
+
+ async def test_run_stored_protocol_calls_execute_method(self):
+ """Test run_stored_protocol calls execute_method with name, wait, protocol (no estimated_duration_seconds)."""
+ self.backend.execute_method = AsyncMock(return_value=None) # type: ignore[method-assign]
+ with patch.object(
+ self.backend, "get_protocol", new_callable=AsyncMock, return_value=None
+ ), patch.object(
+ self.backend, "get_method_set", new_callable=AsyncMock, return_value=ODTCMethodSet()
+ ):
+ await self.backend.run_stored_protocol("MyMethod", wait=True)
+ self.backend.execute_method.assert_called_once_with("MyMethod", wait=True, protocol=None)
+
+
+class TestODTCThermocycler(unittest.TestCase):
+ """Tests for ODTCThermocycler resource."""
+
+ def test_construct_creates_backend_and_uses_dimensions(self):
+ """Constructing with odtc_ip and variant creates ODTCBackend and ODTC dimensions."""
+ with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"):
+ tc = ODTCThermocycler(
+ name="odtc1",
+ odtc_ip="192.168.1.100",
+ variant=384,
+ child_location=Coordinate.zero(),
+ )
+ self.assertIsInstance(tc.backend, ODTCBackend)
+ self.assertEqual(tc.backend.variant, 384000)
+ self.assertEqual(tc.get_size_x(), ODTC_DIMENSIONS.x)
+ self.assertEqual(tc.get_size_y(), ODTC_DIMENSIONS.y)
+ self.assertEqual(tc.get_size_z(), ODTC_DIMENSIONS.z)
+ self.assertEqual(tc.model, "ODTC 384")
+
+ def test_construct_variant_96_model(self):
+ """Constructing with variant=96 sets model ODTC 96."""
+ with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"):
+ tc = ODTCThermocycler(name="tc", odtc_ip="192.168.1.1", variant=96)
+ self.assertEqual(tc.backend.variant, 960000)
+ self.assertEqual(tc.model, "ODTC 96")
+
+ def test_serialize_includes_odtc_ip_and_variant(self):
+ """serialize() includes odtc_ip and variant from backend."""
+ with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"):
+ tc = ODTCThermocycler(
+ name="odtc1",
+ odtc_ip="192.168.1.50",
+ variant=384,
+ child_location=Coordinate.zero(),
+ )
+ tc.backend._sila._machine_ip = "192.168.1.50"
+ data = tc.serialize()
+ self.assertEqual(data["odtc_ip"], "192.168.1.50")
+ self.assertEqual(data["variant"], 384000)
+
+ def test_get_default_config_delegates_to_backend(self):
+ """get_default_config returns backend.get_default_config()."""
+ with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"):
+ tc = ODTCThermocycler(name="tc", odtc_ip="192.168.1.1", variant=384)
+ config = tc.get_default_config(name="MyPCR")
+ self.assertEqual(config.variant, 384000)
+ self.assertEqual(config.name, "MyPCR")
+
+ def test_get_constraints_delegates_to_backend(self):
+ """get_constraints returns backend.get_constraints()."""
+ with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"):
+ tc = ODTCThermocycler(name="tc", odtc_ip="192.168.1.1", variant=384)
+ constraints = tc.get_constraints()
+ self.assertEqual(constraints.variant, 384000)
+ self.assertEqual(constraints.variant_name, "ODTC 384")
+
+ def test_well_count_96(self):
+ """well_count is 96 when backend variant is 960000."""
+ with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"):
+ tc = ODTCThermocycler(name="tc", odtc_ip="192.168.1.1", variant=96)
+ self.assertEqual(tc.well_count, 96)
+
+ def test_well_count_384(self):
+ """well_count is 384 when backend variant is 384000."""
+ with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"):
+ tc = ODTCThermocycler(name="tc", odtc_ip="192.168.1.1", variant=384)
+ self.assertEqual(tc.well_count, 384)
+
+ def test_is_profile_running_delegates_to_backend(self):
+ """is_profile_running delegates to backend.is_method_running()."""
+ with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"):
+ tc = ODTCThermocycler(name="tc", odtc_ip="192.168.1.1", variant=384)
+ tc.backend.is_method_running = AsyncMock(return_value=False) # type: ignore[method-assign]
+ result = asyncio.run(tc.is_profile_running())
+ self.assertFalse(result)
+ tc.backend.is_method_running.assert_called_once()
+
+ def test_wait_for_profile_completion_delegates_to_backend(self):
+ """wait_for_profile_completion delegates to backend.wait_for_method_completion()."""
+ with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"):
+ tc = ODTCThermocycler(name="tc", odtc_ip="192.168.1.1", variant=384)
+ tc.backend.wait_for_method_completion = AsyncMock() # type: ignore[method-assign]
+ asyncio.run(tc.wait_for_profile_completion(poll_interval=5.0))
+ tc.backend.wait_for_method_completion.assert_called_once_with(
+ poll_interval=5.0,
+ timeout=None,
+ )
+
+ def test_backend_provided_uses_it_dimensions_from_constant(self):
+ """When backend= is provided, that backend is used; dimensions still from ODTC_DIMENSIONS."""
+ with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"):
+ backend = ODTCBackend(odtc_ip="10.0.0.1", variant=384)
+ backend._sila = MagicMock(spec=ODTCSiLAInterface)
+ backend._sila._machine_ip = "10.0.0.1"
+ tc = ODTCThermocycler(
+ name="odtc1",
+ odtc_ip="192.168.1.1",
+ variant=384,
+ backend=backend,
+ child_location=Coordinate.zero(),
+ )
+ self.assertIs(tc.backend, backend)
+ self.assertEqual(tc.get_size_x(), ODTC_DIMENSIONS.x)
+ self.assertEqual(tc.get_size_y(), ODTC_DIMENSIONS.y)
+ self.assertEqual(tc.get_size_z(), ODTC_DIMENSIONS.z)
+
+
+class TestODTCSiLAInterfaceDataEvents(unittest.TestCase):
+ """Tests for DataEvent storage in ODTCSiLAInterface."""
+
+ def test_data_event_storage_logic(self):
+ """Test that DataEvent storage logic works correctly."""
+ # Test the storage logic directly without creating the full interface
+ # (which requires network permissions)
+ data_events_by_request_id: Dict[int, List[Dict[str, Any]]] = {}
+
+ # Simulate receiving a DataEvent
+ data_event = {"requestId": 12345, "data": "test_data"}
+
+ # Apply the same logic as in _on_http handler
+ request_id = data_event.get("requestId")
+ if request_id is not None and isinstance(request_id, int):
+ if request_id not in data_events_by_request_id:
+ data_events_by_request_id[request_id] = []
+ data_events_by_request_id[request_id].append(data_event)
+
+ # Verify storage
+ self.assertIn(12345, data_events_by_request_id)
+ self.assertEqual(len(data_events_by_request_id[12345]), 1)
+ self.assertEqual(data_events_by_request_id[12345][0]["requestId"], 12345)
+
+ # Test multiple events for same request_id
+ data_event2 = {"requestId": 12345, "data": "test_data2"}
+ request_id = data_event2.get("requestId")
+ if request_id is not None and isinstance(request_id, int):
+ if request_id not in data_events_by_request_id:
+ data_events_by_request_id[request_id] = []
+ data_events_by_request_id[request_id].append(data_event2)
+
+ self.assertEqual(len(data_events_by_request_id[12345]), 2)
+
+ # Test event with None request_id (should not be stored)
+ data_event_no_id = {"data": "test_data_no_id"}
+ request_id = data_event_no_id.get("requestId")
+ if request_id is not None and isinstance(request_id, int):
+ if request_id not in data_events_by_request_id:
+ data_events_by_request_id[request_id] = []
+ data_events_by_request_id[request_id].append(data_event_no_id)
+
+ # Should still only have 2 events (the one with None request_id wasn't stored)
+ self.assertEqual(len(data_events_by_request_id[12345]), 2)
+
+
+def _minimal_method_xml_with_nested_loops() -> str:
+ """Method XML: 5 steps, inner loop 2-4 x 5, outer loop 1-5 x 30 (LoopNumber = actual count)."""
+ return """
+
+ false
+
+ 960000
+ 0
+ 0
+ false
+ 25
+ 110
+ 14.495100.1000.1001110
+ 22.255100.1000.1001110
+ 34.472100.1000.1001110
+ 44.495100.1000.1251110
+ 52.250200.1000.11301110
+ 6080250100101010070
+
+"""
+
+
+def _minimal_method_xml_flat_loop() -> str:
+ """Method XML: 2 steps, single loop 1-2 x 3 (flat, no nesting)."""
+ return """
+
+ false
+
+ 960000
+ 0
+ 0
+ false
+ 25
+ 110
+ 14.495100.1000.1001110
+ 22.255100.1000.1131110
+ 6080250100101010070
+
+"""
+
+
+class TestODTCStageAndRoundTrip(unittest.TestCase):
+ """Tests for ODTCStage tree, nested loops, and round-trip (steps and stages)."""
+
+ def test_parse_nested_loops_produces_odtc_stage_tree(self):
+ """Parse Method XML with nested loops; odtc_protocol_to_protocol returns Protocol with ODTCStage tree."""
+ method_set = parse_method_set(_minimal_method_xml_with_nested_loops())
+ self.assertEqual(len(method_set.methods), 1)
+ odtc = method_set.methods[0]
+ protocol, _ = odtc_protocol_to_protocol(odtc)
+ stages = protocol.stages
+ self.assertGreater(len(stages), 0)
+ # Top level: we expect at least one ODTCStage with inner_stages (outer 1-5, inner 2-4)
+ outer = next((s for s in stages if isinstance(s, ODTCStage) and s.inner_stages), None)
+ self.assertIsNotNone(outer, "Expected at least one ODTCStage with inner_stages")
+ assert outer is not None # narrow for type checker
+ self.assertEqual(outer.repeats, 30)
+ assert outer.inner_stages is not None # we selected for inner_stages above
+ self.assertEqual(len(outer.inner_stages), 1)
+ self.assertEqual(outer.inner_stages[0].repeats, 5)
+
+ def test_round_trip_via_steps_preserves_structure(self):
+ """Serialize ODTCProtocol (from parsed XML) back to XML and re-parse; structure preserved."""
+ method_set = parse_method_set(_minimal_method_xml_with_nested_loops())
+ odtc = method_set.methods[0]
+ xml_out = method_set_to_xml(
+ ODTCMethodSet(delete_all_methods=False, premethods=[], methods=[odtc])
+ )
+ method_set2 = parse_method_set(xml_out)
+ self.assertEqual(len(method_set2.methods), 1)
+ odtc2 = method_set2.methods[0]
+ self.assertEqual(len(odtc2.steps), len(odtc.steps))
+ for i, (a, b) in enumerate(zip(odtc.steps, odtc2.steps)):
+ self.assertEqual(a.number, b.number, f"step {i} number")
+ self.assertEqual(a.goto_number, b.goto_number, f"step {i} goto_number")
+ self.assertEqual(a.loop_number, b.loop_number, f"step {i} loop_number")
+
+ def test_round_trip_via_stages_serializes_and_reparses(self):
+ """Build ODTCProtocol from ODTCStage tree only (no .steps); serialize uses _odtc_stages_to_steps; re-parse matches."""
+ # Build tree with ODTCStep (ODTC-native, lossless): outer 1 and 5, inner 2-4 x 5; outer repeats=30
+ step1 = ODTCStep(slope=4.4, plateau_temperature=95.0, plateau_time=10.0)
+ step2 = ODTCStep(slope=2.2, plateau_temperature=55.0, plateau_time=10.0)
+ step3 = ODTCStep(slope=4.4, plateau_temperature=72.0, plateau_time=10.0)
+ step4 = ODTCStep(slope=4.4, plateau_temperature=95.0, plateau_time=10.0)
+ step5 = ODTCStep(slope=2.2, plateau_temperature=50.0, plateau_time=20.0)
+ inner = ODTCStage(steps=[step2, step3, step4], repeats=5, inner_stages=None)
+ outer = ODTCStage(steps=[step1, step5], repeats=30, inner_stages=[inner])
+ odtc = ODTCProtocol(
+ kind="method",
+ name="FromStages",
+ variant=960000,
+ start_block_temperature=25.0,
+ start_lid_temperature=110.0,
+ steps=[], # No steps; serialization will use stages
+ stages=[outer],
+ )
+ xml_str = method_set_to_xml(
+ ODTCMethodSet(delete_all_methods=False, premethods=[], methods=[odtc])
+ )
+ method_set = parse_method_set(xml_str)
+ self.assertEqual(len(method_set.methods), 1)
+ reparsed = method_set.methods[0]
+ self.assertEqual(len(reparsed.steps), 5)
+ # Check loop structure: step 4 goto 2 loop 5, step 5 goto 1 loop 30
+ by_num = {s.number: s for s in reparsed.steps}
+ self.assertEqual(by_num[4].goto_number, 2)
+ self.assertEqual(by_num[4].loop_number, 5)
+ self.assertEqual(by_num[5].goto_number, 1)
+ self.assertEqual(by_num[5].loop_number, 30)
+
+ def test_flat_method_produces_flat_stage_list(self):
+ """Flat method (single loop 1-2 x 3) produces flat list of stages (regression)."""
+ method_set = parse_method_set(_minimal_method_xml_flat_loop())
+ odtc = method_set.methods[0]
+ protocol, _ = odtc_protocol_to_protocol(odtc)
+ stages = protocol.stages
+ self.assertEqual(len(stages), 1)
+ self.assertEqual(len(stages[0].steps), 2)
+ self.assertEqual(stages[0].repeats, 3)
+ if isinstance(stages[0], ODTCStage):
+ self.assertFalse(stages[0].inner_stages)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/pylabrobot/thermocycling/inheco/odtc_thermocycler.py b/pylabrobot/thermocycling/inheco/odtc_thermocycler.py
new file mode 100644
index 00000000000..8d11cf8fb60
--- /dev/null
+++ b/pylabrobot/thermocycling/inheco/odtc_thermocycler.py
@@ -0,0 +1,126 @@
+"""ODTC thermocycler resource: subclass that owns connection params and dimensions."""
+
+from __future__ import annotations
+
+from typing import Any, Optional
+
+from pylabrobot.resources import Coordinate, ItemizedResource
+from pylabrobot.thermocycling.thermocycler import Thermocycler
+
+from .odtc_backend import ODTCBackend
+from .odtc_model import ODTC_DIMENSIONS, ODTCConfig, ODTCHardwareConstraints
+
+
+def _model_from_variant(variant: int) -> str:
+ """Return model string from ODTC variant code."""
+ if variant == 960000:
+ return "ODTC 96"
+ if variant == 384000:
+ return "ODTC 384"
+ return "ODTC"
+
+
+class ODTCThermocycler(Thermocycler):
+ """Inheco ODTC thermocycler resource.
+
+ Owns connection params (odtc_ip, variant) and creates ODTCBackend by default.
+ Dimensions (156.5 x 248 x 124.3 mm) are set from ODTC_DIMENSIONS.
+ """
+
+ def __init__(
+ self,
+ name: str,
+ odtc_ip: str,
+ variant: int = 384,
+ child_location: Coordinate = Coordinate.zero(),
+ child: Optional[ItemizedResource] = None,
+ backend: Optional[ODTCBackend] = None,
+ **backend_kwargs: Any,
+ ):
+ """Initialize ODTC thermocycler.
+
+ Args:
+ name: Human-readable name.
+ odtc_ip: IP address of the ODTC device.
+ variant: Well count (96, 384) or ODTC variant code (960000, 384000, 3840000).
+ Normalized via backend; default 384.
+ child_location: Position where a plate sits on the block.
+ child: Optional plate/rack already loaded on the module.
+ backend: Optional pre-constructed ODTCBackend; if None, one is created
+ from odtc_ip, variant, and backend_kwargs.
+ **backend_kwargs: Passed to ODTCBackend when backend is None (e.g. client_ip,
+ logger, poll_interval, lifetime_of_execution, on_response_event_missing).
+ """
+ backend = backend or ODTCBackend(odtc_ip=odtc_ip, variant=variant, **backend_kwargs)
+ model = _model_from_variant(backend.variant)
+ super().__init__(
+ name=name,
+ size_x=ODTC_DIMENSIONS.x,
+ size_y=ODTC_DIMENSIONS.y,
+ size_z=ODTC_DIMENSIONS.z,
+ backend=backend,
+ child_location=child_location,
+ category="thermocycler",
+ model=model,
+ )
+ self.backend: ODTCBackend = backend
+ self.child = child
+ if child is not None:
+ self.assign_child_resource(child, location=child_location)
+
+ def serialize(self) -> dict:
+ """Return a serialized representation of the thermocycler."""
+ return {
+ **super().serialize(),
+ "odtc_ip": self.backend.odtc_ip,
+ "variant": self.backend.variant,
+ }
+
+ def get_default_config(self, **kwargs: Any) -> ODTCConfig:
+ """Get default ODTCConfig for this backend's variant. Delegates to backend."""
+ return self.backend.get_default_config(**kwargs)
+
+ def get_constraints(self) -> ODTCHardwareConstraints:
+ """Get hardware constraints for this backend's variant. Delegates to backend."""
+ return self.backend.get_constraints()
+
+ @property
+ def well_count(self) -> int:
+ """Well count (96 or 384) from backend variant."""
+ if self.backend.variant == 960000:
+ return 96
+ return 384
+
+ async def is_profile_running(self, **backend_kwargs: Any) -> bool:
+ """Return True if a profile (method) is still running.
+
+ For ODTC, this uses device busy state (GetStatus) via
+ backend.is_method_running(); ODTC does not report per-step/cycle progress.
+ """
+ return await self.backend.is_method_running()
+
+ async def get_progress_snapshot(self):
+ """Get progress from the latest DataEvent (elapsed, temperatures, step/cycle/hold). Returns None if no run active.
+
+ Returns Optional[ODTCProgress]. Use tc.backend.get_progress_snapshot(); see ODTCProgress
+ for attributes (elapsed_s, current_temp_c, target_temp_c, lid_temp_c, step/cycle/hold)
+ and format_progress_log_message().
+ """
+ return await self.backend.get_progress_snapshot()
+
+ async def wait_for_profile_completion(
+ self,
+ poll_interval: float = 60.0,
+ **backend_kwargs: Any,
+ ) -> None:
+ """Block until the profile (method) finishes.
+
+ For ODTC, this delegates to backend.wait_for_method_completion(), which
+ polls GetStatus until the device returns to idle. Pass timeout=... in
+ backend_kwargs to limit wait time.
+ """
+ timeout = backend_kwargs.get("timeout")
+ await self.backend.wait_for_method_completion(
+ poll_interval=poll_interval,
+ timeout=timeout,
+ )
diff --git a/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb b/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb
new file mode 100644
index 00000000000..047c7d623ae
--- /dev/null
+++ b/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb
@@ -0,0 +1,513 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# ODTC Tutorial: Connect, Lid, Block Temperature, and Protocol\n",
+ "\n",
+ "This notebook walks through the ODTC (Inheco) thermocycler interface: setup, listing methods, lid (door) commands, setting block temperature, and running a protocol. Use it as a reference for the recommended workflow."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 0. Logging (optional)\n",
+ "\n",
+ "**Optional.** Run this cell if you want to see backend/enriched progress and instrument (SiLA) communication in a file. Backend and SiLA log to one timestamped file; progress (ODTCProgress) is logged every 150 s while you await a handle. Optional: set **`tc.backend.data_event_log_path`** (e.g. in section 1) for raw DataEvent JSONL."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Logging to odtc_run_20260215_004101.log\n",
+ "DataEvent summaries go to the same log; set tc.backend.data_event_log_path = 'odtc_data_events_20260215_004101.jsonl' for raw JSONL (optional)\n"
+ ]
+ }
+ ],
+ "source": [
+ "import logging\n",
+ "from datetime import datetime\n",
+ "\n",
+ "# One log file and one DataEvents file per notebook run (timestamped)\n",
+ "_run_id = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n",
+ "odtc_log_file = f\"odtc_run_{_run_id}.log\"\n",
+ "odtc_data_events_file = f\"odtc_data_events_{_run_id}.jsonl\"\n",
+ "\n",
+ "# File handler: ODTC backend + thermocycling + SiLA (storage inheco used during setup)\n",
+ "_fh = logging.FileHandler(odtc_log_file, encoding=\"utf-8\")\n",
+ "_fh.setLevel(logging.DEBUG) # Set to logging.INFO to reduce verbosity\n",
+ "_fh.setFormatter(logging.Formatter(\"%(asctime)s - %(name)s - %(levelname)s - %(message)s\"))\n",
+ "\n",
+ "for _name in (\"pylabrobot.thermocycling.inheco\", \"pylabrobot.storage.inheco\"):\n",
+ " _log = logging.getLogger(_name)\n",
+ " _log.setLevel(logging.DEBUG)\n",
+ " _log.addHandler(_fh)\n",
+ "\n",
+ "print(f\"Logging to {odtc_log_file}\")\n",
+ "print(f\"DataEvent summaries go to the same log; set tc.backend.data_event_log_path = '{odtc_data_events_file}' for raw JSONL (optional)\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 1. Imports and thermocycler\n",
+ "\n",
+ "Use **ODTCThermocycler** (recommended): it owns `odtc_ip`, `variant`, and dimensions. Alternative: generic `Thermocycler` + `ODTCBackend` for custom backend options."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import logging\n",
+ "from pylabrobot.resources import Coordinate\n",
+ "from pylabrobot.thermocycling.inheco import ODTCThermocycler\n",
+ "# Preferred: ODTCThermocycler (built-in ODTC dimensions; variant 96 or 384)\n",
+ "tc = ODTCThermocycler(name=\"odtc_test\", odtc_ip=\"192.168.1.50\", variant=96, child_location=Coordinate.zero())\n",
+ "# Optional: raw DataEvent JSONL (path from section 0)\n",
+ "tc.backend.data_event_log_path = odtc_data_events_file\n",
+ "# Override: tc = Thermocycler(..., backend=ODTCBackend(odtc_ip=..., variant=96, logger=...), ...) for custom backend"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 2. Connect and list device methods and premethods\n",
+ "\n",
+ "`setup()` resets and initializes the device. **Methods** = runnable protocols on the device; **premethods** = setup-only (e.g. set temperature). Use **`tc.backend.list_protocols()`** to get a **`ProtocolList`** (`.methods`, `.premethods`, `.all`); **`tc.backend.get_protocol(name)`** returns an **ODTCProtocol** for methods (`None` for premethods)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "2026-02-15 00:41:02,166 - pylabrobot.storage.inheco.scila.inheco_sila_interface - INFO - Device reset (unlocked)\n",
+ "2026-02-15 00:41:02,188 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - GetStatus returned raw state: 'standby' (type: str)\n",
+ "2026-02-15 00:41:02,188 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device is in standby state, calling Initialize...\n",
+ "2026-02-15 00:41:02,580 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device successfully initialized and is in idle state\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "✓ Connected and initialized.\n"
+ ]
+ }
+ ],
+ "source": [
+ "await tc.setup()\n",
+ "print(\"✓ Connected and initialized.\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "**ODTCProtocol and running options:** **`tc.backend.get_protocol(name)`** returns an **ODTCProtocol** (subclasses `Protocol`; has `.steps`, `.name`, and ODTC-specific fields). Use **`print(odtc)`** for a human-readable summary.\n",
+ "\n",
+ "- **Roundtrip:** **`tc.run_protocol(odtc, block_max_volume)`** — pass the ODTCProtocol from the device; same device-calculated config (thermal tuning preserved).\n",
+ "- **Run by name (recommended for PCR):** **`tc.run_stored_protocol(\"MethodName\")`** — device runs its stored method; optimal thermal (overshoots and device-tuned ramps).\n",
+ "- **Custom protocol:** **`tc.run_protocol(protocol, block_max_volume, config=...)`** with a generic `Protocol` and optional **`tc.backend.get_default_config(post_heating=...)`** — no prior device config means default overshoot; use roundtrip or run-by-name for best thermal performance."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Methods (runnable protocols):\n",
+ " - plr_currentProtocol\n",
+ " - M18_Abnahmetest\n",
+ " - M23_LEAK\n",
+ " - M24_LEAKCYCLE\n",
+ " - M22_A-RUNIN\n",
+ " - M22_B-RUNIN\n",
+ " - M30_PCULOAD\n",
+ " - M33_Abnahmetest\n",
+ " - M34_Dauertest\n",
+ " - M35_VCLE\n",
+ " - M36_Verifikation\n",
+ " - M37_BGI-Kon\n",
+ " - M39_RMATEST\n",
+ " - M18_Abnahmetest_384\n",
+ " - M23_LEAK_384\n",
+ " - M22_A-RUNIN_384\n",
+ " - M22_B-RUNIN_384\n",
+ " - M30_PCULOAD_384\n",
+ " - M33_Abnahmetest_384\n",
+ " - M34_Dauertest_384\n",
+ " - M35_VCLE_384\n",
+ " - M36_Verifikation_384\n",
+ " - M37_BGI-Kon_384\n",
+ " - M39_RMATEST_384\n",
+ " - M120_OVT\n",
+ " - M320_OVT\n",
+ " - M121_OVT\n",
+ " - M321_OVT\n",
+ " - M123_OVT\n",
+ " - M323_OVT\n",
+ " - M124_OVT\n",
+ " - M324_OVT\n",
+ " - M125_OVT\n",
+ " - M325_OVT\n",
+ " - PMA cycle\n",
+ " - Test\n",
+ " - DC4_ProK_digestion\n",
+ " - DC4_3Prime_Ligation\n",
+ " - DC4_ProK_digestion_1_test\n",
+ " - DC4_3Prime_Ligation_test\n",
+ " - DC4_5Prime_Ligation_test\n",
+ " - DC4_USER_Ligation_test\n",
+ " - DC4_5Prime_Ligation\n",
+ " - DC4_USER_Ligation\n",
+ " - DC4_ProK_digestion_37\n",
+ " - DC4_ProK_digestion_60\n",
+ " - DC4_3Prime_Ligation_Open_Close\n",
+ " - DC4_3Prime_Ligation_37\n",
+ " - DC4_5Prime_Ligation_37\n",
+ " - DC4_USER_Ligation_37\n",
+ " - Digestion_test_10min\n",
+ " - 4-25\n",
+ " - 25-4\n",
+ " - PCR\n",
+ " - 95-4\n",
+ " - DNB\n",
+ " - 37-30min\n",
+ " - 30-10min\n",
+ " - 30-20min\n",
+ " - Nifty_ER\n",
+ " - Nifty_Ad Ligation\n",
+ " - Nifty_PCR\n",
+ "PreMethods (setup-only, e.g. set temperature):\n",
+ " - PRE25\n",
+ " - PRE25LID50\n",
+ " - PRE55\n",
+ " - PREDT\n",
+ " - Pre_25\n",
+ " - Pre25\n",
+ " - Pre_25_test\n",
+ " - Pre_60\n",
+ " - Pre_4\n",
+ " - dude\n",
+ " - START\n",
+ " - EVOPLUS_Init_4C\n",
+ " - EVOPLUS_Init_110C\n",
+ " - EVOPLUS_Init_Block20CLid85C\n",
+ " - EVOPLUS_Init_Block20CLid40C\n",
+ "\n",
+ "Example stored protocol (full structure and steps):\n",
+ "ODTCProtocol(name='plr_currentProtocol', kind='method')\n",
+ " 3 step(s)\n",
+ " step 1: 37.0°C hold 10.0s\n",
+ " step 2: 60.0°C hold 10.0s\n",
+ " step 3: 10.0°C hold 10.0s\n",
+ " start_block_temperature=37.0°C\n",
+ " start_lid_temperature=110.0°C\n",
+ " variant=960000\n"
+ ]
+ }
+ ],
+ "source": [
+ "protocol_list = await tc.backend.list_protocols()\n",
+ "# Print methods and premethods in clear sections\n",
+ "print(protocol_list)\n",
+ "\n",
+ "# Iteration and .all still work: for name in protocol_list, protocol_list.all\n",
+ "# Optional: inspect a runnable method (get_protocol returns None for premethods)\n",
+ "# ODTCProtocol subclasses Protocol; print(odtc) shows full structure and steps\n",
+ "if protocol_list.all:\n",
+ " first_name = protocol_list.methods[0] if protocol_list.methods else protocol_list.premethods[0]\n",
+ " fetched_protocol = await tc.backend.get_protocol(first_name)\n",
+ " if fetched_protocol is not None:\n",
+ " print(\"\\nExample stored protocol (full structure and steps):\")\n",
+ " print(fetched_protocol)\n",
+ " # Roundtrip: run with same ODTC config via run_protocol(odtc, block_max_volume)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 3. Lid (door) commands\n",
+ "\n",
+ "The Thermocycler API uses **`open_lid`** / **`close_lid`** (ODTC device calls this the door). Door open/close use a 60 s estimated duration and corresponding timeout. Use **`wait=False`** to get an execution handle and avoid blocking; then **`await handle.wait()`** or **`await handle`** when you need to wait. For method runs, progress is logged every **progress_log_interval** (default 150 s) while you await. Omit `wait=False` to block until the command finishes."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "**Waiting:** `await handle.wait()` or `await handle` (same). For method/protocol runs, **`await tc.wait_for_profile_completion(poll_interval=..., timeout=...)`** uses polling and supports a timeout."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Close started (request_id=329628599)\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Non-blocking: returns execution handle (ODTCExecution); await handle.wait() when you need to wait\n",
+ "door_handle = await tc.close_lid(wait=False)\n",
+ "print(f\"Close started (request_id={door_handle.request_id})\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 4. Block temperature and protocol\n",
+ "\n",
+ "ODTC has no direct “set block temp” command; **`set_block_temperature`** uploads and runs a PreMethod. Use **`wait=False`** to get a handle; **`run_protocol(protocol, block_max_volume, config=...)`** is always non-blocking — await the returned handle or **`tc.wait_for_profile_completion()`** to block. When you **await** a method or premethod handle, progress is reported every **progress_log_interval** (default 150 s); for premethods the **target** shown is the premethod's target (e.g. 37°C), not the device's ramp setpoint. Use **`config=tc.backend.get_default_config(post_heating=True)`** to hold temperatures after the method ends."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "2026-02-15 00:41:10,960 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - [2026-02-15 00:41:10] Waiting for command\n",
+ " Command: CloseDoor\n",
+ " Duration (timeout): 120.0s\n",
+ " Remaining: 120s\n",
+ "2026-02-15 00:41:18,952 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - MethodSet XML saved to: debug_set_mount_temp.xml\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Door closed.\n",
+ "Block: 28.1 °C Lid: 60.9 °C\n"
+ ]
+ }
+ ],
+ "source": [
+ "await door_handle.wait()\n",
+ "print(\"Door closed.\")\n",
+ "\n",
+ "# set_block_temperature runs a premethod; wait=False returns execution handle (ODTCExecution)\n",
+ "# Override: debug_xml=True, xml_output_path=\"out.xml\" to save generated MethodSet XML\n",
+ "mount_handle = await tc.set_block_temperature([37.0],\n",
+ " wait=False,\n",
+ " debug_xml=True,\n",
+ " xml_output_path=\"debug_set_mount_temp.xml\"\n",
+ " )\n",
+ "block = await tc.get_block_current_temperature()\n",
+ "lid = await tc.get_lid_current_temperature()\n",
+ "print(f\"Block: {block[0]:.1f} °C Lid: {lid[0]:.1f} °C\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "2026-02-15 00:41:41,819 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - [2026-02-15 00:41:41] Waiting for command\n",
+ " Command: plr_currentProtocol (ExecuteMethod)\n",
+ " Duration (timeout): 655.374s\n",
+ " Remaining: 648s\n",
+ "2026-02-15 00:41:41,820 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - ODTC progress: elapsed 5s, step 1/1, cycle 1/1, setpoint 37.0°C, block 27.6°C, lid 59.7°C\n",
+ "2026-02-15 00:44:11,825 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - ODTC progress: elapsed 152s, step 1/1, cycle 1/1, setpoint 37.0°C, block 37.0°C, lid 109.5°C\n",
+ "2026-02-15 00:46:41,831 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - ODTC progress: elapsed 305s, step 1/1, cycle 1/1, setpoint 37.0°C, block 37.0°C, lid 109.6°C\n",
+ "2026-02-15 00:49:11,836 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - ODTC progress: elapsed 452s, step 1/1, cycle 1/1, setpoint 37.0°C, block 37.0°C, lid 109.9°C\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Protocol started (request_id=462796715)\n",
+ "Block: 37.0 °C Lid: 110.2 °C\n"
+ ]
+ }
+ ],
+ "source": [
+ "from pylabrobot.thermocycling.standard import Protocol, Stage, Step\n",
+ "\n",
+ "# Wait for set_block_temperature (previous cell) to finish before starting a protocol\n",
+ "await mount_handle\n",
+ "\n",
+ "# run_protocol is always non-blocking; returns execution handle (ODTCExecution). To block: await handle.wait() or tc.wait_for_profile_completion()\n",
+ "config = tc.backend.get_default_config(post_heating=False) # if True: hold temps after method ends\n",
+ "cycle_protocol = Protocol(stages=[\n",
+ " Stage(steps=[\n",
+ " Step(temperature=[37.0], hold_seconds=10.0),\n",
+ " Step(temperature=[60.0], hold_seconds=10.0),\n",
+ " Step(temperature=[10.0], hold_seconds=10.0),\n",
+ " ], repeats=1)\n",
+ "])\n",
+ "execution = await tc.run_protocol(cycle_protocol, 50.0, config=config)\n",
+ "# Override: run_stored_protocol(\"MethodName\") to run a device-stored method by name\n",
+ "print(f\"Protocol started (request_id={execution.request_id})\")\n",
+ "block, lid = await tc.get_block_current_temperature(), await tc.get_lid_current_temperature()\n",
+ "print(f\"Block: {block[0]:.1f} °C Lid: {lid[0]:.1f} °C\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "**Status and progress during a protocol run** — When you **await execution** (or **await execution.wait()**), progress is logged every **progress_log_interval** (default 150 s) from the latest DataEvent. **`get_progress_snapshot()`** is the single readout for progress during a run: it returns **ODTCProgress** (elapsed_s, current_temp_c, target_temp_c, lid_temp_c; step/cycle/hold when protocol is registered). Poll with **`is_profile_running()`** and **`get_progress_snapshot()`**. For a direct sensor read outside a run, use **`get_block_current_temperature()`** / **`get_lid_current_temperature()`**. Run the cell below after starting the protocol to poll manually."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 4b. Logging (reference)\n",
+ "\n",
+ "Progress and DataEvents use the same log file (section 0). For raw DataEvent JSONL, set **`tc.backend.data_event_log_path`** before a run (e.g. in section 1)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Poll 1: ODTC progress: elapsed 4s, step 1/3, cycle 1/1, setpoint 37.0°C, block 37.0°C, lid 110.2°C\n",
+ "Poll 2: ODTC progress: elapsed 9s, step 1/3, cycle 1/1, setpoint 37.0°C, block 37.0°C, lid 110.2°C\n",
+ "Poll 3: ODTC progress: elapsed 14s, step 2/3, cycle 1/1, setpoint 55.0°C, block 54.9°C, lid 110.1°C\n",
+ "Poll 4: ODTC progress: elapsed 19s, step 2/3, cycle 1/1, setpoint 60.0°C, block 60.3°C, lid 110.1°C\n",
+ "Poll 5: ODTC progress: elapsed 25s, step 2/3, cycle 1/1, setpoint 60.0°C, block 60.1°C, lid 110.1°C\n",
+ "Poll 6: ODTC progress: elapsed 30s, step 3/3, cycle 1/1, setpoint 50.5°C, block 51.6°C, lid 110.1°C\n",
+ "Poll 7: ODTC progress: elapsed 35s, step 3/3, cycle 1/1, setpoint 39.5°C, block 40.8°C, lid 110.0°C\n",
+ "Poll 8: ODTC progress: elapsed 40s, step 3/3, cycle 1/1, setpoint 28.3°C, block 31.9°C, lid 109.9°C\n",
+ "Poll 9: ODTC progress: elapsed 45s, step 3/3, cycle 1/1, setpoint 16.9°C, block 24.4°C, lid 109.8°C\n",
+ "Poll 10: ODTC progress: elapsed 50s, step 3/3, cycle 1/1, setpoint 10.0°C, block 18.6°C, lid 109.7°C\n",
+ "Still running after 6 polls; use await execution.wait() or tc.wait_for_profile_completion() to block until done.\n"
+ ]
+ }
+ ],
+ "source": [
+ "import asyncio\n",
+ "\n",
+ "# Poll status a few times while the protocol runs (run this cell after starting the protocol)\n",
+ "# get_progress_snapshot() returns ODTCProgress; print(snap) uses __str__ (same as progress logged every 150 s)\n",
+ "for poll in range(10):\n",
+ " running = await tc.is_profile_running()\n",
+ " if not running:\n",
+ " print(f\"Poll {poll + 1}: profile no longer running.\")\n",
+ " break\n",
+ " snap = await tc.get_progress_snapshot()\n",
+ " if snap:\n",
+ " print(f\"Poll {poll + 1}: {snap}\")\n",
+ " else:\n",
+ " print(f\"Poll {poll + 1}: no progress snapshot yet.\")\n",
+ " await asyncio.sleep(5)\n",
+ "else:\n",
+ " print(\"Still running after 6 polls; use await execution.wait() or tc.wait_for_profile_completion() to block until done.\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 5. Wait, open lid, disconnect\n",
+ "\n",
+ "Await protocol completion, open the lid (non-blocking then wait), then **`tc.stop()`** to close the connection."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "2026-02-15 00:50:34,070 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - [2026-02-15 00:50:34] Waiting for command\n",
+ " Command: plr_currentProtocol (ExecuteMethod)\n",
+ " Duration (timeout): 113.69154545454546s\n",
+ " Remaining: 56s\n",
+ "2026-02-15 00:50:34,072 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - ODTC progress: elapsed 55s, step 3/3, cycle 1/1, setpoint 10.0°C, block 13.9°C, lid 109.8°C\n",
+ "2026-02-15 00:50:36,788 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - [2026-02-15 00:50:36] Waiting for command\n",
+ " Command: OpenDoor\n",
+ " Duration (timeout): 120.0s\n",
+ " Remaining: 120s\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Done.\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Block until protocol done; progress is logged every 150 s (progress_log_interval) while waiting\n",
+ "# Alternatively: await tc.wait_for_profile_completion(poll_interval=..., timeout=...)\n",
+ "await execution.wait()\n",
+ "\n",
+ "open_handle = await tc.open_lid(wait=False)\n",
+ "await open_handle.wait()\n",
+ "await tc.stop()\n",
+ "print(\"Done.\")"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": ".venv",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.10.18"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/pylabrobot/thermocycling/opentrons_backend.py b/pylabrobot/thermocycling/opentrons_backend.py
index f2b444c786d..11b6f61b142 100644
--- a/pylabrobot/thermocycling/opentrons_backend.py
+++ b/pylabrobot/thermocycling/opentrons_backend.py
@@ -91,7 +91,7 @@ async def deactivate_lid(self):
"""Deactivate the lid heater."""
return thermocycler_deactivate_lid(module_id=self.opentrons_id)
- async def run_protocol(self, protocol: Protocol, block_max_volume: float):
+ async def run_protocol(self, protocol: Protocol, block_max_volume: float, **kwargs):
"""Enqueue and return immediately (no wait) the PCR profile command."""
# flatten the protocol to a list of Steps
diff --git a/pylabrobot/thermocycling/opentrons_backend_usb.py b/pylabrobot/thermocycling/opentrons_backend_usb.py
index 41daf9a002c..b8003bf5320 100644
--- a/pylabrobot/thermocycling/opentrons_backend_usb.py
+++ b/pylabrobot/thermocycling/opentrons_backend_usb.py
@@ -166,7 +166,7 @@ async def _execute_cycles(
volume=volume,
)
- async def run_protocol(self, protocol: Protocol, block_max_volume: float):
+ async def run_protocol(self, protocol: Protocol, block_max_volume: float, **kwargs):
"""Execute thermocycler protocol using similar execution logic from thermocycler.py.
Implements specific to opentrons thermocycler:
diff --git a/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py b/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py
index 2f036676ce2..349f2653303 100644
--- a/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py
+++ b/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py
@@ -927,6 +927,7 @@ async def run_protocol(
cover_enabled=True,
protocol_name: str = "PCR_Protocol",
stage_name_prefixes: Optional[List[str]] = None,
+ **kwargs,
):
assert block_id is not None, "block_id must be specified"
diff --git a/pylabrobot/thermocycling/thermocycler.py b/pylabrobot/thermocycling/thermocycler.py
index 622599d47a2..1fd3df4ad82 100644
--- a/pylabrobot/thermocycling/thermocycler.py
+++ b/pylabrobot/thermocycling/thermocycler.py
@@ -11,7 +11,11 @@
class Thermocycler(ResourceHolder, Machine):
- """Generic Thermocycler: block + lid + profile + status queries."""
+ """Generic Thermocycler: block + lid + profile + status queries.
+
+ Awaitable: ``await tc`` waits for the current profile to complete (equivalent
+ to ``await tc.wait_for_profile_completion()`` with default args).
+ """
def __init__(
self,
@@ -79,25 +83,64 @@ async def deactivate_lid(self, **backend_kwargs):
"""Turn off the lid heater."""
return await self.backend.deactivate_lid(**backend_kwargs)
- async def run_protocol(self, protocol: Protocol, block_max_volume: float, **backend_kwargs):
- """Enqueue a multi-stage temperature protocol (fire-and-forget).
+ async def run_protocol(
+ self,
+ protocol: Protocol,
+ block_max_volume: float,
+ **backend_kwargs,
+ ):
+ """Start a multi-stage temperature protocol; return an execution handle.
+
+ Always returns immediately (non-blocking). To block until the protocol
+ completes, await the handle (e.g. await handle.wait()) or use
+ wait_for_profile_completion().
Args:
protocol: Protocol object containing stages with steps and repeats.
+ Backends may accept subclasses (e.g. ODTCProtocol).
block_max_volume: Maximum block volume (µL) for safety.
- """
+ **backend_kwargs: Backend-specific options (e.g. ODTC accepts
+ config=ODTCConfig).
- num_zones = len(protocol.stages[0].steps[0].temperature)
- for stage in protocol.stages:
- for i, step in enumerate(stage.steps):
- if len(step.temperature) != num_zones:
- raise ValueError(
- f"All steps must have the same number of temperatures. "
- f"Expected {num_zones}, got {len(step.temperature)} in step {i}."
- )
+ Returns:
+ Execution handle (backend-specific), or None for backends that do not
+ return a handle. To block until done: await handle.wait() or
+ wait_for_profile_completion().
+ """
+ if protocol.stages:
+ num_zones = len(protocol.stages[0].steps[0].temperature)
+ for stage in protocol.stages:
+ for i, step in enumerate(stage.steps):
+ if len(step.temperature) != num_zones:
+ raise ValueError(
+ f"All steps must have the same number of temperatures. "
+ f"Expected {num_zones}, got {len(step.temperature)} in step {i}."
+ )
return await self.backend.run_protocol(protocol, block_max_volume, **backend_kwargs)
+ async def run_stored_protocol(
+ self,
+ name: str,
+ **backend_kwargs,
+ ):
+ """Run a stored protocol by name (backends that support it override).
+
+ Args:
+ name: Name of the stored protocol to run.
+ **backend_kwargs: Backend-specific options (e.g. wait=True for ODTC to
+ block until done before returning).
+
+ Returns:
+ Execution handle (backend-specific). To block until done, await
+ handle.wait() or use wait_for_profile_completion(); some backends
+ (e.g. ODTC) accept wait=True in backend_kwargs.
+
+ Raises:
+ NotImplementedError: If this backend does not support running stored protocols by name.
+ """
+ return await self.backend.run_stored_protocol(name, **backend_kwargs)
+
async def run_pcr_profile(
self,
denaturation_temp: List[float],
@@ -272,8 +315,21 @@ async def is_profile_running(self, **backend_kwargs) -> bool:
return True
return False
+ def __await__(self):
+ """Make the thermocycler awaitable: wait for the current profile to complete.
+
+ Equivalent to ``await self.wait_for_profile_completion()`` with default
+ poll_interval. For backends that track current execution (e.g. ODTC), this
+ uses the execution handle when available.
+ """
+ return self.wait_for_profile_completion().__await__()
+
async def wait_for_profile_completion(self, poll_interval: float = 60.0, **backend_kwargs):
- """Block until the profile finishes, polling at `poll_interval` seconds."""
+ """Block until the profile finishes, polling at `poll_interval` seconds.
+
+ ``await tc`` is equivalent to ``await tc.wait_for_profile_completion()``
+ with default args.
+ """
while await self.is_profile_running(**backend_kwargs):
await asyncio.sleep(poll_interval)