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)