diff --git a/pylabrobot/storage/inheco/scila/inheco_sila_interface.py b/pylabrobot/storage/inheco/scila/inheco_sila_interface.py index 86388ae337c..a3a99c6ec63 100644 --- a/pylabrobot/storage/inheco/scila/inheco_sila_interface.py +++ b/pylabrobot/storage/inheco/scila/inheco_sila_interface.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import datetime import http.server import logging import random @@ -11,12 +10,11 @@ import urllib.request import xml.etree.ElementTree as ET from dataclasses import dataclass -from typing import Any, Optional, Tuple +from enum import Enum +from typing import Any, Dict, List, Optional, Tuple from pylabrobot.storage.inheco.scila.soap import ( XSI, - _localname, - soap_body_payload, soap_decode, soap_encode, ) @@ -66,10 +64,34 @@ """ +SOAP_RESPONSE_ErrorEventResponse = """ + + + + 1 + Success + PT0.0005967S + 0 + + + +""" + + def _get_local_ip(machine_ip: str) -> str: + from pylabrobot.io.sila.discovery import _get_link_local_interfaces + + # Link-local (169.254.x.x): the UDP routing trick picks the wrong interface + # on multi-homed hosts. Enumerate local link-local addresses instead. + if machine_ip.startswith("169.254."): + interfaces = _get_link_local_interfaces() + if interfaces: + return interfaces[0] + raise RuntimeError(f"No link-local interface found for device at {machine_ip}") + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: - # Doesn't actually connect, just determines the route s.connect((machine_ip, 1)) local_ip: str = s.getsockname()[0] # type: ignore if local_ip is None or local_ip.startswith("127."): @@ -79,6 +101,19 @@ def _get_local_ip(machine_ip: str) -> str: return local_ip +class SiLAState(str, Enum): + """SiLA device states per specification.""" + + STARTUP = "startup" + STANDBY = "standby" + INITIALIZING = "initializing" + IDLE = "idle" + BUSY = "busy" + PAUSED = "paused" + ERRORHANDLING = "errorHandling" + INERROR = "inError" + + class SiLAError(RuntimeError): def __init__(self, code: int, message: str, command: str, details: Optional[dict] = None): self.code = code @@ -88,6 +123,13 @@ def __init__(self, code: int, message: str, command: str, details: Optional[dict super().__init__(f"Command {command} failed with code {code}: '{message}'") +class SiLATimeoutError(SiLAError): + """Command timed out: lifetime_of_execution exceeded or ResponseEvent not received.""" + + def __init__(self, message: str, command: str = ""): + super().__init__(code=0, message=message, command=command) + + class InhecoSiLAInterface: @dataclass(frozen=True) class _HTTPRequest: @@ -113,11 +155,14 @@ def __init__( self._machine_ip = machine_ip self._logger = logger or logging.getLogger(__name__) - # single "in-flight token" - self._making_request = asyncio.Lock() + # pending commands by request_id (supports multiple in-flight) + self._pending_by_id: Dict[int, InhecoSiLAInterface._SiLACommand] = {} - # pending command information - self._pending: Optional[InhecoSiLAInterface._SiLACommand] = None + # lock state + self._lock_id: Optional[str] = None + + # DataEvent storage by request_id + self._data_events_by_request_id: Dict[int, List[Dict[str, Any]]] = {} # server plumbing self._loop: Optional[asyncio.AbstractEventLoop] = None @@ -173,12 +218,10 @@ def _do(self) -> None: fut = asyncio.run_coroutine_threadsafe(outer._on_http(req), outer._loop) try: resp_body = fut.result() - status = 200 - except Exception as e: - resp_body = f"Internal Server Error: {type(e).__name__}: {e}\n".encode() - status = 500 + except Exception: + resp_body = SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") - self.send_response(status) + self.send_response(200) self.send_header("Content-Type", "text/xml; charset=utf-8") self.send_header("Content-Length", str(len(resp_body))) self.end_headers() @@ -219,59 +262,121 @@ async def close(self) -> None: self._httpd = None self._server_task = None - async def _on_http(self, req: _HTTPRequest) -> bytes: - """ - Called on the asyncio loop for every incoming HTTP request. - If there's a pending command, try to match and resolve it. - """ - - cmd = self._pending + def _complete_pending( + self, + request_id: int, + result: Any = None, + exception: Optional[BaseException] = None, + ) -> None: + """Pop pending command by request_id and resolve its future.""" + pending = self._pending_by_id.pop(request_id, None) + if pending is None or pending.fut.done(): + return + if exception is not None: + pending.fut.set_exception(exception) + else: + pending.fut.set_result(result) + async def _on_http(self, req: _HTTPRequest) -> bytes: + """Dispatch incoming device events to handler methods.""" try: - xml_str = req.body.decode("utf-8") - payload = soap_body_payload(xml_str) - tag_local = _localname(payload.tag) - - if cmd is not None and not cmd.fut.done() and tag_local == "ResponseEvent": - response_event = soap_decode(xml_str) - if response_event["ResponseEvent"].get("requestId") == cmd.request_id: - ret = response_event["ResponseEvent"].get("returnValue", {}) - rc = ret.get("returnCode") - if rc != 3: # 3=Success - cmd.fut.set_exception( - SiLAError(rc, ret.get("message", "").replace(chr(10), " "), cmd.name, details=ret) - ) - else: - cmd.fut.set_result( - ET.fromstring(d) - if (d := response_event["ResponseEvent"].get("responseData")) - else ET.Element("EmptyResponse") - ) - - if tag_local == "DataEvent": - try: - raw = next(e.text for e in payload.iter() if _localname(e.tag) == "dataValue") - any_data_elem = ET.fromstring(raw).find(".//AnyData") # type: ignore[arg-type] - assert any_data_elem is not None and any_data_elem.text is not None - series = ET.fromstring(any_data_elem.text).findall(".//dataSeries") - data = {} - for s in series: - val = s.findall(".//integerValue")[-1].text - unit = s.get("unit") - data[s.get("nameId")] = f"{val} {unit}" if unit else val - print(f"[{datetime.datetime.now().strftime('%H:%M:%S.%f')[:-3]}] [SiLA DataEvent] {data}") - except Exception: - pass - return SOAP_RESPONSE_DataEventResponse.encode("utf-8") - - if tag_local == "StatusEvent": - return SOAP_RESPONSE_StatusEventResponse.encode("utf-8") + decoded = soap_decode(req.body.decode("utf-8")) + for event_type, handler, response in ( + ("ResponseEvent", self._on_response_event, SOAP_RESPONSE_ResponseEventResponse), + ("StatusEvent", self._on_status_event, SOAP_RESPONSE_StatusEventResponse), + ("DataEvent", self._on_data_event, SOAP_RESPONSE_DataEventResponse), + ("ErrorEvent", self._on_error_event, SOAP_RESPONSE_ErrorEventResponse), + ): + if event_type in decoded: + handler(decoded[event_type]) + return response.encode("utf-8") + + self._logger.warning("Unknown event type received") return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") except Exception as e: - self._logger.error(f"Error handling event: {e}") + self._logger.error(f"Error handling event: {e}\nRaw body: {req.body[:500]}") return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") + def _on_response_event(self, response_event: dict) -> None: + request_id = response_event.get("requestId") + if request_id is None: + self._logger.warning("ResponseEvent missing requestId") + return + + pending = self._pending_by_id.get(request_id) + if pending is None: + self._logger.warning(f"ResponseEvent for unknown requestId: {request_id}") + return + if pending.fut.done(): + self._logger.warning(f"ResponseEvent for already-completed requestId: {request_id}") + return + + return_value = response_event.get("returnValue", {}) + return_code = return_value.get("returnCode") + + if return_code == 3: + response_data = response_event.get("responseData", "") + if response_data and response_data.strip(): + try: + self._complete_pending(request_id, result=ET.fromstring(response_data)) + except ET.ParseError as e: + self._logger.error(f"Failed to parse ResponseEvent responseData: {e}") + self._complete_pending( + request_id, exception=RuntimeError(f"Failed to parse response data: {e}") + ) + else: + self._complete_pending(request_id, result=None) + else: + message = return_value.get("message", "") + err_msg = message.replace("\n", " ") if message else f"Unknown error (code {return_code})" + self._complete_pending( + request_id, + exception=SiLAError(return_code, err_msg, pending.name), + ) + + def _on_status_event(self, status_event: dict) -> None: + event_description = status_event.get("eventDescription", {}) + if isinstance(event_description, dict): + device_state = event_description.get("DeviceState") + elif isinstance(event_description, str) and "" in event_description: + root = ET.fromstring(event_description) + device_state = root.text if root.tag == "DeviceState" else root.findtext("DeviceState") + else: + self._logger.warning(f"StatusEvent with unparsable eventDescription: {event_description!r}") + return + if device_state: + self._logger.debug(f"StatusEvent device state: {device_state}") + + def _on_data_event(self, data_event: dict) -> None: + """Store DataEvent. Override in subclasses for additional processing.""" + request_id = data_event.get("requestId") + if request_id is None: + return + if request_id not in self._data_events_by_request_id: + self._data_events_by_request_id[request_id] = [] + self._data_events_by_request_id[request_id].append(data_event) + + def get_data_events(self, request_id: int) -> List[Dict[str, Any]]: + """Get collected DataEvents for a request_id.""" + return self._data_events_by_request_id.get(request_id, []) + + def _on_error_event(self, error_event: dict) -> None: + req_id = error_event.get("requestId") + return_value = error_event.get("returnValue", {}) + return_code = return_value.get("returnCode") + message = return_value.get("message", "") + + self._logger.error(f"ErrorEvent for requestId {req_id}: code {return_code}, message: {message}") + + err_msg = message.replace("\n", " ") if message else f"Error (code {return_code})" + if req_id is not None: + pending = self._pending_by_id.get(req_id) + if pending and not pending.fut.done(): + self._complete_pending( + req_id, exception=RuntimeError(f"Command {pending.name} error: '{err_msg}'") + ) + def _get_return_code_and_message(self, command_name: str, response: Any) -> Tuple[int, str]: resp_level = response.get(f"{command_name}Response", {}) # first level result_level = resp_level.get(f"{command_name}Result", {}) # second level @@ -280,21 +385,65 @@ def _get_return_code_and_message(self, command_name: str, response: Any) -> Tupl raise ValueError(f"returnCode not found in response for {command_name}") return return_code, result_level.get("message", "") + async def request_status(self) -> SiLAState: + """Query the device for its current state via GetStatus.""" + decoded = await self.send_command("GetStatus") + state_str = decoded.get("GetStatusResponse", {}).get("state", "") + try: + return SiLAState(state_str) + except ValueError: + for s in SiLAState: + if s.value.lower() == state_str.lower(): + return s + raise ValueError(f"Unknown device state: {state_str!r}") + + async def _handle_error_code( + self, return_code: int, message: str, command_name: str, request_id: int + ) -> None: + """Handle error return codes (called by send_command for codes other than 1, 2, 3).""" + if return_code == 4: + raise SiLAError(4, "Device is busy", command_name) + if return_code == 5: + raise SiLAError(5, "LockId mismatch", command_name) + if return_code == 6: + raise SiLAError(6, "Invalid or duplicate requestId", command_name) + if return_code == 9: + try: + state = await self.request_status() + except Exception: + state = None + msg = f"{message} (state: {state.value})" if state else message + if state == SiLAState.INERROR: + msg += ". Device requires a power cycle to recover." + raise SiLAError(9, msg, command_name) + if return_code == 11: + raise SiLAError(11, f"Invalid parameter: {message}", command_name) + if return_code == 12: + self._logger.warning(f"Command {command_name} finished with warning: {message}") + return + if return_code >= 1000: + self._handle_device_error_code(return_code, message, command_name) + return + raise SiLAError(return_code, message, command_name) + + def _handle_device_error_code(self, return_code: int, message: str, command_name: str) -> None: + """Handle device-specific return codes (1000+). Override in subclasses.""" + raise SiLAError(return_code, f"Device error: {message}", command_name) + async def setup(self) -> None: await self.start() def _make_request_id(self): return random.randint(1, 2**31 - 1) - async def send_command( - self, - command: str, - **kwargs, - ) -> Any: - if self._closed: - raise RuntimeError("Bridge is closed") + @property + def event_receiver_uri(self) -> str: + return f"http://{self._client_ip}:{self.bound_port}/" - request_id = self._make_request_id() + async def _post_command( + self, command: str, request_id: int, **kwargs: Any + ) -> Tuple[Any, int, str]: + """POST a SOAP command to the device. Returns (decoded_response, return_code, message).""" cmd_xml = soap_encode( command, {"requestId": request_id, **kwargs}, @@ -302,7 +451,6 @@ async def send_command( extra_method_xmlns={"i": XSI}, ) - # make POST request to machine url = f"http://{self._machine_ip}:8080/" req = urllib.request.Request( url=url, @@ -317,29 +465,72 @@ async def send_command( }, ) - if self._making_request.locked(): - raise RuntimeError("can't send multiple commands at the same time") + def _do_request() -> bytes: + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.read() # type: ignore - async with self._making_request: - try: + body = await asyncio.to_thread(_do_request) + decoded = soap_decode(body.decode("utf-8")) + return_code, message = self._get_return_code_and_message(command, decoded) + return decoded, return_code, message - def _do_request() -> bytes: - with urllib.request.urlopen(req) as resp: - return resp.read() # type: ignore + async def send_command_async( + self, + command: str, + **kwargs, + ) -> Tuple[asyncio.Future[Any], int]: + """Send command, return (future, request_id). Future is already resolved for sync commands.""" + if self._closed: + raise RuntimeError("Bridge is closed") - body = await asyncio.to_thread(_do_request) - return_code, message = self._get_return_code_and_message( - command, soap_decode(body.decode("utf-8")) - ) - if return_code == 1: # success - return soap_decode(body.decode("utf-8")) - elif return_code == 2: # concurrent command - fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future() - self._pending = InhecoSiLAInterface._SiLACommand( - name=command, request_id=request_id, fut=fut - ) - return await fut # wait for response to be handled in _on_http - else: - raise RuntimeError(f"command {command} failed: {return_code} {message}") - finally: - self._pending = None + if self._lock_id is not None and "lockId" not in kwargs: + kwargs["lockId"] = self._lock_id + + request_id = self._make_request_id() + decoded, return_code, message = await self._post_command(command, request_id, **kwargs) + + if return_code in (1, 3): + if return_code == 3: + self._logger.warning(f"Command {command} accepted with warning: {message}") + fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + fut.set_result(decoded) + return fut, request_id + + if return_code == 2: + fut = asyncio.get_running_loop().create_future() + self._pending_by_id[request_id] = InhecoSiLAInterface._SiLACommand( + name=command, request_id=request_id, fut=fut + ) + return fut, request_id + + await self._handle_error_code(return_code, message, command, request_id) + raise RuntimeError(f"command {command} failed: {return_code}") + + async def send_command( + self, + command: str, + timeout: Optional[float] = None, + **kwargs, + ) -> Any: + """Send command and wait for completion.""" + fut, _ = await self.send_command_async(command, **kwargs) + if timeout is None: + timeout = 60.0 + try: + return await asyncio.wait_for(fut, timeout=timeout) + except asyncio.TimeoutError: + raise SiLATimeoutError( + f"Timed out after {timeout}s waiting for ResponseEvent", command=command + ) from None + + async def lock_device(self, lock_id: str, **kwargs: Any) -> None: + """Lock the device for exclusive access.""" + await self.send_command("LockDevice", lockId=lock_id, **kwargs) + self._lock_id = lock_id + + async def unlock_device(self) -> None: + """Unlock the device.""" + if self._lock_id is None: + raise RuntimeError("Device is not locked") + await self.send_command("UnlockDevice") + self._lock_id = None diff --git a/pylabrobot/storage/inheco/scila/scila_backend.py b/pylabrobot/storage/inheco/scila/scila_backend.py index 4f046ed67ff..02b7198cc2a 100644 --- a/pylabrobot/storage/inheco/scila/scila_backend.py +++ b/pylabrobot/storage/inheco/scila/scila_backend.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Literal, Optional from pylabrobot.machines.backend import MachineBackend -from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface +from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface, SiLAState def _parse_scalar(text: Optional[str], tag: str) -> object: @@ -46,18 +46,17 @@ async def stop(self) -> None: await self._sila_interface.close() async def _reset_and_initialize(self) -> None: - event_uri = f"http://{self._sila_interface.client_ip}:{self._sila_interface.bound_port}/" await self._sila_interface.send_command( - command="Reset", deviceId="MyController", eventReceiverURI=event_uri, simulationMode=False + command="Reset", + deviceId="MyController", + eventReceiverURI=self._sila_interface.event_receiver_uri, + simulationMode=False, ) await self._sila_interface.send_command("Initialize") - async def request_status(self) -> str: - # GetStatus returns synchronously (return_code 1 = immediate dict), unlike other commands - # which return asynchronously (return_code 2 = XML via callback). - resp = await self._sila_interface.send_command("GetStatus") - return resp.get("GetStatusResponse", {}).get("state", "Unknown") # type: ignore + async def request_status(self) -> SiLAState: + return await self._sila_interface.request_status() async def request_liquid_level(self) -> str: root = await self._sila_interface.send_command("GetLiquidLevel") diff --git a/pylabrobot/storage/inheco/scila/scila_backend_tests.py b/pylabrobot/storage/inheco/scila/scila_backend_tests.py index 03cad4dd5f7..a603c20feb0 100644 --- a/pylabrobot/storage/inheco/scila/scila_backend_tests.py +++ b/pylabrobot/storage/inheco/scila/scila_backend_tests.py @@ -2,7 +2,7 @@ import xml.etree.ElementTree as ET from unittest.mock import AsyncMock, patch -from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface +from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface, SiLAState from pylabrobot.storage.inheco.scila.scila_backend import SCILABackend @@ -13,6 +13,7 @@ def setUp(self): self.mock_sila_interface = AsyncMock(spec=InhecoSiLAInterface) self.mock_sila_interface.bound_port = 80 self.mock_sila_interface.client_ip = "127.0.0.1" + self.mock_sila_interface.event_receiver_uri = "http://127.0.0.1:80/" self.MockInhecoSiLAInterface.return_value = self.mock_sila_interface self.backend = SCILABackend(scila_ip="127.0.0.1") @@ -35,10 +36,10 @@ async def test_stop(self): self.mock_sila_interface.close.assert_called_once() async def test_request_status(self): - self.mock_sila_interface.send_command.return_value = {"GetStatusResponse": {"state": "standBy"}} + self.mock_sila_interface.request_status = AsyncMock(return_value=SiLAState.STANDBY) status = await self.backend.request_status() - self.assertEqual(status, "standBy") - self.mock_sila_interface.send_command.assert_called_with("GetStatus") + self.assertEqual(status, SiLAState.STANDBY) + self.mock_sila_interface.request_status.assert_called_once() async def test_request_liquid_level(self): self.mock_sila_interface.send_command.return_value = ET.fromstring( @@ -232,5 +233,33 @@ def test_deserialize_no_client_ip(self): self.MockInhecoSiLAInterface.assert_called_with(client_ip=None, machine_ip="169.254.1.117") +class TestInhecoSiLAInterfaceLocking(unittest.IsolatedAsyncioTestCase): + """Tests for lock_device / unlock_device on InhecoSiLAInterface.""" + + def setUp(self): + self.interface = InhecoSiLAInterface(machine_ip="192.168.1.100", client_ip="127.0.0.1") + + async def test_lock_device(self): + self.interface.send_command = AsyncMock() # type: ignore[method-assign] + await self.interface.lock_device("my_lock_id") + self.interface.send_command.assert_called_once() + call_kwargs = self.interface.send_command.call_args[1] + self.assertEqual(call_kwargs["lockId"], "my_lock_id") + self.assertEqual(self.interface._lock_id, "my_lock_id") + + async def test_unlock_device(self): + self.interface._lock_id = "my_lock_id" + self.interface.send_command = AsyncMock() # type: ignore[method-assign] + await self.interface.unlock_device() + self.interface.send_command.assert_called_once_with("UnlockDevice") + self.assertIsNone(self.interface._lock_id) + + async def test_unlock_device_not_locked(self): + self.interface._lock_id = None + with self.assertRaises(RuntimeError) as cm: + await self.interface.unlock_device() + self.assertIn("not locked", str(cm.exception)) + + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/thermocycling/backend.py b/pylabrobot/thermocycling/backend.py index cf0955364ae..221e58bdab7 100644 --- a/pylabrobot/thermocycling/backend.py +++ b/pylabrobot/thermocycling/backend.py @@ -41,7 +41,11 @@ async def deactivate_lid(self): """Deactivate thermocycler lid.""" @abstractmethod - async def run_protocol(self, protocol: Protocol, block_max_volume: float): + async def run_protocol( + self, + protocol: Protocol, + block_max_volume: float, + ): """Execute thermocycler protocol run. Args: @@ -49,6 +53,19 @@ async def run_protocol(self, protocol: Protocol, block_max_volume: float): block_max_volume: Maximum block volume (µL) for safety. """ + async def run_stored_protocol(self, name: str, wait: bool = False): + """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. + + 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]: """Get the current block temperature zones in °C.""" @@ -79,20 +96,20 @@ async def get_block_status(self) -> BlockStatus: @abstractmethod async def get_hold_time(self) -> float: - """Get remaining hold time in seconds.""" + """Get remaining hold time in seconds. Return 0 when no profile is running.""" @abstractmethod async def get_current_cycle_index(self) -> int: - """Get the zero-based index of the current cycle.""" + """Get the zero-based index of the current cycle. Return 0 when no profile is running.""" @abstractmethod async def get_total_cycle_count(self) -> int: - """Get the total cycle count.""" + """Get the total cycle count. Return 0 when no profile is running.""" @abstractmethod async def get_current_step_index(self) -> int: - """Get the zero-based index of the current step within the cycle.""" + """Get the zero-based index of the current step within the cycle. Return 0 when no profile is running.""" @abstractmethod async def get_total_step_count(self) -> int: - """Get the total number of steps in the current cycle.""" + """Get the total number of steps in the current cycle. Return 0 when no profile is running.""" 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..f11087bb5d9 --- /dev/null +++ b/pylabrobot/thermocycling/inheco/README.md @@ -0,0 +1,251 @@ +# 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) → `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 + +```python +from pylabrobot.resources import Coordinate +from pylabrobot.thermocycling.inheco import ODTCBackend, ODTC_DIMENSIONS +from pylabrobot.thermocycling.thermocycler import Thermocycler + +backend = ODTCBackend(odtc_ip="192.168.1.100", variant=96) # or 384 +tc = Thermocycler( + name="odtc", + size_x=ODTC_DIMENSIONS.x, + size_y=ODTC_DIMENSIONS.y, + size_z=ODTC_DIMENSIONS.z, + backend=backend, + child_location=Coordinate(0, 0, 0), +) +await tc.setup() # HTTP event receiver + Reset + Initialize → idle +``` + +**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 ODTCBackend, ODTC_DIMENSIONS +from pylabrobot.thermocycling.inheco.odtc_model import protocol_to_odtc_protocol +from pylabrobot.thermocycling.standard import Protocol, Stage, Step +from pylabrobot.thermocycling.thermocycler import Thermocycler + +backend = ODTCBackend(odtc_ip="192.168.1.100", variant=96) +tc = Thermocycler( + name="odtc", + size_x=ODTC_DIMENSIONS.x, size_y=ODTC_DIMENSIONS.y, size_z=ODTC_DIMENSIONS.z, + backend=backend, 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 index 2831db10e54..eb67802d484 100644 --- a/pylabrobot/thermocycling/inheco/__init__.py +++ b/pylabrobot/thermocycling/inheco/__init__.py @@ -1 +1,34 @@ -from .odtc_backend import ExperimentalODTCBackend +"""Inheco ODTC thermocycler implementation. + + 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 or 384 (device codes like 960000 also accepted and normalized). +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 +from .odtc_model import ( + ODTC_DIMENSIONS, + ODTCProgress, + ODTCProtocol, + ODTCVariant, + normalize_variant, +) + +__all__ = [ + "ODTCBackend", + "ODTC_DIMENSIONS", + "ODTCProgress", + "ODTCProtocol", + "ODTCVariant", + "normalize_variant", +] diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index 84e1b69665b..6ae75341456 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -1,392 +1,832 @@ +"""ODTC backend implementing ThermocyclerBackend interface using ODTC SiLA interface.""" + +from __future__ import annotations + import asyncio -import datetime -import time +import logging import xml.etree.ElementTree as ET -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union -from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface, SiLAError from pylabrobot.thermocycling.backend import ThermocyclerBackend from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol +from .odtc_model import ( + ODTCConfig, + ODTCHardwareConstraints, + ODTCMethodSet, + ODTCProgress, + ODTCProtocol, + ODTCVariant, + ODTCSensorValues, + get_constraints, + normalize_variant, + volume_to_fluid_quantity, +) +from .odtc_protocol import ( + build_progress_from_data_event, + protocol_to_odtc_protocol, +) +from .odtc_xml import ( + method_set_to_xml, + parse_method_set, + parse_method_set_file, + parse_sensor_values, +) +from pylabrobot.storage.inheco.scila.inheco_sila_interface import SiLAState + +from .odtc_sila_interface import ( + DEFAULT_FIRST_EVENT_TIMEOUT_SECONDS, + ODTCSiLAInterface, +) + +# Buffer (seconds) added to device remaining duration (ExecuteMethod) or first_event_timeout (status commands) for timeout cap (fail faster than full lifetime). + + +# ============================================================================= +# 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: str, + _dict: Optional[Dict[str, Any]] = None, + _et_root: Optional[ET.Element] = None, + ) -> None: + self._command = command + 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: str, + ) -> "_NormalizedSiLAResponse": + """Build from send_command return value (dict for sync, ET root for async).""" + if raw is None: + return cls(command=command, _dict={}) + if isinstance(raw, dict): + return cls(command=command, _dict=raw) + return cls(command=command, _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}: 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}: 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}: 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}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} 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}") + + 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") -def _format_number(n: Any) -> str: - if n is None: - return "0" - try: - f = float(n) - return str(int(f)) if f.is_integer() else str(f) - except (ValueError, TypeError): - return str(n) - - -def _recursive_find_key(data: Any, key: str) -> Any: - if isinstance(data, dict): - if key in data: - return data[key] - for v in data.values(): - item = _recursive_find_key(v, key) - if item is not None: - return item - elif isinstance(data, list): - for v in data: - item = _recursive_find_key(v, key) - if item is not None: - return item - elif hasattr(data, "find"): - node = data.find(f".//{key}") - if node is not None: - return node.text - if str(data.tag).endswith(key): - return data.text - return None - - -class ExperimentalODTCBackend(ThermocyclerBackend): - def __init__(self, ip: str, client_ip: Optional[str] = None) -> None: - self._sila_interface = InhecoSiLAInterface(client_ip=client_ip, machine_ip=ip) - self._block_target_temp: Optional[float] = None - self._lid_target_temp: Optional[float] = None - self._current_sensors: Dict[str, float] = {} - self._temp_update_time: float = 0 - - async def setup(self) -> None: - await self._sila_interface.setup() - await self._reset_and_initialize() - - async def stop(self): - await self._sila_interface.close() - - async def _reset_and_initialize(self) -> None: - try: - event_uri = f"http://{self._sila_interface._client_ip}:{self._sila_interface.bound_port}/" - await self._sila_interface.send_command( - command="Reset", deviceId="ODTC", eventReceiverURI=event_uri, simulationMode=False + if param is None: + xml_str = ET.tostring(resp, encoding="unicode") + raise ValueError( + f"Parameter '{name}' not found in {self._command} response. " + f"Root element tag: {resp.tag}\nFull XML response:\n{xml_str}" ) - await self._sila_interface.send_command("Initialize") - except Exception as e: - print(f"Warning during ODTC initialization: {e}") - - async def _wait_for_idle(self, timeout=30): - """Wait until device state is not Busy.""" - start = time.time() - while time.time() - start < timeout: - root = await self._sila_interface.send_command("GetStatus") - st = _recursive_find_key(root, "state") - if st and st in ["idle", "standby"]: + + 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} 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}: 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}: 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}: 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 {} + + +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=384); 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 = 96, + client_ip: Optional[str] = None, + logger: Optional[logging.Logger] = None, + timeout: float = 10800.0, + 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 or 384). Device codes (960000, 384000, 3840000) + are also accepted and normalized to 96/384. + client_ip: IP address of this client (auto-detected if None). + logger: Logger instance (creates one if None). + timeout: Max seconds to wait for execute_method with wait=True. Default 3 hours. + first_event_timeout_seconds: Timeout for waiting for first DataEvent. Default 60s. + """ + super().__init__() + self._variant: ODTCVariant = normalize_variant(variant) + self._simulation_mode: bool = False + self._current_request_id: Optional[int] = None + self._current_protocol: Optional[ODTCProtocol] = None + self._timeout = timeout + self._sila = ODTCSiLAInterface( + machine_ip=odtc_ip, + client_ip=client_ip, + logger=logger, + ) + 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 variant(self) -> int: + """ODTC variant (96 or 384).""" + return self._variant + + @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_execution_state(self) -> None: + """Clear current execution state.""" + self._current_request_id = None + self._current_protocol = None + + async def setup( + self, + full: bool = True, + simulation_mode: bool = False, + max_attempts: int = 10, + 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. + + 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 - await asyncio.sleep(1) - raise RuntimeError("Timeout waiting for ODTC idle state") + 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 - # ------------------------------------------------------------------------- - # Lid - # ------------------------------------------------------------------------- + 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() + await self.reset(simulation_mode=simulation_mode) - async def open_lid(self): - await self._sila_interface.send_command("OpenDoor") + status = await self.request_status() + self.logger.info(f"GetStatus returned state: {status.value!r}") - async def close_lid(self): - await self._sila_interface.send_command("CloseDoor") + if status == SiLAState.STANDBY: + self.logger.info("Device is in standby state, calling Initialize...") + await self.initialize() - async def get_lid_open(self) -> bool: - raise NotImplementedError() + status_after_init = await self.request_status() - async def get_lid_status(self) -> LidStatus: - raise NotImplementedError() + if status_after_init == SiLAState.IDLE: + 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.value!r}." + ) + elif status == SiLAState.IDLE: + self.logger.info("Device already in idle state after Reset") + else: + raise RuntimeError( + f"Unexpected device state after Reset: {status.value!r}. " + f"Expected {SiLAState.STANDBY.value!r} or {SiLAState.IDLE.value!r}." + ) - # ------------------------------------------------------------------------- - # Temperature Helpers - # ------------------------------------------------------------------------- + async def stop(self) -> None: + """Close the ODTC device connection.""" + await self._sila.close() - async def get_sensor_data(self) -> Dict[str, float]: - """ - Get all sensor data from the device. - Returns a dictionary with keys: 'Mount', 'Mount_Monitor', 'Lid', 'Lid_Monitor', - 'Ambient', 'PCB', 'Heatsink', 'Heatsink_TEC'. - Values are in degrees Celsius. - """ - if time.time() - self._temp_update_time < 2.0 and self._current_sensors: - return self._current_sensors + 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: - root = await self._sila_interface.send_command("ReadActualTemperature") - - embedded_xml = _recursive_find_key(root, "String") - - if embedded_xml and isinstance(embedded_xml, str): - sensor_root = ET.fromstring(embedded_xml) - - data = {} - for child in sensor_root: - if child.tag and child.text: - try: - # Values are integers scaled by 100 (3700 -> 37.0 C) - data[child.tag] = float(child.text) / 100.0 - except ValueError: - pass - - self._current_sensors = data - self._temp_update_time = time.time() - return self._current_sensors - except Exception as e: - print(f"Error reading sensor data: {e}") + out["port"] = self._sila.bound_port + except RuntimeError: + # Server not started yet; omit port so deck can be serialized without connecting pass - return self._current_sensors + return out + + # ============================================================================ + # 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 request_status(self) -> SiLAState: + """Get device status state.""" + return await self._sila.request_status() + + async def initialize(self) -> None: + """Initialize the device (SiLA: standby -> idle).""" + await self._sila.send_command("Initialize") + + async def reset(self, simulation_mode: bool = False) -> None: + """Reset the device (SiLA: startup -> standby, register event receiver).""" + self._simulation_mode = simulation_mode + await self._sila.send_command( + "Reset", + deviceId="ODTC", + eventReceiverURI=self._sila.event_receiver_uri, + simulationMode=simulation_mode, + ) + self._sila._lock_id = None - async def _run_pre_method(self, block_temp: float, lid_temp: float, dynamic_time: bool = True): - """ - Define and run a PreMethod (Hold) used for setting constant temperature. - WARNING: ODTC pre-methods take 7-10 minutes to pre-warm evenly the block and lid before a run. - This command is not ideal for quick temperature changes. - dynamic_time: if True, method will complete in less than 10 minutes (like 7) - if False, command holds temp for 10 minutes before proceeding + async def get_device_identification(self) -> dict: + """Get device identification information. + + Returns: + Device identification dictionary. """ - now = datetime.datetime.now().astimezone() - method_name = f"PLR_Hold_{now.strftime('%Y%m%d_%H%M%S')}" - - methods_xml = ( - f'' - f"" - f"false" - f'' - f"{_format_number(block_temp)}" - f"{_format_number(lid_temp)}" - f"{'true' if dynamic_time else 'false'}" - f"" - f"" + resp = await self._request("GetDeviceIdentification") + result = resp.get_value( + "GetDeviceIdentificationResponse", + "GetDeviceIdentificationResult", + required=False, ) + return result if isinstance(result, dict) else {} - ps = ET.Element("ParameterSet") - pm = ET.SubElement(ps, "Parameter", name="MethodsXML") - ET.SubElement(pm, "String").text = methods_xml - params_xml = ET.tostring(ps, encoding="unicode") + async def request_temperatures(self) -> ODTCSensorValues: + """Read all temperature sensors. - await self.stop_method() - await self._wait_for_idle() + 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 - await self._sila_interface.send_command("SetParameters", paramsXML=params_xml) - await self._sila_interface.send_command("ExecuteMethod", methodName=method_name) + async def get_last_data(self) -> _NormalizedSiLAResponse: + """Get temperature trace of last executed method""" + return await self._request("GetLastData") - # ------------------------------------------------------------------------- - # Block Temperature - # ------------------------------------------------------------------------- + async def execute_method(self, protocol: ODTCProtocol, wait: bool = False) -> None: + """Execute a method or premethod on the device.""" + self._clear_execution_state() + self._current_protocol = protocol - async def set_block_temperature(self, temperature: List[float], dynamic_time: bool = True): - if not temperature: - return - self._block_target_temp = temperature[0] - lid = self._lid_target_temp if self._lid_target_temp is not None else 105.0 - await self._run_pre_method(self._block_target_temp, lid, dynamic_time=dynamic_time) + fut, request_id = await self._sila.send_command_async("ExecuteMethod", methodName=protocol.name) + self._current_request_id = request_id + fut.add_done_callback(lambda _: self._clear_execution_state()) - async def deactivate_block(self): - await self.stop_method() + if wait: + await asyncio.wait_for(fut, timeout=self._timeout) - async def get_block_current_temperature(self) -> List[float]: - temps = await self.get_sensor_data() - return [temps.get("Mount", 0.0)] + async def stop_method(self) -> None: + """Stop the currently running method (SiLA: StopMethod).""" + await self._sila.send_command("StopMethod") - async def get_block_target_temperature(self) -> List[float]: - raise NotImplementedError() + # --- Method running and completion --- - async def get_block_status(self) -> BlockStatus: - raise NotImplementedError() + async def is_method_running(self) -> bool: + """Check if a method is currently running. - # ------------------------------------------------------------------------- - # Lid Temperature - # ------------------------------------------------------------------------- + Uses GetStatus to check device state. Returns True if state is 'busy', + indicating a method execution is in progress. - async def set_lid_temperature(self, temperature: List[float], dynamic_time: bool = True): - if not temperature: - return - self._lid_target_temp = temperature[0] - block = self._block_target_temp if self._block_target_temp is not None else 25.0 - await self._run_pre_method(block, self._lid_target_temp, dynamic_time=dynamic_time) + Returns: + True if method is running (state is 'busy'), False otherwise. + """ + status = await self.request_status() + return status == SiLAState.BUSY - async def deactivate_lid(self): - raise NotImplementedError() + async def get_method_set(self) -> ODTCMethodSet: + """Get the full MethodSet from the device. - async def get_lid_current_temperature(self) -> List[float]: - temps = await self.get_sensor_data() - return [temps.get("Lid", 0.0)] + Returns: + ODTCMethodSet containing all methods and premethods. - async def get_lid_target_temperature(self) -> List[float]: - raise NotImplementedError() + 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 = method_set.get(name) + if resolved is None or resolved.kind == "premethod": + return None + return resolved - # ------------------------------------------------------------------------- - # Protocol - # ------------------------------------------------------------------------- + def get_constraints(self) -> ODTCHardwareConstraints: + """Get hardware constraints for this backend's variant. - def _generate_method_xml( + Returns: + ODTCHardwareConstraints for the current variant (96 or 384-well). + """ + return get_constraints(self._variant) + + # --- Protocol upload and run --- + + async def upload_protocol( self, - protocol: Protocol, - block_max_volume: float, - start_block_temperature: float, - start_lid_temperature: float, - post_heating: bool, - method_name: Optional[str] = None, - **kwargs, - ) -> tuple[str, str]: - if not method_name: - method_name = f"PLR_Protocol_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}" - - if block_max_volume < 30.0: - fluid_quantity = "0" - elif block_max_volume < 75.0: - fluid_quantity = "1" + protocol: ODTCProtocol, + allow_overwrite: bool = False, + debug_xml: bool = False, + xml_output_path: Optional[str] = None, + ) -> None: + """Upload an ODTCProtocol to the device.""" + if protocol.is_scratch: + allow_overwrite = True + if protocol.kind == "method": + method_set = ODTCMethodSet(methods=[protocol], premethods=[]) else: - fluid_quantity = "2" + method_set = ODTCMethodSet(methods=[], premethods=[protocol]) + await self.upload_method_set( + method_set, + allow_overwrite=allow_overwrite, + debug_xml=debug_xml, + xml_output_path=xml_output_path, + ) - # Use ISO format with timezone for strict SiLA compliance (e.g. 2026-01-06T18:39:30.503368-08:00) - now = datetime.datetime.now().astimezone() - now_str = now.isoformat() + async def run_stored_protocol(self, name: str, wait: bool = False) -> None: + """Execute a stored protocol by name.""" + method_set = await self.get_method_set() + resolved = method_set.get(name) + if resolved is None: + raise ValueError(f"Protocol '{name}' not found on device") + await self.execute_method(resolved, wait=wait) - root = ET.Element("MethodSet") - ET.SubElement(root, "DeleteAllMethods").text = "false" + 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. - method_elem = ET.SubElement( - root, "Method", methodName=method_name, creator="PyLabRobot", dateTime=now_str - ) - ET.SubElement(method_elem, "Variant").text = "960000" - ET.SubElement(method_elem, "PlateType").text = "0" - ET.SubElement(method_elem, "FluidQuantity").text = fluid_quantity + 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 = existing_method_set.get(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 = existing_method_set.get(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. - ET.SubElement(method_elem, "PostHeating").text = "true" if post_heating else "false" + 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. - ET.SubElement(method_elem, "StartBlockTemperature").text = _format_number( - start_block_temperature - ) - ET.SubElement(method_elem, "StartLidTemperature").text = _format_number(start_lid_temperature) - - # Step defaults - def_slope = _format_number(kwargs.get("slope", "4.4")) - def_os_slope1 = _format_number(kwargs.get("overshoot_slope1", "0.1")) - def_os_temp = _format_number(kwargs.get("overshoot_temperature", "0")) - def_os_time = _format_number(kwargs.get("overshoot_time", "0")) - def_os_slope2 = _format_number(kwargs.get("overshoot_slope2", "0.1")) - pid_number = _format_number(kwargs.get("pid_number", "1")) - - step_counter = 1 - for stage_idx, stage in enumerate(protocol.stages): - if not stage.steps: - continue - start_of_stage = step_counter - - for i, step in enumerate(stage.steps): - b_temp = step.temperature[0] if step.temperature else 25 - l_temp = start_lid_temperature # Keep lid at start temp, could be extended to support step-specific lid temps - duration = step.hold_seconds - s_slope = _format_number(step.rate) if step.rate is not None else def_slope - - s = ET.SubElement(method_elem, "Step") - ET.SubElement(s, "Number").text = str(step_counter) - ET.SubElement(s, "Slope").text = s_slope - ET.SubElement(s, "PlateauTemperature").text = _format_number(b_temp) - ET.SubElement(s, "PlateauTime").text = _format_number(duration) - - # OverShoot params - use defaults passed to function - ET.SubElement(s, "OverShootSlope1").text = def_os_slope1 - ET.SubElement(s, "OverShootTemperature").text = def_os_temp - ET.SubElement(s, "OverShootTime").text = def_os_time - ET.SubElement(s, "OverShootSlope2").text = def_os_slope2 - - # Loop logic on the last step of the stage - if i == len(stage.steps) - 1 and stage.repeats > 1: - ET.SubElement(s, "GotoNumber").text = str(start_of_stage) - ET.SubElement(s, "LoopNumber").text = str(stage.repeats - 1) - else: - ET.SubElement(s, "GotoNumber").text = "0" - ET.SubElement(s, "LoopNumber").text = "0" - - ET.SubElement(s, "PIDNumber").text = pid_number - ET.SubElement(s, "LidTemp").text = _format_number(l_temp) - step_counter += 1 - - # Default PID - pid_set = ET.SubElement(method_elem, "PIDSet") - pid = ET.SubElement(pid_set, "PID", number=pid_number) - defaults = { - "PHeating": "60", - "PCooling": "80", - "IHeating": "250", - "ICooling": "100", - "DHeating": "10", - "DCooling": "10", - "PLid": "100", - "ILid": "70", - } - for k, v in defaults.items(): - # Allow kwargs to override specific PID values, e.g. PHeating="70" - val = kwargs.get(k, v) - ET.SubElement(pid, k).text = _format_number(val) + 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) - xml_str = '' + ET.tostring(root, encoding="unicode") - return xml_str, method_name + async def save_method_set_to_file(self, filepath: str) -> None: + """Download methods from device and save to file. - async def run_protocol( + 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): + await self._sila.send_command("OpenDoor") + + async def close_lid(self): + await self._sila.send_command("CloseDoor") + + async def set_block_temperature( self, - protocol: Protocol, - block_max_volume: float = 20.0, - start_block_temperature: float = 25.0, - start_lid_temperature: float = 30.0, - post_heating: bool = True, - method_name: Optional[str] = None, - **kwargs, + temperature: List[float], + lid_temperature: Optional[List[float]] = None, + wait: bool = False, + debug_xml: bool = False, + xml_output_path: Optional[str] = None, ): - """ - Run a PCR protocol. + """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: - protocol: The protocol to run. - block_max_volume: Maximum block volume in microliters. - start_block_temperature: The starting block temperature in C. - start_lid_temperature: The starting lid temperature in C. - post_heating: Whether to keep last temperature after method end. - method_name: Optional name for the method on the device. - **kwargs: Additional XML parameters for the ODTC method, including: - slope, overshoot_slope1, overshoot_temperature, overshoot_time, overshoot_slope2, - pid_number, and PID parameters (PHeating, PCooling, etc.) + 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. """ + 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[0] + else: + constraints = self.get_constraints() + target_lid_temp = constraints.max_lid_temp + + protocol = ODTCProtocol( + stages=[], + kind="premethod", + target_block_temperature=block_temp, + target_lid_temperature=target_lid_temp, + ) + await self.upload_protocol( + protocol, allow_overwrite=True, debug_xml=debug_xml, xml_output_path=xml_output_path + ) + await self.execute_method(protocol, wait=wait) + + async def set_lid_temperature(self, temperature: List[float]) -> None: + """Not supported by ODTC. Use set_block_temperature(lid_temperature=...) instead.""" + raise NotImplementedError("ODTC does not support set_lid_temperature directly.") + + async def deactivate_block(self) -> None: + """Deactivate block (maps to StopMethod).""" + await self.stop_method() - method_xml, method_name = self._generate_method_xml( - protocol, - block_max_volume, - start_block_temperature, - start_lid_temperature, - post_heating, - method_name=method_name, - **kwargs, + 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, + ) -> None: + """Execute thermocycler protocol. Converts to ODTCProtocol if needed, uploads, and executes.""" + if isinstance(protocol, ODTCProtocol): + odtc_protocol = protocol + else: + fluid_quantity = ( + volume_to_fluid_quantity(block_max_volume) if 0 < block_max_volume <= 100 else 1 + ) + config = ODTCConfig(variant=self._variant, fluid_quantity=fluid_quantity) + odtc_protocol = protocol_to_odtc_protocol(protocol, config=config) + + # Set block/lid to the method's start temperatures and wait for stabilization + await self.set_block_temperature( + temperature=[odtc_protocol.start_block_temperature], + lid_temperature=[odtc_protocol.start_lid_temperature], + wait=True, ) - ps = ET.Element("ParameterSet") - pm = ET.SubElement(ps, "Parameter", name="MethodsXML") - ET.SubElement(pm, "String").text = method_xml - params_xml = ET.tostring(ps, encoding="unicode") + await self.upload_protocol(odtc_protocol, allow_overwrite=True) + await self.execute_method(odtc_protocol, wait=False) + + # --- Temperatures and lid/block status --- - print("[ODTC] Uploading MethodSet...") - await self._sila_interface.send_command("SetParameters", paramsXML=params_xml) + async def get_block_current_temperature(self) -> List[float]: + sensor_values = await self.request_temperatures() + return [sensor_values.mount] + + async def get_block_target_temperature(self) -> List[float]: + """Not supported by ODTC.""" + raise RuntimeError("ODTC does not report block target temperature.") - print(f"[ODTC] Executing method '{method_name}'") + async def get_lid_current_temperature(self) -> List[float]: + sensor_values = await self.request_temperatures() + return [sensor_values.lid] + + async def get_lid_target_temperature(self) -> List[float]: + """Not supported by ODTC.""" + raise RuntimeError("ODTC does not report lid target temperature.") + + async def get_lid_open(self) -> bool: + """Not supported by ODTC.""" + raise NotImplementedError("ODTC does not report door status.") + + 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._sila_interface.send_command("ExecuteMethod", methodName=method_name) - except SiLAError as e: - if e.code == 12: # SuccessWithWarning - print(f"[ODTC Warning] {e.message}") - else: - raise e + await self.request_temperatures() + return LidStatus.HOLDING_AT_TARGET + except Exception: + return LidStatus.IDLE - async def stop_method(self): - await self._sila_interface.send_command("StopMethod") + 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.request_temperatures() + return BlockStatus.HOLDING_AT_TARGET + except Exception: + return BlockStatus.IDLE + + # --- Progress and step/cycle (DataEvent) --- + + async def _get_progress(self, request_id: int) -> Optional[ODTCProgress]: + """Get progress from latest DataEvent. Returns None if no protocol registered.""" + if self._current_protocol is None: + return None + events = self._sila.get_data_events(request_id) + if not events: + return None + return build_progress_from_data_event(events[-1], odtc_protocol=self._current_protocol) + + async def get_progress_snapshot(self) -> Optional[ODTCProgress]: + """Get current run progress. Returns None if no method is running.""" + if self._current_request_id is None: + return None + return await self._get_progress(self._current_request_id) async def get_hold_time(self) -> float: - raise NotImplementedError() + progress = await self.get_progress_snapshot() + if progress is None: + return 0.0 + return progress.remaining_hold_s if progress.remaining_hold_s is not None else 0.0 async def get_current_cycle_index(self) -> int: - raise NotImplementedError() + progress = await self.get_progress_snapshot() + if progress is None: + return 0 + return progress.current_cycle_index if progress.current_cycle_index is not None else 0 async def get_total_cycle_count(self) -> int: - raise NotImplementedError() + progress = await self.get_progress_snapshot() + if progress is None: + return 0 + return progress.total_cycle_count if progress.total_cycle_count is not None else 0 async def get_current_step_index(self) -> int: - raise NotImplementedError() + progress = await self.get_progress_snapshot() + if progress is None: + return 0 + return progress.current_step_index if progress.current_step_index is not None else 0 async def get_total_step_count(self) -> int: - raise NotImplementedError() + progress = await self.get_progress_snapshot() + if progress is None: + return 0 + 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..c5c2eafe1df --- /dev/null +++ b/pylabrobot/thermocycling/inheco/odtc_model.py @@ -0,0 +1,605 @@ +""" +ODTC model: domain types and constants. + +Defines ODTC dataclasses (ODTCProtocol, ODTCConfig, etc.) and hardware +constants. XML serialization lives in odtc_xml.py; protocol conversion +lives in odtc_protocol.py. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Literal, + Optional, + Tuple, +) + +from pylabrobot.thermocycling.standard import Protocol, Stage, Step + +if TYPE_CHECKING: + pass # Protocol used at runtime for ODTCProtocol base + +logger = logging.getLogger(__name__) + +ODTCVariant = Literal[96, 384] + + +# ============================================================================= +# 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") + + +# ============================================================================= +# 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. + """ + + 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 + + +def get_constraints(variant: ODTCVariant) -> ODTCHardwareConstraints: + if variant == 96: + return ODTCHardwareConstraints(max_heating_slope=4.4, max_lid_temp=110.0) + if variant == 384: + return ODTCHardwareConstraints( + max_heating_slope=5.0, max_lid_temp=115.0, valid_plate_types=(0, 2) + ) + raise ValueError(f"Unknown variant {variant}. Valid: [96, 384]") + + +def normalize_variant(variant: int) -> ODTCVariant: + """Normalize variant to 96 or 384. + + Accepts well count (96, 384) or device codes (960000, 384000, 3840000). + + Args: + variant: Well count or ODTC device code. + + Returns: + 96 or 384. + + Raises: + ValueError: If variant is not recognized. + """ + if variant in (96, 960000): + return 96 + if variant in (384, 384000, 3840000): + return 384 + raise ValueError(f"Unknown variant {variant}. Expected 96, 384, 960000, 384000, or 3840000.") + + +def _variant_to_device_code(variant: ODTCVariant) -> int: + """Convert variant (96/384) to ODTC device code for XML serialization.""" + return {96: 960000, 384: 384000}[variant] + + +# ============================================================================= +# Volume / fluid quantity (ODTC domain rule: uL -> fluid_quantity code) +# ============================================================================= + + +def volume_to_fluid_quantity(volume_ul: float) -> int: + """Map volume in uL 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 uL. + """ + 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 + + +# ============================================================================= +# XML Field Metadata (defined here so dataclasses can use them; full XML +# serialization engine lives in odtc_xml.py) +# ============================================================================= + + +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/100C -> 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) + + def get(self, name: str) -> Optional[ODTCProtocol]: + """Find a method or premethod by name. Returns ODTCProtocol or None.""" + return next((p for p in self.methods + self.premethods if p.name == name), None) + + def __str__(self) -> str: + lines: List[str] = ["Methods (runnable protocols):"] + if self.methods: + for m in self.methods: + lines.append(f" - {m.name} ({len(m.steps)} steps)") + else: + lines.append(" (none)") + lines.append("PreMethods (setup-only):") + if self.premethods: + for p in self.premethods: + lines.append( + f" - {p.name} (block={p.target_block_temperature:.1f}°C," + f" lid={p.target_lid_temperature:.1f}°C)" + ) + else: + lines.append(" (none)") + return "\n".join(lines) + + +@dataclass +class ODTCSensorValues: + """Temperature sensor readings from ODTC. + + 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}" + + +# ============================================================================= +# 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(frozen=True) +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 + + """ + + # 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: ODTCVariant = 96 + 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) + + def __post_init__(self): + errors: List[str] = [] + c = get_constraints(self.variant) + + # 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 {self.variant}. " + 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 {self.variant}. 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 {self.variant}" + ) + + # 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 {self.variant}" + ) + 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 {self.variant}" + ) + + # 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)) + + +# ============================================================================= +# 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 = "plr_currentProtocol" + is_scratch: bool = True + creator: Optional[str] = None + description: Optional[str] = None + datetime: str = field(default_factory=generate_odtc_timestamp) + target_block_temperature: float = 0.0 + target_lid_temperature: float = 0.0 + variant: ODTCVariant = 96 + 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) + + +# ============================================================================= +# 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 + + 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() diff --git a/pylabrobot/thermocycling/inheco/odtc_protocol.py b/pylabrobot/thermocycling/inheco/odtc_protocol.py new file mode 100644 index 00000000000..943f47ddc55 --- /dev/null +++ b/pylabrobot/thermocycling/inheco/odtc_protocol.py @@ -0,0 +1,741 @@ +"""ODTC protocol conversion. + +Conversion between PyLabRobot Protocol and ODTC representation (ODTCProtocol), +loop analysis, step expansion, duration estimation, timeline building, and +DataEvent payload parsing. +""" + +from __future__ import annotations + +import html +import logging +import xml.etree.ElementTree as ET +from dataclasses import replace +from typing import ( + Any, + Dict, + List, + Optional, + Tuple, + cast, +) + +from pylabrobot.thermocycling.standard import Protocol, Stage, Step + +from .odtc_model import ( + PREMETHOD_ESTIMATED_DURATION_SECONDS, + ODTCConfig, + ODTCProgress, + ODTCProtocol, + ODTCStage, + ODTCStep, + ODTCStepSettings, + generate_odtc_timestamp, + get_constraints, +) + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# 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 protocol_to_odtc_protocol( + protocol: "Protocol", + config: ODTCConfig = ODTCConfig(), +) -> ODTCProtocol: + """Convert a standard Protocol to ODTCProtocol (kind='method'). + + Args: + protocol: Standard Protocol with stages and steps. + config: ODTC config for variant, fluid_quantity, slopes, etc. + + Returns: + ODTCProtocol (kind='method') ready for upload or run. Steps are authoritative; + stages=[] so the stage view is derived via odtc_protocol_to_protocol(odtc) when needed. + """ + + 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 + ) + + # Generate timestamp if not already set + resolved_datetime = config.datetime if config.datetime else generate_odtc_timestamp() + + odtc_protocol = ODTCProtocol( + kind="method", + 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=[], + ) + if config.name is not None: + odtc_protocol.name = config.name + odtc_protocol.is_scratch = False + return odtc_protocol + + +def odtc_protocol_to_protocol(odtc_protocol: ODTCProtocol) -> "Protocol": + """Convert ODTCProtocol to a Protocol view (stages built from steps).""" + from pylabrobot.thermocycling.standard import Protocol + + if odtc_protocol.kind == "method" and odtc_protocol.steps: + stages = _build_odtc_stages_from_steps(odtc_protocol.steps) + return Protocol(stages=cast(List[Stage], stages)) + return Protocol(stages=[]) + + +# ============================================================================= +# Loop Analysis and Stage/Step Conversion +# ============================================================================= + + +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_protocol: ODTCProtocol) -> int: + """Return total step count in execution order (loops expanded). Used for progress display when device does not send it.""" + if not odtc_protocol.steps: + return 0 + loops = _analyze_loop_structure(odtc_protocol.steps) + return len(_expand_step_sequence(odtc_protocol.steps, loops)) + + +def odtc_cycle_count(odtc_protocol: 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_protocol.steps: + return 0 + loops = _analyze_loop_structure(odtc_protocol.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_protocol: 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_protocol: ODTCProtocol (kind='method') with steps and start_block_temperature. + + Returns: + Estimated duration in seconds. + """ + if odtc_protocol.kind == "premethod": + return PREMETHOD_ESTIMATED_DURATION_SECONDS + if not odtc_protocol.steps: + return 0.0 + loops = _analyze_loop_structure(odtc_protocol.steps) + step_nums = _expand_step_sequence(odtc_protocol.steps, loops) + steps_by_num = {s.number: s for s in odtc_protocol.steps} + + total = 0.0 + prev_temp = odtc_protocol.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_protocol: 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_protocol.kind == "premethod": + duration = PREMETHOD_ESTIMATED_DURATION_SECONDS + setpoint = odtc_protocol.target_block_temperature + return [(0.0, duration, 0, 0, setpoint, duration)] + + if not odtc_protocol.steps: + return [] + + loops = _analyze_loop_structure(odtc_protocol.steps) + step_nums = _expand_step_sequence(odtc_protocol.steps, loops) + steps_by_num = {s.number: s for s in odtc_protocol.steps} + total_expanded = len(step_nums) + total_cycles = odtc_cycle_count(odtc_protocol) + steps_per_cycle = total_expanded // total_cycles if total_cycles > 0 else max(1, total_expanded) + + segments: List[Tuple[float, float, int, int, float, float]] = [] + t = 0.0 + prev_temp = odtc_protocol.start_block_temperature + min_slope = 0.1 + + for flat_index, step_num in enumerate(step_nums): + step = 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_protocol: 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_protocol) + if not segments: + total_steps = odtc_expanded_step_count(odtc_protocol) if odtc_protocol.steps else 0 + total_cycles = odtc_cycle_count(odtc_protocol) if odtc_protocol.steps else 1 + return { + "step_index": 0, + "cycle_index": 0, + "setpoint_c": odtc_protocol.start_block_temperature + if hasattr(odtc_protocol, "start_block_temperature") + else None, + "remaining_hold_s": 0.0, + "total_steps": total_steps, + "total_cycles": total_cycles, + } + + if odtc_protocol.kind == "method" and odtc_protocol.steps: + total_expanded = len( + _expand_step_sequence(odtc_protocol.steps, _analyze_loop_structure(odtc_protocol.steps)) + ) + total_cycles = odtc_cycle_count(odtc_protocol) + steps_per_cycle = total_expanded // total_cycles if total_cycles > 0 else total_expanded + else: + steps_per_cycle = 1 + total_cycles = 1 + + for t_start, t_end, step_index, cycle_index, setpoint_c, plateau_end_t in segments: + if elapsed_s <= t_end: + 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, + } + + +# ============================================================================= +# 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]) -> Dict[str, Any]: + """Parse a single DataEvent payload into a dict (elapsed_s, temps, etc.).""" + data_value = payload.get("dataValue") + if not data_value or not isinstance(data_value, str): + raise ValueError(f"DataEvent missing dataValue: {payload}") + outer = ET.fromstring(data_value) + any_data = outer.find(".//{*}AnyData") or outer.find(".//AnyData") + if any_data is None or any_data.text is None: + raise ValueError(f"DataEvent missing AnyData: {data_value[:200]}") + inner_xml = any_data.text.strip() + if "<" in inner_xml or ">" in inner_xml: + inner_xml = html.unescape(inner_xml) + inner = ET.fromstring(inner_xml) + elapsed_s = 0.0 + target_temp_c: Optional[float] = None + current_temp_c: Optional[float] = None + lid_temp_c: Optional[float] = None + 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, + } + + +# ============================================================================= +# Build ODTCProgress from DataEvent +# ============================================================================= + + +def build_progress_from_data_event( + payload: Dict[str, Any], + odtc_protocol: Optional[ODTCProtocol] = None, +) -> ODTCProgress: + """Build ODTCProgress from raw DataEvent payload and optional protocol.""" + parsed = _parse_data_event_payload(payload) + elapsed_s = parsed["elapsed_s"] + target_temp_c = parsed.get("target_temp_c") + current_temp_c = parsed.get("current_temp_c") + lid_temp_c = parsed.get("lid_temp_c") + step_idx = parsed.get("current_step_index") or 0 + step_count = parsed.get("total_step_count") or 0 + cycle_idx = parsed.get("current_cycle_index") or 0 + cycle_count = parsed.get("total_cycle_count") or 0 + hold_s = parsed.get("remaining_hold_s") or 0.0 + + if odtc_protocol is None: + return ODTCProgress( + elapsed_s=elapsed_s, + target_temp_c=target_temp_c, + current_temp_c=current_temp_c, + lid_temp_c=lid_temp_c, + 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=None, + remaining_duration_s=0.0, + ) + + position = _protocol_position_from_elapsed(odtc_protocol, elapsed_s) + target = target_temp_c + if odtc_protocol.kind == "premethod": + target = odtc_protocol.target_block_temperature + elif position.get("setpoint_c") is not None and target is None: + target = position["setpoint_c"] + + if odtc_protocol.kind == "premethod": + est_s: Optional[float] = PREMETHOD_ESTIMATED_DURATION_SECONDS + else: + est_s = estimate_method_duration_seconds(odtc_protocol) + rem_s = max(0.0, est_s - elapsed_s) + + return ODTCProgress( + elapsed_s=elapsed_s, + target_temp_c=target, + current_temp_c=current_temp_c, + lid_temp_c=lid_temp_c, + current_step_index=position["step_index"], + total_step_count=position.get("total_steps") or 0, + current_cycle_index=position["cycle_index"], + total_cycle_count=position.get("total_cycles") or 0, + remaining_hold_s=position.get("remaining_hold_s") or 0.0, + estimated_duration_s=est_s, + remaining_duration_s=rem_s, + ) diff --git a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py new file mode 100644 index 00000000000..5bb705ce482 --- /dev/null +++ b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py @@ -0,0 +1,219 @@ +"""ODTC-specific SiLA interface with parallelism, lockId validation, and return code handling. + +This module extends InhecoSiLAInterface to support ODTC-specific requirements: +- Multiple in-flight commands with parallelism enforcement +- 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 logging +import time +from typing import Any, Dict, Optional, Set + +from pylabrobot.storage.inheco.scila.inheco_sila_interface import ( + InhecoSiLAInterface, + SiLAError, + SiLAState, + SiLATimeoutError, +) + +from .odtc_protocol import build_progress_from_data_event + + +class FirstEventTimeout(SiLATimeoutError): + """No first event received within timeout (e.g. no DataEvent for ExecuteMethod).""" + + pass + + +# 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. +# Kept as module constant for backward compatibility with ODTCBackend. +POLLING_START_BUFFER: float = 10.0 + + +class ODTCSiLAInterface(InhecoSiLAInterface): + """ODTC-specific SiLA interface with parallelism, lockId validation, and return code handling. + + Extends InhecoSiLAInterface to support: + - Multiple in-flight commands with parallelism enforcement per ODTC doc section 3 + - 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 (reference only; device enforces this) + STATE_ALLOWABILITY: Dict[str, Set[SiLAState]] = { + "Abort": {SiLAState.IDLE, SiLAState.BUSY}, + "CloseDoor": {SiLAState.IDLE, SiLAState.BUSY}, + "DoContinue": {SiLAState.IDLE, SiLAState.BUSY}, + "ExecuteMethod": {SiLAState.IDLE, SiLAState.BUSY}, + "GetConfiguration": {SiLAState.STANDBY}, + "GetParameters": {SiLAState.IDLE, SiLAState.BUSY}, + "GetDeviceIdentification": { + SiLAState.STARTUP, + SiLAState.STANDBY, + SiLAState.INITIALIZING, + SiLAState.IDLE, + SiLAState.BUSY, + SiLAState.ERRORHANDLING, + SiLAState.INERROR, + }, + "GetLastData": {SiLAState.IDLE, SiLAState.BUSY}, + "GetStatus": set(SiLAState), + "Initialize": {SiLAState.STANDBY}, + "LockDevice": {SiLAState.STANDBY}, + "OpenDoor": {SiLAState.IDLE, SiLAState.BUSY}, + "Pause": {SiLAState.IDLE, SiLAState.BUSY}, + "ReadActualTemperature": {SiLAState.IDLE, SiLAState.BUSY}, + "Reset": set(SiLAState), + "SetConfiguration": {SiLAState.STANDBY}, + "SetParameters": {SiLAState.STANDBY, SiLAState.IDLE, SiLAState.BUSY}, + "StopMethod": {SiLAState.IDLE, SiLAState.BUSY}, + "UnlockDevice": {SiLAState.STANDBY}, + } + + # Device-specific return codes that indicate DeviceError (InError state) + DEVICE_ERROR_CODES: Set[int] = {1000, 2000, 2001, 2007} + + def __init__( + self, + machine_ip: str, + client_ip: Optional[str] = None, + logger: Optional[logging.Logger] = None, + ) -> None: + super().__init__(machine_ip=machine_ip, client_ip=client_ip, logger=logger) + + async def wait_for_first_data_event( + self, + request_id: int, + timeout_seconds: float, + ) -> Optional[Dict[str, Any]]: + """Wait for the first DataEvent for this request_id, or raise on timeout.""" + 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 _handle_device_error_code(self, return_code: int, message: str, command_name: str) -> None: + """Handle ODTC device-specific return codes (1000+).""" + if return_code in self.DEVICE_ERROR_CODES: + raise SiLAError(return_code, f"Device error: {message}", command_name) + # Warning or recoverable error + self._logger.warning( + f"Command {command_name} returned device-specific code {return_code}: {message}" + ) + + def _on_data_event(self, data_event: dict) -> None: + super()._on_data_event(data_event) + progress = build_progress_from_data_event(data_event, None) + self._logger.debug( + "DataEvent requestId %s: elapsed %.0fs, block %.1f°C, target %.1f°C, lid %.1f°C", + data_event.get("requestId"), + progress.elapsed_s, + progress.current_temp_c or 0.0, + progress.target_temp_c or 0.0, + progress.lid_temp_c or 0.0, + ) diff --git a/pylabrobot/thermocycling/inheco/odtc_tests.py b/pylabrobot/thermocycling/inheco/odtc_tests.py new file mode 100644 index 00000000000..48ba4759c21 --- /dev/null +++ b/pylabrobot/thermocycling/inheco/odtc_tests.py @@ -0,0 +1,970 @@ +"""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.thermocycling.inheco.odtc_backend import ODTCBackend +from pylabrobot.thermocycling.inheco.odtc_model import ( + PREMETHOD_ESTIMATED_DURATION_SECONDS, + ODTCMethodSet, + ODTCProgress, + ODTCProtocol, + ODTCStage, + ODTCStep, +) +from pylabrobot.thermocycling.inheco.odtc_protocol import ( + build_progress_from_data_event, + estimate_method_duration_seconds, + odtc_protocol_to_protocol, +) +from pylabrobot.thermocycling.inheco.odtc_xml import ( + method_set_to_xml, + parse_method_set, +) +from pylabrobot.storage.inheco.scila.inheco_sila_interface import SiLAState +from pylabrobot.thermocycling.inheco.odtc_sila_interface import ( + ODTCSiLAInterface, +) + + +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 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 = build_progress_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 build_progress_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 = build_progress_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 = build_progress_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 = build_progress_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 = build_progress_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 = build_progress_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 = build_progress_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 = build_progress_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 = build_progress_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) + + +class TestODTCProgress(unittest.TestCase): + """Tests for build_progress_from_data_event and format_progress_log_message.""" + + 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 = build_progress_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_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 = build_progress_from_data_event(payload, odtc_protocol=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 = build_progress_from_data_event(payload, odtc_protocol=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") + + async def test_handle_error_code(self): + """Test return code handling (codes 1, 2, 3 are handled by send_command, not _handle_error_code).""" + # Code 4 should raise + with self.assertRaises(RuntimeError): + await self.interface._handle_error_code(4, "busy", "ExecuteMethod", 123) + + # Code 5 should raise + with self.assertRaises(RuntimeError): + await self.interface._handle_error_code(5, "LockId error", "ExecuteMethod", 123) + + # Code 9 should raise with state info + self.interface.request_status = AsyncMock(return_value=SiLAState.IDLE) # type: ignore[method-assign] + with self.assertRaises(RuntimeError) as cm: + await self.interface._handle_error_code(9, "Not allowed", "ExecuteMethod", 123) + self.assertIn("idle", str(cm.exception)) + + # Code 9 with inError should hint power cycle + self.interface.request_status = AsyncMock(return_value=SiLAState.INERROR) # type: ignore[method-assign] + with self.assertRaises(RuntimeError) as cm: + await self.interface._handle_error_code(9, "Not allowed", "ExecuteMethod", 123) + self.assertIn("power cycle", str(cm.exception)) + + # Device error code should raise + with self.assertRaises(RuntimeError): + await self.interface._handle_error_code(1000, "Device error", "ExecuteMethod", 123) + + +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._client_ip = "127.0.0.1" + self.backend._sila.event_receiver_uri = "http://127.0.0.1:8080/" + + 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 96).""" + self.assertEqual(self.backend.variant, 96) + + 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.request_status = AsyncMock(return_value=SiLAState.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.request_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.request_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.request_status = AsyncMock(return_value=SiLAState.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.""" + self.backend._sila.send_command = AsyncMock(return_value=None) # 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 retries when reset fails transiently.""" + self.backend._sila.setup = AsyncMock() # type: ignore[method-assign] + self.backend.request_status = AsyncMock(return_value=SiLAState.IDLE) # type: ignore[method-assign] + reset_count = 0 + + async def mock_reset(**kwargs): + nonlocal reset_count + reset_count += 1 + if reset_count < 3: + raise RuntimeError("transient") + + self.backend.reset = AsyncMock(side_effect=mock_reset) # type: ignore[method-assign] + with patch("asyncio.sleep", new_callable=AsyncMock): + await self.backend.setup(full=True, max_attempts=3) + self.assertEqual(reset_count, 3) + self.assertEqual(self.backend._sila.setup.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.request_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_request_status(self): + """Test request_status.""" + self.backend._sila.request_status = AsyncMock(return_value=SiLAState.IDLE) # type: ignore[method-assign] + status = await self.backend.request_status() + self.assertEqual(status, SiLAState.IDLE) + self.backend._sila.request_status.assert_called_once() + + async def test_open_lid(self): + self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign] + await self.backend.open_lid() + self.backend._sila.send_command.assert_called_once_with("OpenDoor") + + async def test_close_lid(self): + self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign] + await self.backend.close_lid() + 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.request_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 sends ExecuteMethod via send_command_async.""" + fut: asyncio.Future[Any] = asyncio.Future() + fut.set_result(None) + self.backend._sila.send_command_async = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] + protocol = ODTCProtocol(kind="method", name="MyMethod", stages=[]) + await self.backend.execute_method(protocol, wait=True) + self.backend._sila.send_command_async.assert_called_once() + call_kwargs = self.backend._sila.send_command_async.call_args[1] + self.assertEqual(call_kwargs["methodName"], "MyMethod") + + 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_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._current_request_id = request_id + self.backend._current_protocol = premethod + self.backend._sila.get_data_events = MagicMock( # type: ignore[method-assign] + return_value=[_data_event_payload_with_elapsed(100.0, request_id)] + ) + 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.target_temp_c, 37.0) + + 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._current_request_id = request_id + self.backend._current_protocol = premethod + self.backend._sila.get_data_events = MagicMock( # type: ignore[method-assign] + return_value=[_data_event_payload_with_elapsed(50.0, request_id)] + ) + 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) + + async def test_execute_method_premethod_stores_protocol(self): + """When executing a premethod, _current_protocol is set.""" + premethod = ODTCProtocol( + kind="premethod", + name="Pre37", + target_block_temperature=37.0, + stages=[], + ) + fut: asyncio.Future[Any] = asyncio.Future() + fut.set_result(None) + self.backend._sila.send_command_async = AsyncMock(return_value=(fut, 99999)) # type: ignore[method-assign] + await self.backend.execute_method(premethod, wait=False) + self.assertEqual(self.backend._current_protocol, premethod) + + async def test_is_method_running(self): + """Test is_method_running().""" + with patch.object( + ODTCBackend, "request_status", new_callable=AsyncMock, return_value=SiLAState.BUSY + ): + self.assertTrue(await self.backend.is_method_running()) + + with patch.object( + ODTCBackend, "request_status", new_callable=AsyncMock, return_value=SiLAState.IDLE + ): + self.assertFalse(await self.backend.is_method_running()) + + async def test_get_data_events(self): + """Test get_data_events on SiLA interface.""" + events = {12345: [{"requestId": 12345, "data": "test1"}, {"requestId": 12345, "data": "test2"}]} + self.backend._sila.get_data_events = MagicMock(side_effect=lambda rid: events.get(rid, [])) # type: ignore[method-assign] + self.assertEqual(len(self.backend._sila.get_data_events(12345)), 2) + self.assertEqual(self.backend._sila.get_data_events(99999), []) + + 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 resolved protocol.""" + method = ODTCProtocol(kind="method", name="MyMethod", stages=[]) + method_set = ODTCMethodSet(methods=[method]) + self.backend.execute_method = AsyncMock(return_value=None) # type: ignore[method-assign] + self.backend.get_method_set = AsyncMock(return_value=method_set) # type: ignore[method-assign] + await self.backend.run_stored_protocol("MyMethod", wait=True) + self.backend.execute_method.assert_called_once_with(method, wait=True) + + +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=96, + 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_tutorial.ipynb b/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb new file mode 100644 index 00000000000..62be6489b18 --- /dev/null +++ b/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb @@ -0,0 +1,507 @@ +{ + "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(\n", + " f\"DataEvent summaries go to the same log; set tc.backend.data_event_log_path = '{odtc_data_events_file}' for raw JSONL (optional)\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 1. Imports and thermocycler\n\nCreate a `Thermocycler` with `ODTCBackend`. Use `ODTC_DIMENSIONS` for the standard footprint." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "import logging\n\nfrom pylabrobot.resources import Coordinate\nfrom pylabrobot.thermocycling.inheco import ODTCBackend, ODTC_DIMENSIONS\nfrom pylabrobot.thermocycling.thermocycler import Thermocycler\n\nbackend = ODTCBackend(odtc_ip=\"192.168.1.50\", variant=96)\ntc = Thermocycler(\n name=\"odtc_test\",\n size_x=ODTC_DIMENSIONS.x, size_y=ODTC_DIMENSIONS.y, size_z=ODTC_DIMENSIONS.z,\n backend=backend,\n child_location=Coordinate.zero(),\n)\n# Optional: raw DataEvent JSONL (path from section 0)\ntc.backend.data_event_log_path = odtc_data_events_file" + }, + { + "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": "stdout", + "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)" + ] + }, + { + "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": "stdout", + "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(\n", + " [37.0], wait=False, debug_xml=True, 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": "stdout", + "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(\n", + " stages=[\n", + " Stage(\n", + " 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", + " ],\n", + " repeats=1,\n", + " )\n", + " ]\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\")" + ] + }, + { + "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(\n", + " \"Still running after 6 polls; use await execution.wait() or tc.wait_for_profile_completion() to block until done.\"\n", + " )" + ] + }, + { + "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": "stdout", + "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 +} \ No newline at end of file diff --git a/pylabrobot/thermocycling/inheco/odtc_xml.py b/pylabrobot/thermocycling/inheco/odtc_xml.py new file mode 100644 index 00000000000..1773053e6dd --- /dev/null +++ b/pylabrobot/thermocycling/inheco/odtc_xml.py @@ -0,0 +1,428 @@ +"""ODTC XML serialization and parsing. + +Schema-driven XML serialization for MethodSet, SensorValues, and related +ODTC dataclasses. Handles Method and PreMethod XML elements and round-trip +serialization of ODTCProtocol. +""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET +from dataclasses import fields +from typing import ( + Any, + Dict, + List, + Optional, + Type, + TypeVar, + Union, + cast, + get_args, + get_origin, + get_type_hints, +) + + +from pylabrobot.thermocycling.standard import Step + +from .odtc_model import ( + ODTCPID, + ODTCMethodSet, + ODTCProtocol, + ODTCSensorValues, + ODTCStage, + ODTCStep, + XMLField, + XMLFieldType, + _variant_to_device_code, + normalize_variant, +) +from .odtc_protocol import _odtc_stages_to_steps + +T = TypeVar("T") + + +# ============================================================================= +# 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_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 = elem.attrib["methodName"] + creator = elem.attrib.get("creator") + description = elem.attrib.get("description") + datetime_ = elem.attrib["dateTime"] + variant = normalize_variant(int(float(_read_opt_elem(elem, "Variant") or 960000))) + plate_type = int(float(_read_opt_elem(elem, "PlateType") or 0)) + fluid_quantity = int(float(_read_opt_elem(elem, "FluidQuantity") or 0)) + post_heating = (_read_opt_elem(elem, "PostHeating") or "false").lower() == "true" + start_block_temperature = float(_read_opt_elem(elem, "StartBlockTemperature") or 0.0) + start_lid_temperature = float(_read_opt_elem(elem, "StartLidTemperature") or 0.0) + 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, + is_scratch=False, + creator=creator, + description=description, + datetime=datetime_, + variant=variant, + plate_type=plate_type, + fluid_quantity=fluid_quantity, + post_heating=post_heating, + start_block_temperature=start_block_temperature, + start_lid_temperature=start_lid_temperature, + 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 = elem.attrib.get("methodName") or "" + creator = elem.attrib.get("creator") + description = elem.attrib.get("description") + datetime_ = elem.attrib.get("dateTime") + target_block_temperature = float(_read_opt_elem(elem, "TargetBlockTemperature") or 0.0) + target_lid_temperature = float(_read_opt_elem(elem, "TargetLidTemp") or 0.0) + return ODTCProtocol( + kind="premethod", + name=name, + is_scratch=False, + creator=creator, + description=description, + datetime=datetime_, + target_block_temperature=target_block_temperature, + target_lid_temperature=target_lid_temperature, + stages=[], + ) + + +def _get_steps_for_serialization(odtc_protocol: ODTCProtocol) -> List[ODTCStep]: + """Return canonical ODTCStep list for serializing an ODTCProtocol (kind='method'). + + Uses odtc_protocol.steps when present; otherwise builds from odtc_protocol.stages via _odtc_stages_to_steps. + """ + if odtc_protocol.steps: + return odtc_protocol.steps + if odtc_protocol.stages: + stages_as_odtc = [] + for s in odtc_protocol.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_protocol: ODTCProtocol, parent: ET.Element) -> ET.Element: + """Serialize ODTCProtocol (kind='method') to XML.""" + if odtc_protocol.kind != "method": + raise ValueError("ODTCProtocol must have kind='method' to serialize as Method") + steps_to_serialize = _get_steps_for_serialization(odtc_protocol) + elem = ET.SubElement(parent, "Method") + elem.set("methodName", odtc_protocol.name) + if odtc_protocol.creator: + elem.set("creator", odtc_protocol.creator) + if odtc_protocol.description: + elem.set("description", odtc_protocol.description) + if odtc_protocol.datetime: + elem.set("dateTime", odtc_protocol.datetime) + ET.SubElement(elem, "Variant").text = str(_variant_to_device_code(odtc_protocol.variant)) + ET.SubElement(elem, "PlateType").text = str(odtc_protocol.plate_type) + ET.SubElement(elem, "FluidQuantity").text = str(odtc_protocol.fluid_quantity) + ET.SubElement(elem, "PostHeating").text = "true" if odtc_protocol.post_heating else "false" + ET.SubElement(elem, "StartBlockTemperature").text = _format_value( + odtc_protocol.start_block_temperature + ) + ET.SubElement(elem, "StartLidTemperature").text = _format_value( + odtc_protocol.start_lid_temperature + ) + for step in steps_to_serialize: + to_xml(step, "Step", elem) + if odtc_protocol.pid_set: + pid_set_elem = ET.SubElement(elem, "PIDSet") + for pid in odtc_protocol.pid_set: + to_xml(pid, "PID", pid_set_elem) + return elem + + +def _odtc_protocol_to_premethod_xml(odtc_protocol: ODTCProtocol, parent: ET.Element) -> ET.Element: + """Serialize ODTCProtocol (kind='premethod') to XML.""" + if odtc_protocol.kind != "premethod": + raise ValueError("ODTCProtocol must have kind='premethod' to serialize as PreMethod") + elem = ET.SubElement(parent, "PreMethod") + elem.set("methodName", odtc_protocol.name) + if odtc_protocol.creator: + elem.set("creator", odtc_protocol.creator) + if odtc_protocol.description: + elem.set("description", odtc_protocol.description) + if odtc_protocol.datetime: + elem.set("dateTime", odtc_protocol.datetime) + ET.SubElement(elem, "TargetBlockTemperature").text = _format_value( + odtc_protocol.target_block_temperature + ) + ET.SubElement(elem, "TargetLidTemp").text = _format_value(odtc_protocol.target_lid_temperature) + return elem + + +# ============================================================================= +# Convenience Functions +# ============================================================================= + + +def parse_method_set_from_root(root: ET.Element) -> ODTCMethodSet: + """Parse a MethodSet 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) 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 86bab4c0e3d..67ca657e7bb 100644 --- a/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py +++ b/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py @@ -931,6 +931,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)