From 278586bbcfb7faa17ed5bb11c37b7fda8f67e72d Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sun, 25 Jan 2026 23:25:47 -0800 Subject: [PATCH 01/28] ODTC Sila, Codec, Connection Init --- pylabrobot/thermocycling/inheco/__init__.py | 6 + pylabrobot/thermocycling/inheco/odtc.py | 192 +++ .../thermocycling/inheco/odtc_backend.py | 528 +++++++++ .../inheco/odtc_backend_tests.py | 249 ++++ .../inheco/odtc_sila_interface.py | 749 ++++++++++++ pylabrobot/thermocycling/inheco/odtc_xml.py | 1042 +++++++++++++++++ 6 files changed, 2766 insertions(+) create mode 100644 pylabrobot/thermocycling/inheco/__init__.py create mode 100644 pylabrobot/thermocycling/inheco/odtc.py create mode 100644 pylabrobot/thermocycling/inheco/odtc_backend.py create mode 100644 pylabrobot/thermocycling/inheco/odtc_backend_tests.py create mode 100644 pylabrobot/thermocycling/inheco/odtc_sila_interface.py create mode 100644 pylabrobot/thermocycling/inheco/odtc_xml.py diff --git a/pylabrobot/thermocycling/inheco/__init__.py b/pylabrobot/thermocycling/inheco/__init__.py new file mode 100644 index 00000000000..3268864ad0f --- /dev/null +++ b/pylabrobot/thermocycling/inheco/__init__.py @@ -0,0 +1,6 @@ +"""Inheco ODTC thermocycler implementation.""" + +from .odtc import InhecoODTC384, InhecoODTC96 +from .odtc_backend import ODTCBackend + +__all__ = ["InhecoODTC96", "InhecoODTC384", "ODTCBackend"] diff --git a/pylabrobot/thermocycling/inheco/odtc.py b/pylabrobot/thermocycling/inheco/odtc.py new file mode 100644 index 00000000000..38727ecd9c1 --- /dev/null +++ b/pylabrobot/thermocycling/inheco/odtc.py @@ -0,0 +1,192 @@ +"""Inheco ODTC (On-Deck Thermocycler) resource class.""" + +from typing import Optional + +from pylabrobot.resources import Coordinate, ItemizedResource +from pylabrobot.thermocycling.thermocycler import Thermocycler + +from .odtc_backend import ODTCBackend + + +class InhecoODTC96(Thermocycler): + """Inheco ODTC 96-well On-Deck Thermocycler. + + The ODTC is a compact thermocycler designed for integration into liquid handling + systems. It features a motorized drawer for plate access and supports PCR protocols + via XML-defined methods. + + Approximate dimensions: + - Width (X): 147 mm + - Depth (Y): 298 mm + - Height (Z): 130 mm (with drawer closed) + + Example usage: + ```python + from pylabrobot.thermocycling.inheco import InhecoODTC96, ODTCBackend + + # Create backend and thermocycler + backend = ODTCBackend(odtc_ip="192.168.1.100") + tc = InhecoODTC96(name="odtc1", backend=backend) + + # Initialize + await tc.setup() + + # Upload and run protocols + await backend.upload_method_set_from_file("protocols.xml") + await backend.execute_method("PRE25") # Set initial temperatures + await backend.execute_method("PCR_30cycles") # Run PCR + + # Read temperatures + temp = await tc.get_block_current_temperature() + print(f"Block temperature: {temp[0]}°C") + + # Clean up + await tc.stop() + ``` + """ + + def __init__( + self, + name: str, + backend: ODTCBackend, + child_location: Coordinate = Coordinate(x=10.0, y=10.0, z=50.0), + child: Optional[ItemizedResource] = None, + ): + """Initialize the ODTC thermocycler. + + Args: + name: Human-readable name for this resource. + backend: ODTCBackend instance configured with device IP. + child_location: Position where a plate sits on the block. + Defaults to approximate center of the block area. + child: Optional plate/rack already loaded on the module. + """ + super().__init__( + name=name, + size_x=147.0, # mm - approximate width + size_y=298.0, # mm - approximate depth (includes drawer travel) + size_z=130.0, # mm - approximate height with drawer closed + backend=backend, + child_location=child_location, + category="thermocycler", + model="InhecoODTC96", + ) + + self.backend: ODTCBackend = backend + self.child = child + if child is not None: + self.assign_child_resource(child, location=child_location) + + def serialize(self) -> dict: + """Return a serialized representation of the thermocycler.""" + return { + **super().serialize(), + "odtc_ip": self.backend._sila._machine_ip, + "port": self.backend._sila.bound_port, + } + + # Convenience methods that expose ODTC-specific functionality + + async def execute_method(self, method_name: str) -> None: + """Execute a method or premethod by name. + + Args: + method_name: Name of the method or premethod to execute. + """ + await self.backend.execute_method(method_name) + + async def stop_method(self) -> None: + """Stop any currently running method.""" + await self.backend.stop_method() + + async def list_methods(self) -> tuple: + """Return (premethod_names, method_names) available on device.""" + return await self.backend.list_methods() + + async def upload_method_set_from_file(self, filepath: str) -> None: + """Load a MethodSet XML file and upload to device.""" + await self.backend.upload_method_set_from_file(filepath) + + async def save_method_set_to_file(self, filepath: str) -> None: + """Download methods from device and save to file.""" + await self.backend.save_method_set_to_file(filepath) + + async def read_temperatures(self): + """Read all temperature sensors. + + Returns: + ODTCSensorValues with temperatures in °C. + """ + return await self.backend.read_temperatures() + + +class InhecoODTC384(Thermocycler): + """Inheco ODTC 384-well On-Deck Thermocycler. + + Similar to the 96-well variant but configured for 384-well plates. + """ + + def __init__( + self, + name: str, + backend: ODTCBackend, + child_location: Coordinate = Coordinate(x=10.0, y=10.0, z=50.0), + child: Optional[ItemizedResource] = None, + ): + """Initialize the ODTC 384-well thermocycler. + + Args: + name: Human-readable name for this resource. + backend: ODTCBackend instance configured with device IP. + child_location: Position where a plate sits on the block. + child: Optional plate/rack already loaded on the module. + """ + super().__init__( + name=name, + size_x=147.0, # mm - approximate width + size_y=298.0, # mm - approximate depth + size_z=130.0, # mm - approximate height + backend=backend, + child_location=child_location, + category="thermocycler", + model="InhecoODTC384", + ) + + self.backend: ODTCBackend = backend + self.child = child + if child is not None: + self.assign_child_resource(child, location=child_location) + + def serialize(self) -> dict: + """Return a serialized representation of the thermocycler.""" + return { + **super().serialize(), + "odtc_ip": self.backend._sila._machine_ip, + "port": self.backend._sila.bound_port, + } + + # Convenience methods (same as 96-well) + + async def execute_method(self, method_name: str) -> None: + """Execute a method or premethod by name.""" + await self.backend.execute_method(method_name) + + async def stop_method(self) -> None: + """Stop any currently running method.""" + await self.backend.stop_method() + + async def list_methods(self) -> tuple: + """Return (premethod_names, method_names) available on device.""" + return await self.backend.list_methods() + + async def upload_method_set_from_file(self, filepath: str) -> None: + """Load a MethodSet XML file and upload to device.""" + await self.backend.upload_method_set_from_file(filepath) + + async def save_method_set_to_file(self, filepath: str) -> None: + """Download methods from device and save to file.""" + await self.backend.save_method_set_to_file(filepath) + + async def read_temperatures(self): + """Read all temperature sensors.""" + return await self.backend.read_temperatures() diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py new file mode 100644 index 00000000000..064d3e7eee3 --- /dev/null +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -0,0 +1,528 @@ +"""ODTC backend implementing ThermocyclerBackend interface using ODTC SiLA interface.""" + +from __future__ import annotations + +import logging +from typing import List, Optional, Tuple + +from pylabrobot.machines.backend import MachineBackend +from pylabrobot.thermocycling.backend import ThermocyclerBackend +from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol + +from .odtc_sila_interface import ODTCSiLAInterface +from .odtc_xml import ( + ODTCMethodSet, + ODTCSensorValues, + method_set_to_xml, + parse_method_set, + parse_method_set_file, + parse_sensor_values, +) + + +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. + """ + + def __init__( + self, + odtc_ip: str, + client_ip: Optional[str] = None, + logger: Optional[logging.Logger] = None, + ): + """Initialize ODTC backend. + + Args: + odtc_ip: IP address of the ODTC device. + client_ip: IP address of this client (auto-detected if None). + logger: Logger instance (creates one if None). + """ + super().__init__() + self._sila = ODTCSiLAInterface(machine_ip=odtc_ip, client_ip=client_ip, logger=logger) + self.logger = logger or logging.getLogger(__name__) + + async def setup(self) -> None: + """Initialize the ODTC device connection. + + Performs the full SiLA connection lifecycle: + 1. Sets up the HTTP event receiver server + 2. Calls Reset to move from Startup -> Standby and register event receiver + 3. Waits for Reset to complete and checks state + 4. Calls Initialize to move from Standby -> Idle + """ + # Step 1: Set up the HTTP event receiver server + await self._sila.setup() + + # Step 2: Reset (Startup -> Standby) - registers event receiver URI + # Reset is async, so we wait for it to complete + event_receiver_uri = f"http://{self._sila._client_ip}:{self._sila.bound_port}/" + await self.reset( + device_id="ODTC", + event_receiver_uri=event_receiver_uri, + simulation_mode=False, + ) + + # Step 3: Check state after Reset completes + # GetStatus is synchronous and will update our internal state tracking + status = await self.get_status() + + # Normalize state string for comparison (device returns lowercase) + status_normalized = status.lower() if status else status + + # Step 4: Initialize (Standby -> Idle) if we're in Standby + if status_normalized == "standby": + await self.initialize() + elif status_normalized == "idle": + # Already in Idle, nothing to do + self.logger.info("Device already in Idle state after Reset") + else: + raise RuntimeError( + f"Unexpected device state after Reset: {status}. Expected standby or idle." + ) + + async def stop(self) -> None: + """Close the ODTC device connection.""" + await self._sila.close() + + def serialize(self) -> dict: + """Return serialized representation of the backend.""" + return { + **super().serialize(), + "odtc_ip": self._sila._machine_ip, + "port": self._sila.bound_port, + } + + # ============================================================================ + # Basic ODTC Commands (from plan) + # ============================================================================ + + async def get_status(self) -> str: + """Get device status state. + + Returns: + Device state string (e.g., "Idle", "Busy", "Standby"). + """ + resp = await self._sila.send_command("GetStatus") + # GetStatus is synchronous - resp is a dict from soap_decode + if isinstance(resp, dict): + # Try different possible response structures + # Structure 1: GetStatusResponse -> state (like SCILABackend) + state = resp.get("GetStatusResponse", {}).get("state") + if state: + return state # type: ignore + # Structure 2: GetStatusResponse -> GetStatusResult -> state + state = resp.get("GetStatusResponse", {}).get("GetStatusResult", {}).get("state") + if state: + return state # type: ignore + # Structure 3: Direct state key + state = resp.get("state") + if state: + return state # type: ignore + # Debug: log the actual response structure to help diagnose + self.logger.debug(f"GetStatus response keys: {list(resp.keys())}") + if "GetStatusResponse" in resp: + self.logger.debug(f"GetStatusResponse keys: {list(resp['GetStatusResponse'].keys())}") + return "Unknown" + else: + # Fallback if response format is different + self.logger.warning(f"GetStatus returned non-dict response: {type(resp)}") + return "Unknown" + + async def initialize(self) -> None: + """Initialize the device (must be in Standby state).""" + await self._sila.send_command("Initialize") + + async def reset( + self, + device_id: str = "ODTC", + event_receiver_uri: Optional[str] = None, + simulation_mode: bool = False, + ) -> None: + """Reset the device. + + Args: + device_id: Device identifier. + event_receiver_uri: Event receiver URI (auto-detected if None). + simulation_mode: Enable simulation mode. + """ + if event_receiver_uri is None: + event_receiver_uri = f"http://{self._sila._client_ip}:{self._sila.bound_port}/" + await self._sila.send_command( + "Reset", + deviceId=device_id, + eventReceiverURI=event_receiver_uri, + simulationMode=simulation_mode, + ) + + async def get_device_identification(self) -> dict: + """Get device identification information. + + Returns: + Device identification dictionary. + """ + resp = await self._sila.send_command("GetDeviceIdentification") + # GetDeviceIdentification is synchronous - resp is a dict from soap_decode + if isinstance(resp, dict): + return resp.get("GetDeviceIdentificationResponse", {}).get("GetDeviceIdentificationResult", {}) # type: ignore + else: + return {} + + async def lock_device(self, lock_id: str, lock_timeout: Optional[float] = None) -> None: + """Lock the device for exclusive access. + + Args: + lock_id: Unique lock identifier. + lock_timeout: Lock timeout in seconds (optional). + """ + params: dict = {"lockId": lock_id, "PMSId": "PyLabRobot"} + if lock_timeout is not None: + params["lockTimeout"] = lock_timeout + await self._sila.send_command("LockDevice", lock_id=lock_id, **params) + + async def unlock_device(self) -> None: + """Unlock the device.""" + # Must provide the lockId that was used to lock it + if self._sila._lock_id is None: + raise RuntimeError("Device is not locked") + await self._sila.send_command("UnlockDevice", lock_id=self._sila._lock_id) + + # Door control commands + async def open_door(self) -> None: + """Open the drawer door (equivalent to PrepareForOutput).""" + await self._sila.send_command("OpenDoor") + + async def close_door(self) -> None: + """Close the drawer door (equivalent to PrepareForInput).""" + await self._sila.send_command("CloseDoor") + + async def prepare_for_output(self, position: Optional[int] = None) -> None: + """Prepare for output (equivalent to OpenDoor).""" + params = {} + if position is not None: + params["position"] = position + await self._sila.send_command("PrepareForOutput", **params) + + async def prepare_for_input(self, position: Optional[int] = None) -> None: + """Prepare for input (equivalent to CloseDoor).""" + params = {} + if position is not None: + params["position"] = position + await self._sila.send_command("PrepareForInput", **params) + + # Sensor commands + async def read_temperatures(self) -> ODTCSensorValues: + """Read all temperature sensors. + + Returns: + ODTCSensorValues with temperatures in °C. + """ + resp = await self._sila.send_command("ReadActualTemperature") + # Response is ElementTree root - find SensorValues parameter + if resp is None: + raise ValueError("Empty response from ReadActualTemperature") + + # Response structure: ResponseData/Parameter[@name='SensorValues']/String + param = resp.find(".//Parameter[@name='SensorValues']") + if param is None: + raise ValueError("SensorValues parameter not found in response") + sensor_str_elem = param.find("String") + if sensor_str_elem is None or sensor_str_elem.text is None: + raise ValueError("SensorValues String element not found") + # Parse the XML string (it's escaped in the response) + sensor_xml = sensor_str_elem.text + return parse_sensor_values(sensor_xml) + + async def get_last_data(self) -> str: + """Get temperature trace of last executed method (CSV format). + + Returns: + CSV string with temperature trace data. + """ + resp = await self._sila.send_command("GetLastData") + # Response contains CSV data in SiLA Data Capture format + # For now, return the raw response - parsing can be added later + return str(resp) # type: ignore + + # Method control commands + async def execute_method(self, method_name: str, priority: Optional[int] = None) -> None: + """Execute a method or premethod by name. + + Args: + method_name: Name of the method or premethod to execute. + priority: Priority (not used by ODTC, but part of SiLA spec). + """ + params: dict = {"methodName": method_name} + if priority is not None: + params["priority"] = priority + await self._sila.send_command("ExecuteMethod", **params) + + async def stop_method(self) -> None: + """Stop currently running method.""" + await self._sila.send_command("StopMethod") + + async def list_methods(self) -> Tuple[List[str], List[str]]: + """List available methods and premethods. + + Returns: + Tuple of (premethod_names, method_names). + """ + resp = await self._sila.send_command("GetParameters") + if resp is None: + return ([], []) + + # Extract MethodsXML parameter + param = resp.find(".//Parameter[@name='MethodsXML']") + if param is None: + return ([], []) + + string_elem = param.find("String") + if string_elem is None or string_elem.text is None: + return ([], []) + + # Parse MethodSet XML (it's escaped in the response) + method_set_xml = string_elem.text + method_set = parse_method_set(method_set_xml) + + premethod_names = [pm.name for pm in method_set.premethods] + method_names = [m.name for m in method_set.methods] + + return (premethod_names, method_names) + + async def upload_method_set_from_file(self, filepath: str) -> None: + """Load and upload a MethodSet XML file to the device. + + Args: + filepath: Path to MethodSet XML file. + """ + method_set = parse_method_set_file(filepath) + method_set_xml = method_set_to_xml(method_set) + + # 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) + await self._sila.send_command("SetParameters", paramsXML=params_xml) + + async def save_method_set_to_file(self, filepath: str) -> None: + """Download methods from device and save to file. + + Args: + filepath: Path to save MethodSet XML file. + """ + resp = await self._sila.send_command("GetParameters") + if resp is None: + raise ValueError("Empty response from GetParameters") + + # Extract MethodsXML parameter + param = resp.find(".//Parameter[@name='MethodsXML']") + if param is None: + raise ValueError("MethodsXML parameter not found in response") + + string_elem = param.find("String") + if string_elem is None or string_elem.text is None: + raise ValueError("MethodsXML String element not found") + + # XML is escaped in the response, so we get it as-is + method_set_xml = string_elem.text + # Write to file + with open(filepath, "w", encoding="utf-8") as f: + f.write(method_set_xml) + + # ============================================================================ + # ThermocyclerBackend Abstract Methods + # ============================================================================ + + async def open_lid(self) -> None: + """Open thermocycler lid (maps to OpenDoor).""" + await self.open_door() + + async def close_lid(self) -> None: + """Close thermocycler lid (maps to CloseDoor).""" + await self.close_door() + + async def set_block_temperature(self, temperature: List[float]) -> None: + """Set block temperature. + + Note: ODTC doesn't have a direct SetBlockTemperature command. + Temperature is controlled via ExecuteMethod with PreMethod or Method. + This is a placeholder that raises NotImplementedError. + + Args: + temperature: Target temperature(s) in °C. + """ + raise NotImplementedError( + "ODTC doesn't support direct block temperature setting. " + "Use ExecuteMethod with a PreMethod or Method instead." + ) + + async def set_lid_temperature(self, temperature: List[float]) -> None: + """Set lid temperature. + + Note: ODTC doesn't have a direct SetLidTemperature command. + Lid temperature is controlled via ExecuteMethod with PreMethod or Method. + This is a placeholder that raises NotImplementedError. + + Args: + temperature: Target temperature(s) in °C. + """ + raise NotImplementedError( + "ODTC doesn't support direct lid temperature setting. " + "Use ExecuteMethod with a PreMethod or Method instead." + ) + + async def deactivate_block(self) -> None: + """Deactivate block (maps to StopMethod).""" + await self.stop_method() + + async def deactivate_lid(self) -> None: + """Deactivate lid (maps to StopMethod).""" + await self.stop_method() + + async def run_protocol(self, protocol: Protocol, block_max_volume: float) -> None: + """Execute thermocycler protocol. + + Note: This requires converting Protocol to ODTCMethod and uploading it. + For now, this is a placeholder. + + Args: + protocol: Protocol to execute. + block_max_volume: Maximum block volume (µL). + """ + raise NotImplementedError( + "Protocol execution requires converting Protocol to ODTCMethod. " + "Use protocol_to_odtc_method() from odtc_xml.py, then upload and execute." + ) + + async def get_block_current_temperature(self) -> List[float]: + """Get current block temperature. + + Returns: + List of block temperatures in °C (single zone for ODTC). + """ + sensor_values = await self.read_temperatures() + return [sensor_values.mount] + + async def get_block_target_temperature(self) -> List[float]: + """Get block target temperature. + + Returns: + List of target temperatures in °C. + + Raises: + RuntimeError: If no target is set. + """ + # ODTC doesn't expose target temperature directly + # Would need to query current method execution state + raise RuntimeError("Target temperature not available - method execution state not tracked") + + async def get_lid_current_temperature(self) -> List[float]: + """Get current lid temperature. + + Returns: + List of lid temperatures in °C (single zone for ODTC). + """ + sensor_values = await self.read_temperatures() + return [sensor_values.lid] + + async def get_lid_target_temperature(self) -> List[float]: + """Get lid target temperature. + + Returns: + List of target temperatures in °C. + + Raises: + RuntimeError: If no target is set. + """ + # ODTC doesn't expose target temperature directly + raise RuntimeError("Target temperature not available - method execution state not tracked") + + async def get_lid_open(self) -> bool: + """Check if lid is open. + + Returns: + True if lid/door is open. + """ + # Would need GetDoorStatus command - for now, return False + # TODO: Implement GetDoorStatus if available + return False + + async def get_lid_status(self) -> LidStatus: + """Get lid temperature status. + + Returns: + LidStatus enum value. + """ + # Simplified: if we can read temperature, assume it's holding + try: + await self.read_temperatures() + return LidStatus.HOLDING_AT_TARGET + except Exception: + return LidStatus.IDLE + + async def get_block_status(self) -> BlockStatus: + """Get block temperature status. + + Returns: + BlockStatus enum value. + """ + # Simplified: if we can read temperature, assume it's holding + try: + await self.read_temperatures() + return BlockStatus.HOLDING_AT_TARGET + except Exception: + return BlockStatus.IDLE + + async def get_hold_time(self) -> float: + """Get remaining hold time. + + Returns: + Remaining hold time in seconds. + """ + # Not directly available from ODTC - would need method execution state + raise NotImplementedError("Hold time not available - method execution state not tracked") + + async def get_current_cycle_index(self) -> int: + """Get current cycle index. + + Returns: + Zero-based cycle index. + """ + # Not directly available from ODTC - would need method execution state + raise NotImplementedError("Cycle index not available - method execution state not tracked") + + async def get_total_cycle_count(self) -> int: + """Get total cycle count. + + Returns: + Total number of cycles. + """ + # Not directly available from ODTC - would need method execution state + raise NotImplementedError("Cycle count not available - method execution state not tracked") + + async def get_current_step_index(self) -> int: + """Get current step index. + + Returns: + Zero-based step index. + """ + # Not directly available from ODTC - would need method execution state + raise NotImplementedError("Step index not available - method execution state not tracked") + + async def get_total_step_count(self) -> int: + """Get total step count. + + Returns: + Total number of steps. + """ + # Not directly available from ODTC - would need method execution state + raise NotImplementedError("Step count not available - method execution state not tracked") diff --git a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py new file mode 100644 index 00000000000..bbb73641193 --- /dev/null +++ b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py @@ -0,0 +1,249 @@ +"""Tests for ODTC backend and SiLA interface.""" + +import unittest +import xml.etree.ElementTree as ET +from unittest.mock import AsyncMock, MagicMock, patch + +from pylabrobot.thermocycling.inheco.odtc_backend import ODTCBackend +from pylabrobot.thermocycling.inheco.odtc_sila_interface import ODTCSiLAInterface, SiLAState + + +class TestODTCSiLAInterface(unittest.IsolatedAsyncioTestCase): + """Tests for ODTCSiLAInterface.""" + + def setUp(self): + """Set up test fixtures.""" + self.interface = ODTCSiLAInterface(machine_ip="192.168.1.100") + + def test_normalize_command_name(self): + """Test command name normalization for aliases.""" + self.assertEqual(self.interface._normalize_command_name("OpenDoor"), "OpenDoor") + self.assertEqual(self.interface._normalize_command_name("PrepareForOutput"), "OpenDoor") + self.assertEqual(self.interface._normalize_command_name("CloseDoor"), "CloseDoor") + self.assertEqual(self.interface._normalize_command_name("PrepareForInput"), "CloseDoor") + self.assertEqual(self.interface._normalize_command_name("ExecuteMethod"), "ExecuteMethod") + + def test_check_state_allowability(self): + """Test state allowability checking.""" + # GetStatus allowed in all states + self.interface._current_state = SiLAState.STARTUP + self.assertTrue(self.interface._check_state_allowability("GetStatus")) + + self.interface._current_state = SiLAState.STANDBY + self.assertTrue(self.interface._check_state_allowability("GetStatus")) + self.assertTrue(self.interface._check_state_allowability("Initialize")) + self.assertFalse(self.interface._check_state_allowability("ExecuteMethod")) + + self.interface._current_state = SiLAState.IDLE + self.assertTrue(self.interface._check_state_allowability("ExecuteMethod")) + self.assertFalse(self.interface._check_state_allowability("Initialize")) + + def test_check_parallelism(self): + """Test parallelism checking.""" + # No commands executing - should allow + self.assertTrue(self.interface._check_parallelism("ReadActualTemperature")) + + # SetParameters executing - ReadActualTemperature can run in parallel + self.interface._executing_commands.add("SetParameters") + self.assertTrue(self.interface._check_parallelism("ReadActualTemperature")) + + # ExecuteMethod executing - SetParameters cannot run in parallel (S) + self.interface._executing_commands.clear() + self.interface._executing_commands.add("ExecuteMethod") + self.assertFalse(self.interface._check_parallelism("SetParameters")) + + # ExecuteMethod executing - ReadActualTemperature can run in parallel (P) + self.assertTrue(self.interface._check_parallelism("ReadActualTemperature")) + + def test_validate_lock_id(self): + """Test lockId validation.""" + # Device not locked - any lockId (including None) is fine + self.interface._lock_id = None + self.interface._validate_lock_id(None) # Should not raise + self.interface._validate_lock_id("some_id") # Should not raise + + # Device locked - must provide matching lockId + self.interface._lock_id = "locked_id" + self.interface._validate_lock_id("locked_id") # Should not raise + with self.assertRaises(RuntimeError) as cm: + self.interface._validate_lock_id("wrong_id") + # Check that error mentions lockId mismatch + error_msg = str(cm.exception) + self.assertIn("locked", error_msg.lower()) + self.assertIn("5", error_msg) # Return code 5 + + def test_update_state_from_status(self): + """Test state updates from status strings.""" + self.interface._update_state_from_status("Idle") + self.assertEqual(self.interface._current_state, SiLAState.IDLE) + + self.interface._update_state_from_status("Busy") + self.assertEqual(self.interface._current_state, SiLAState.BUSY) + + self.interface._update_state_from_status("Standby") + self.assertEqual(self.interface._current_state, SiLAState.STANDBY) + + def test_handle_return_code(self): + """Test return code handling.""" + # Code 1, 2, 3 should not raise + self.interface._handle_return_code(1, "Success", "GetStatus", 123) + self.interface._handle_return_code(2, "Accepted", "ExecuteMethod", 123) + self.interface._handle_return_code(3, "Finished", "ExecuteMethod", 123) + + # Code 4 should raise + with self.assertRaises(RuntimeError) as cm: + self.interface._handle_return_code(4, "Busy", "ExecuteMethod", 123) + self.assertIn("return code 4", str(cm.exception)) + + # Code 5 should raise + with self.assertRaises(RuntimeError) as cm: + self.interface._handle_return_code(5, "LockId error", "ExecuteMethod", 123) + self.assertIn("return code 5", str(cm.exception)) + + # Code 9 should raise + with self.assertRaises(RuntimeError) as cm: + self.interface._handle_return_code(9, "Not allowed", "ExecuteMethod", 123) + self.assertIn("return code 9", str(cm.exception)) + + # Device error code should transition to InError + with self.assertRaises(RuntimeError): + self.interface._handle_return_code(1000, "Device error", "ExecuteMethod", 123) + self.assertEqual(self.interface._current_state, SiLAState.INERROR) + + +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 + + async def test_setup(self): + """Test backend setup.""" + self.backend._sila.setup = AsyncMock() + await self.backend.setup() + self.backend._sila.setup.assert_called_once() + + async def test_stop(self): + """Test backend stop.""" + self.backend._sila.close = AsyncMock() + await self.backend.stop() + self.backend._sila.close.assert_called_once() + + async def test_get_status(self): + """Test get_status.""" + self.backend._sila.send_command = AsyncMock( + return_value={"GetStatusResponse": {"GetStatusResult": {"state": "Idle"}}} + ) + status = await self.backend.get_status() + self.assertEqual(status, "Idle") + self.backend._sila.send_command.assert_called_once_with("GetStatus") + + async def test_open_door(self): + """Test open_door.""" + self.backend._sila.send_command = AsyncMock() + await self.backend.open_door() + self.backend._sila.send_command.assert_called_once_with("OpenDoor") + + async def test_close_door(self): + """Test close_door.""" + self.backend._sila.send_command = AsyncMock() + await self.backend.close_door() + self.backend._sila.send_command.assert_called_once_with("CloseDoor") + + async def test_read_temperatures(self): + """Test read_temperatures.""" + # Mock response with SensorValues XML + sensor_xml = ( + '' + "2463" + "2642" + "2575" + "2627" + "2450" + "3308" + "2596" + "2487" + "" + ) + + # Create mock ElementTree response + root = ET.Element("ResponseData") + param = ET.SubElement(root, "Parameter", name="SensorValues") + string_elem = ET.SubElement(param, "String") + string_elem.text = sensor_xml + + self.backend._sila.send_command = AsyncMock(return_value=root) + sensor_values = await self.backend.read_temperatures() + self.assertAlmostEqual(sensor_values.mount, 24.63, places=2) # 2463 * 0.01 + self.assertAlmostEqual(sensor_values.lid, 25.75, places=2) # 2575 * 0.01 + + async def test_execute_method(self): + """Test execute_method.""" + self.backend._sila.send_command = AsyncMock() + await self.backend.execute_method("MyMethod") + self.backend._sila.send_command.assert_called_once_with("ExecuteMethod", methodName="MyMethod") + + async def test_stop_method(self): + """Test stop_method.""" + self.backend._sila.send_command = AsyncMock() + await self.backend.stop_method() + self.backend._sila.send_command.assert_called_once_with("StopMethod") + + async def test_lock_device(self): + """Test lock_device.""" + self.backend._sila.send_command = AsyncMock() + await self.backend.lock_device("my_lock_id") + self.backend._sila.send_command.assert_called_once() + call_kwargs = self.backend._sila.send_command.call_args[1] + self.assertEqual(call_kwargs["lock_id"], "my_lock_id") + self.assertEqual(call_kwargs["PMSId"], "PyLabRobot") + + async def test_unlock_device(self): + """Test unlock_device.""" + self.backend._sila._lock_id = "my_lock_id" + self.backend._sila.send_command = AsyncMock() + await self.backend.unlock_device() + self.backend._sila.send_command.assert_called_once_with("UnlockDevice", lock_id="my_lock_id") + + async def test_unlock_device_not_locked(self): + """Test unlock_device when device is not locked.""" + self.backend._sila._lock_id = None + with self.assertRaises(RuntimeError) as cm: + await self.backend.unlock_device() + self.assertIn("not locked", str(cm.exception)) + + async def test_get_block_current_temperature(self): + """Test get_block_current_temperature.""" + sensor_xml = '2500' + root = ET.Element("ResponseData") + param = ET.SubElement(root, "Parameter", name="SensorValues") + string_elem = ET.SubElement(param, "String") + string_elem.text = sensor_xml + + self.backend._sila.send_command = AsyncMock(return_value=root) + 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) + temps = await self.backend.get_lid_current_temperature() + self.assertEqual(len(temps), 1) + self.assertAlmostEqual(temps[0], 26.0, places=2) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py new file mode 100644 index 00000000000..906ef8c04d6 --- /dev/null +++ b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py @@ -0,0 +1,749 @@ +"""ODTC-specific SiLA interface with parallelism, state management, and lockId validation. + +This module extends InhecoSiLAInterface to support ODTC-specific requirements: +- Multiple in-flight commands with parallelism enforcement +- State machine tracking and command allowability checks +- LockId validation (defaults to None, validates when device is locked) +- Proper return code handling (including device-specific codes) +- All event types (ResponseEvent, StatusEvent, DataEvent, ErrorEvent) +""" + +from __future__ import annotations + +import asyncio +import logging +import time +import urllib.request +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, Optional, Set + +import xml.etree.ElementTree as ET + +from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface +from pylabrobot.storage.inheco.scila.soap import soap_decode, soap_encode, XSI + +# SOAP responses for events +SOAP_RESPONSE_ResponseEventResponse = """ + + + + 1 + Success + PT0.0006262S + 0 + + + +""" + +SOAP_RESPONSE_StatusEventResponse = """ + + + + 1 + Success + PT0.0005967S + 0 + + + +""" + +SOAP_RESPONSE_DataEventResponse = """ + + + + 1 + Success + PT0.0005967S + 0 + + + +""" + +SOAP_RESPONSE_ErrorEventResponse = """ + + + + 1 + Success + PT0.0005967S + 0 + + + +""" + + +class SiLAState(str, Enum): + """SiLA device states per specification. + + Note: State values match what the ODTC device actually returns. + Based on SCILABackend comment, devices return: "standBy", "inError", "startup" (mixed/lowercase). + However, the actual ODTC device returns "standby" (all lowercase) as seen in practice. + """ + + STARTUP = "startup" + STANDBY = "standby" # Device returns "standby" (all lowercase) or "standBy" (camelCase) + INITIALIZING = "initializing" + IDLE = "idle" + BUSY = "busy" + PAUSED = "paused" + ERRORHANDLING = "errorHandling" # Device returns "errorHandling" (camelCase per SCILABackend pattern) + INERROR = "inError" # Device returns "inError" (camelCase per SCILABackend comment) + + +@dataclass(frozen=True) +class PendingCommand: + """Tracks a pending async command.""" + + name: str + request_id: int + fut: asyncio.Future[Any] + started_at: float + timeout: Optional[float] = None # Estimated duration from device response + lock_id: Optional[str] = None # LockId sent with LockDevice command (for tracking) + + +class ODTCSiLAInterface(InhecoSiLAInterface): + """ODTC-specific SiLA interface with parallelism, state tracking, and lockId validation. + + Extends InhecoSiLAInterface to support: + - Multiple in-flight commands with parallelism enforcement per ODTC doc section 3 + - State machine tracking and command allowability per ODTC doc section 4 + - LockId validation (defaults to None, validates when device is locked) + - Proper return code handling including device-specific codes (1000-2010) + - All event types: ResponseEvent, StatusEvent, DataEvent, ErrorEvent + """ + + # Parallelism table from ODTC doc section 3 + # Format: {command: {other_command: "P" (parallel) or "S" (sequential)}} + # Commands: SP=SetParameters, GP=GetParameters, OD=OpenDoor, CD=CloseDoor, + # RAT=ReadActualTemperature, EM=ExecuteMethod, SM=StopMethod, GLD=GetLastData + PARALLELISM_TABLE: Dict[str, Dict[str, str]] = { + "SetParameters": { + "SetParameters": "S", + "GetParameters": "S", + "OpenDoor": "P", + "CloseDoor": "P", + "ReadActualTemperature": "P", + "ExecuteMethod": "S", + "StopMethod": "P", + "GetLastData": "S", + }, + "GetParameters": { + "SetParameters": "P", + "GetParameters": "S", + "OpenDoor": "P", + "CloseDoor": "P", + "ReadActualTemperature": "P", + "ExecuteMethod": "S", + "StopMethod": "P", + "GetLastData": "S", + }, + "OpenDoor": { + "SetParameters": "P", + "GetParameters": "P", + "OpenDoor": "S", + "CloseDoor": "S", + "ReadActualTemperature": "P", + "ExecuteMethod": "P", + "StopMethod": "P", + "GetLastData": "P", + }, + "CloseDoor": { + "SetParameters": "P", + "GetParameters": "P", + "OpenDoor": "S", + "CloseDoor": "S", + "ReadActualTemperature": "P", + "ExecuteMethod": "P", + "StopMethod": "P", + "GetLastData": "P", + }, + "ReadActualTemperature": { + "SetParameters": "P", + "GetParameters": "P", + "OpenDoor": "P", + "CloseDoor": "P", + "ReadActualTemperature": "P", + "ExecuteMethod": "P", + "StopMethod": "P", + "GetLastData": "P", + }, + "ExecuteMethod": { + "SetParameters": "S", + "GetParameters": "S", + "OpenDoor": "P", + "CloseDoor": "P", + "ReadActualTemperature": "P", + "ExecuteMethod": "S", + "StopMethod": "P", + "GetLastData": "S", + }, + "StopMethod": { + "SetParameters": "P", + "GetParameters": "P", + "OpenDoor": "P", + "CloseDoor": "P", + "ReadActualTemperature": "P", + "ExecuteMethod": "S", + "StopMethod": "S", + "GetLastData": "P", + }, + "GetLastData": { + "SetParameters": "S", + "GetParameters": "S", + "OpenDoor": "P", + "CloseDoor": "P", + "ReadActualTemperature": "P", + "ExecuteMethod": "S", + "StopMethod": "P", + "GetLastData": "S", + }, + } + + # State allowability table from ODTC doc section 4 + # Format: {command: {state: True if allowed}} + STATE_ALLOWABILITY: Dict[str, Dict[SiLAState, bool]] = { + "Abort": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "CloseDoor": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "DoContinue": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "ExecuteMethod": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "GetConfiguration": {SiLAState.STARTUP: False, SiLAState.STANDBY: True, SiLAState.IDLE: False, SiLAState.BUSY: False}, + "GetParameters": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "GetDeviceIdentification": {SiLAState.STARTUP: True, SiLAState.STANDBY: True, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "GetLastData": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "GetStatus": {SiLAState.STARTUP: True, SiLAState.STANDBY: True, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "Initialize": {SiLAState.STARTUP: False, SiLAState.STANDBY: True, SiLAState.IDLE: False, SiLAState.BUSY: False}, + "LockDevice": {SiLAState.STARTUP: False, SiLAState.STANDBY: True, SiLAState.IDLE: False, SiLAState.BUSY: False}, + "OpenDoor": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "Pause": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "PrepareForInput": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "PrepareForOutput": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "ReadActualTemperature": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "Reset": {SiLAState.STARTUP: True, SiLAState.STANDBY: True, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "SetConfiguration": {SiLAState.STARTUP: False, SiLAState.STANDBY: True, SiLAState.IDLE: False, SiLAState.BUSY: False}, + "SetParameters": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "StopMethod": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "UnlockDevice": {SiLAState.STARTUP: False, SiLAState.STANDBY: True, SiLAState.IDLE: False, SiLAState.BUSY: False}, + } + + # Synchronous commands (return code 1, no ResponseEvent) + SYNCHRONOUS_COMMANDS: Set[str] = {"GetStatus", "GetDeviceIdentification"} + + # Device-specific return codes that indicate DeviceError (InError state) + DEVICE_ERROR_CODES: Set[int] = {1000, 2000, 2001, 2007} + + def __init__( + self, + machine_ip: str, + client_ip: Optional[str] = None, + logger: Optional[logging.Logger] = None, + ) -> None: + """Initialize ODTC SiLA interface. + + Args: + machine_ip: IP address of the ODTC device. + client_ip: IP address of this client (auto-detected if None). + logger: Logger instance (creates one if None). + """ + super().__init__(machine_ip=machine_ip, client_ip=client_ip, logger=logger) + + # Multi-request tracking (replaces single _pending) + self._pending_by_id: Dict[int, PendingCommand] = {} + self._active_request_ids: Set[int] = set() # For duplicate detection + + # State tracking + self._current_state: SiLAState = SiLAState.STARTUP + self._lock_id: Optional[str] = None # None = unlocked, str = locked with this ID + + # Track currently executing commands for parallelism checking + self._executing_commands: Set[str] = set() + + # Lock for parallelism checking (separate from base class's _making_request) + self._parallelism_lock = asyncio.Lock() + + def _check_state_allowability(self, command: str) -> bool: + """Check if command is allowed in current state. + + Args: + command: Command name. + + Returns: + True if command is allowed, False otherwise. + """ + if command not in self.STATE_ALLOWABILITY: + # Unknown command - allow it (might be device-specific) + return True + + state_rules = self.STATE_ALLOWABILITY[command] + return state_rules.get(self._current_state, False) + + def _check_parallelism(self, command: str) -> bool: + """Check if command can run in parallel with currently executing commands. + + Args: + command: Command name to check. + + Returns: + True if command can run in parallel, False if it conflicts. + """ + # If no commands executing, allow it + if not self._executing_commands: + return True + + # Check against each executing command + for executing_cmd in self._executing_commands: + # Normalize command names (handle aliases) + cmd1 = self._normalize_command_name(command) + cmd2 = self._normalize_command_name(executing_cmd) + + # Check parallelism table + if cmd1 in self.PARALLELISM_TABLE: + if cmd2 in self.PARALLELISM_TABLE[cmd1]: + if self.PARALLELISM_TABLE[cmd1][cmd2] == "S": + return False # Sequential required + else: + # Command not in table - default to sequential for safety + return False + else: + # Command not in parallelism table - default to sequential + return False + + return True # Can run in parallel + + def _normalize_command_name(self, command: str) -> str: + """Normalize command name for parallelism table lookup. + + Handles command aliases (OpenDoor/PrepareForOutput, CloseDoor/PrepareForInput). + + Args: + command: Command name. + + Returns: + Normalized command name for table lookup. + """ + # Handle aliases per ODTC doc section 8 + if command in ("PrepareForOutput", "OpenDoor"): + return "OpenDoor" + if command in ("PrepareForInput", "CloseDoor"): + return "CloseDoor" + return command + + def _validate_lock_id(self, lock_id: Optional[str]) -> None: + """Validate lockId parameter. + + Args: + lock_id: LockId to validate (None is allowed if device not locked). + + Raises: + RuntimeError: If lockId validation fails (return code 5). + """ + if self._lock_id is None: + # Device not locked - any lockId (including None) is fine + return + + # Device is locked - must provide matching lockId + if lock_id != self._lock_id: + raise RuntimeError( + f"Device is locked with lockId '{self._lock_id}', " + f"but command provided lockId '{lock_id}'. Return code: 5" + ) + + def _update_state_from_status(self, state_str: str) -> None: + """Update internal state from GetStatus or StatusEvent response. + + Args: + state_str: State string from device response (must match enum values exactly). + """ + if not state_str: + return + + try: + self._current_state = SiLAState(state_str) + self._logger.debug(f"State updated to: {self._current_state.value}") + except ValueError: + self._logger.warning( + f"Unknown state received: {state_str!r}, keeping current state {self._current_state.value}. " + f"Expected one of: {[s.value for s in SiLAState]}" + ) + + def _handle_return_code( + self, return_code: int, message: str, command_name: str, request_id: int + ) -> None: + """Handle return code and update state accordingly. + + Args: + return_code: Return code from device. + message: Return message. + command_name: Name of the command. + request_id: Request ID of the command. + + Raises: + RuntimeError: For error return codes. + """ + if return_code == 1: + # Success (synchronous commands) + return + elif return_code == 2: + # Asynchronous command accepted + return + elif return_code == 3: + # Asynchronous command finished (success) - handled in ResponseEvent + return + elif return_code == 4: + # Device busy + raise RuntimeError(f"Command {command_name} rejected: Device is busy (return code 4)") + elif return_code == 5: + # LockId error + raise RuntimeError(f"Command {command_name} rejected: LockId mismatch (return code 5)") + elif return_code == 6: + # RequestId error + raise RuntimeError(f"Command {command_name} rejected: Invalid or duplicate requestId (return code 6)") + elif return_code == 9: + # Command not allowed in this state + raise RuntimeError( + f"Command {command_name} not allowed in state {self._current_state.value} (return code 9)" + ) + elif return_code == 11: + # Invalid parameter + raise RuntimeError(f"Command {command_name} rejected: Invalid parameter (return code 11): {message}") + elif return_code == 12: + # Finished with warning + self._logger.warning(f"Command {command_name} finished with warning (return code 12): {message}") + return + elif return_code >= 1000: + # Device-specific return code + if return_code in self.DEVICE_ERROR_CODES: + # DeviceError - transition to InError + self._current_state = SiLAState.INERROR + raise RuntimeError( + f"Command {command_name} failed with device error (return code {return_code}): {message}" + ) + else: + # Warning or recoverable error + self._logger.warning( + f"Command {command_name} returned device-specific code {return_code}: {message}" + ) + # May transition to ErrorHandling if recoverable + if return_code not in {2005, 2006, 2008, 2009, 2010}: # These are warnings, not errors + # Recoverable error - transition to ErrorHandling + self._current_state = SiLAState.ERRORHANDLING + else: + # Unknown return code + raise RuntimeError(f"Command {command_name} returned unknown code {return_code}: {message}") + + async def _on_http(self, req: InhecoSiLAInterface._HTTPRequest) -> bytes: + """Handle incoming HTTP requests from device (events). + + Overrides base class to support multiple pending requests and all event types. + + Args: + req: HTTP request from device. + + Returns: + SOAP response bytes. + """ + body_str = req.body.decode("utf-8") + decoded = soap_decode(body_str) + + # Handle ResponseEvent (async command completion) + if "ResponseEvent" in decoded: + response_event = decoded["ResponseEvent"] + request_id = response_event.get("requestId") + return_value = response_event.get("returnValue", {}) + return_code = return_value.get("returnCode") + message = return_value.get("message", "") + + if request_id is None: + self._logger.warning("ResponseEvent missing requestId") + return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") + + # Find matching pending command + pending = self._pending_by_id.get(request_id) + if pending is None: + self._logger.warning(f"ResponseEvent for unknown requestId: {request_id}") + return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") + + if pending.fut.done(): + self._logger.warning(f"ResponseEvent for already-completed requestId: {request_id}") + return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") + + # Fix: Code 3 means "async finished" (SUCCESS), not error + if return_code == 3: + # Success - extract response data + response_data = response_event.get("responseData", "") + if response_data and response_data.strip(): + try: + root = ET.fromstring(response_data) + pending.fut.set_result(root) + except ET.ParseError as e: + self._logger.error(f"Failed to parse ResponseEvent responseData: {e}") + pending.fut.set_exception(RuntimeError(f"Failed to parse response data: {e}")) + else: + # No response data - still success (e.g., OpenDoor, CloseDoor) + pending.fut.set_result(None) + + # Handle LockDevice/UnlockDevice/Reset to update lock state (only on success) + if pending.name == "LockDevice" and pending.lock_id is not None: + self._lock_id = pending.lock_id + self._logger.info(f"Device locked with lockId: {self._lock_id}") + elif pending.name == "UnlockDevice": + self._lock_id = None + self._logger.info("Device unlocked") + elif pending.name == "Reset": + # Reset unlocks device implicitly + self._lock_id = None + self._logger.info("Device reset (unlocked)") + else: + # Error or other code + err_msg = message.replace("\n", " ") if message else f"Unknown error (code {return_code})" + pending.fut.set_exception(RuntimeError(f"Command {pending.name} failed with code {return_code}: '{err_msg}'")) + + # Clean up + self._pending_by_id.pop(request_id, None) + self._active_request_ids.discard(request_id) + # Use normalized command name for cleanup + normalized_cmd = self._normalize_command_name(pending.name) + self._executing_commands.discard(normalized_cmd) + + # Update state: if no more commands executing, transition Busy -> Idle + if not self._executing_commands and self._current_state == SiLAState.BUSY: + self._current_state = SiLAState.IDLE + + return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") + + # Handle StatusEvent (state changes) + if "StatusEvent" in decoded: + status_event = decoded["StatusEvent"] + event_description = status_event.get("eventDescription", {}) + device_state = event_description.get("DeviceState") + if device_state: + self._update_state_from_status(device_state) + return SOAP_RESPONSE_StatusEventResponse.encode("utf-8") + + # Handle DataEvent (intermediate data, e.g., during ExecuteMethod) + if "DataEvent" in decoded: + # For now, just log DataEvents - design allows future exposure + data_event = decoded["DataEvent"] + request_id = data_event.get("requestId") + self._logger.debug(f"DataEvent received for requestId {request_id}") + # TODO: Could expose via callback/stream in future + return SOAP_RESPONSE_DataEventResponse.encode("utf-8") + + # Handle ErrorEvent (recoverable errors with continuation tasks) + if "ErrorEvent" in decoded: + error_event = decoded["ErrorEvent"] + request_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 {request_id}: code {return_code}, message: {message}") + + # Transition to ErrorHandling state + self._current_state = SiLAState.ERRORHANDLING + + # Find matching pending command and set exception + pending = self._pending_by_id.get(request_id) + if pending and not pending.fut.done(): + err_msg = message.replace("\n", " ") if message else f"Error (code {return_code})" + pending.fut.set_exception(RuntimeError(f"Command {pending.name} error: '{err_msg}'")) + + # TODO: Continuation task selection/response not implemented (out of scope) + return SOAP_RESPONSE_ErrorEventResponse.encode("utf-8") + + # Unknown event type + self._logger.warning("Unknown event type received") + return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") + + async def send_command( + self, + command: str, + lock_id: Optional[str] = None, + **kwargs, + ) -> Any: + """Send a SiLA command with parallelism, state, and lockId validation. + + Overrides base class to add: + - Parallelism checking + - State allowability checking + - LockId validation + - Multi-request tracking + - Proper return code handling + + Args: + command: Command name. + lock_id: LockId (defaults to None, validated if device is locked). + **kwargs: Additional command parameters. + + Returns: + Command response (parsed XML ElementTree for async, decoded dict for sync). + + Raises: + RuntimeError: For validation failures, return code errors, or state violations. + """ + if self._closed: + raise RuntimeError("Interface is closed") + + # GetStatus doesn't require lockId per ODTC doc section 2 + if command != "GetStatus": + self._validate_lock_id(lock_id) + + # Check state allowability + if not self._check_state_allowability(command): + raise RuntimeError( + f"Command {command} not allowed in state {self._current_state.value} (return code 9)" + ) + + # Check parallelism (for commands in the table) + normalized_cmd = self._normalize_command_name(command) + if normalized_cmd in self.PARALLELISM_TABLE: + async with self._parallelism_lock: + if not self._check_parallelism(normalized_cmd): + raise RuntimeError( + f"Command {command} cannot run in parallel with currently executing commands (return code 4)" + ) + else: + # Command not in parallelism table - default to sequential (safe) + async with self._parallelism_lock: + if self._executing_commands: + # If any command is executing and this command isn't in table, reject + raise RuntimeError( + f"Command {command} not in parallelism table and device is busy (return code 4)" + ) + + # Generate request_id (reuse base class method) + request_id = self._make_request_id() + + # Check for duplicate request_id (unlikely but guard against it) + if request_id in self._active_request_ids: + raise RuntimeError(f"Duplicate requestId generated: {request_id} (return code 6)") + + # Build command parameters + params: Dict[str, Any] = {"requestId": request_id, **kwargs} + # Add lockId if provided (or if device is locked, it's required) + if command != "GetStatus": # GetStatus exception + if self._lock_id is not None: + # Device is locked - must provide lockId + params["lockId"] = lock_id if lock_id is not None else self._lock_id + elif lock_id is not None: + # Device not locked but lockId provided - include it + params["lockId"] = lock_id + + # Encode SOAP request + cmd_xml = soap_encode( + command, + params, + method_ns="http://sila.coop", + extra_method_xmlns={"i": XSI}, + ) + + # Make POST request + url = f"http://{self._machine_ip}:8080/" + req = urllib.request.Request( + url=url, + data=cmd_xml.encode("utf-8"), + method="POST", + headers={ + "Content-Type": "text/xml; charset=utf-8", + "Content-Length": str(len(cmd_xml)), + "SOAPAction": f"http://sila.coop/{command}", + "Expect": "100-continue", + "Accept-Encoding": "gzip, deflate", + }, + ) + + # Execute request + def _do_request() -> bytes: + with urllib.request.urlopen(req) as resp: + return resp.read() # type: ignore + + body = await asyncio.to_thread(_do_request) + decoded = soap_decode(body.decode("utf-8")) + + # Extract return code and message + return_code, message = self._get_return_code_and_message(command, decoded) + + # Handle return codes + if return_code == 1: + # Synchronous success (GetStatus, GetDeviceIdentification) + # Update state from GetStatus response if applicable + if command == "GetStatus": + # Try different possible response structures + state = decoded.get("GetStatusResponse", {}).get("state") + if not state: + state = decoded.get("GetStatusResponse", {}).get("GetStatusResult", {}).get("state") + if state: + self._update_state_from_status(state) + return decoded + + elif return_code == 2: + # Asynchronous command accepted - set up pending tracking + fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + + # Extract duration for timeout (if provided) + result = decoded.get(f"{command}Response", {}).get(f"{command}Result", {}) + duration_str = result.get("duration") + timeout = None + if duration_str: + # Parse ISO 8601 duration (simplified - just extract seconds) + # Format: PT30.7S or P5DT4H12M17S + try: + # For now, just use a multiplier - proper parsing would use datetime.timedelta + # This is a simplified approach + if isinstance(duration_str, str) and "S" in duration_str: + # Extract seconds part + import re + match = re.search(r"(\d+(?:\.\d+)?)S", duration_str) + if match: + seconds = float(match.group(1)) + timeout = seconds + 10.0 # Add 10s buffer + except Exception: + pass # Ignore parsing errors, use None timeout + + # Store lock_id for LockDevice commands so we can set it after success + pending_lock_id = None + if command == "LockDevice" and "lockId" in params: + pending_lock_id = params["lockId"] + + pending = PendingCommand( + name=command, + request_id=request_id, + fut=fut, + started_at=time.time(), + timeout=timeout, + lock_id=pending_lock_id, + ) + + self._pending_by_id[request_id] = pending + self._active_request_ids.add(request_id) + self._executing_commands.add(normalized_cmd) + + # Update state: Idle -> Busy + if self._current_state == SiLAState.IDLE: + self._current_state = SiLAState.BUSY + + # Wait for async response + try: + result = await fut + return result + except asyncio.TimeoutError: + # Clean up on timeout + self._pending_by_id.pop(request_id, None) + self._active_request_ids.discard(request_id) + self._executing_commands.discard(normalized_cmd) + raise RuntimeError(f"Command {command} timed out waiting for ResponseEvent") + + else: + # Error return code + self._handle_return_code(return_code, message, command, request_id) + # Should not reach here (handle_return_code raises), but just in case: + raise RuntimeError(f"Command {command} failed: {return_code} {message}") diff --git a/pylabrobot/thermocycling/inheco/odtc_xml.py b/pylabrobot/thermocycling/inheco/odtc_xml.py new file mode 100644 index 00000000000..889485e7447 --- /dev/null +++ b/pylabrobot/thermocycling/inheco/odtc_xml.py @@ -0,0 +1,1042 @@ +""" +Schema-driven XML serialization for ODTC MethodSet. + +Uses dataclass field metadata to define XML mapping, enabling automatic +bidirectional conversion between Python objects and XML. +""" + +from __future__ import annotations + +import logging +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field, fields +from enum import Enum +from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, Union, get_args, get_origin, get_type_hints + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +# ============================================================================= +# Hardware Constraints +# ============================================================================= + + +@dataclass(frozen=True) +class ODTCHardwareConstraints: + """Hardware limits for ODTC variants - immutable reference data. + + These values are derived from Inheco documentation and Script Editor defaults. + Note: Actual achievable rates may vary based on fluid quantity and target temperature. + """ + + variant: int + variant_name: str + min_block_temp: float = 4.0 + max_block_temp: float = 99.0 + min_lid_temp: float = 30.0 + max_lid_temp: float = 115.0 + min_slope: float = 0.1 + max_heating_slope: float = 4.4 + max_cooling_slope: float = 2.2 + valid_fluid_quantities: Tuple[int, ...] = (-1, 0, 1, 2) # -1 = verification tool + valid_plate_types: Tuple[int, ...] = (0,) + max_steps_per_method: int = 100 + + +ODTC_96_CONSTRAINTS = ODTCHardwareConstraints( + variant=960000, + variant_name="ODTC 96", + max_heating_slope=4.4, + max_lid_temp=110.0, + valid_fluid_quantities=(-1, 0, 1, 2), +) + +ODTC_384_CONSTRAINTS = ODTCHardwareConstraints( + variant=384000, + variant_name="ODTC 384", + max_heating_slope=5.0, + max_lid_temp=115.0, + valid_fluid_quantities=(-1, 0, 1, 2), # Same as 96-well per XML samples + valid_plate_types=(0, 2), # Only 0 observed in XML samples +) + +_CONSTRAINTS_MAP: Dict[int, ODTCHardwareConstraints] = { + 960000: ODTC_96_CONSTRAINTS, + 384000: ODTC_384_CONSTRAINTS, + 3840000: ODTC_384_CONSTRAINTS, # Alias +} + + +def get_constraints(variant: int) -> ODTCHardwareConstraints: + """Get hardware constraints for a variant. + + Args: + variant: ODTC variant code (960000 for 96-well, 384000 for 384-well). + + Returns: + ODTCHardwareConstraints for the specified variant. + + Raises: + ValueError: If variant is unknown. + """ + if variant not in _CONSTRAINTS_MAP: + raise ValueError(f"Unknown variant {variant}. Valid: {list(_CONSTRAINTS_MAP.keys())}") + return _CONSTRAINTS_MAP[variant] + + +# ============================================================================= +# XML Field Metadata +# ============================================================================= + + +class XMLFieldType(Enum): + """How a field maps to XML.""" + + ELEMENT = "element" # value + ATTRIBUTE = "attribute" # + CHILD_LIST = "child_list" # List of child elements + + +@dataclass(frozen=True) +class XMLField: + """Metadata for XML field mapping.""" + + tag: Optional[str] = None # XML tag name (defaults to field name) + field_type: XMLFieldType = XMLFieldType.ELEMENT + default: Any = None # Default value if missing + scale: float = 1.0 # For unit conversion (e.g., 1/100°C -> °C) + + +def xml_field( + tag: Optional[str] = None, + field_type: XMLFieldType = XMLFieldType.ELEMENT, + default: Any = None, + scale: float = 1.0, +) -> Any: + """Create a dataclass field with XML metadata.""" + metadata = {"xml": XMLField(tag=tag, field_type=field_type, default=default, scale=scale)} + if default is None: + return field(default=None, metadata=metadata) + return field(default=default, metadata=metadata) + + +def xml_attr(tag: Optional[str] = None, default: Any = None) -> Any: + """Shorthand for an XML attribute field.""" + return xml_field(tag=tag, field_type=XMLFieldType.ATTRIBUTE, default=default) + + +def xml_child_list(tag: Optional[str] = None) -> Any: + """Shorthand for a list of child elements.""" + metadata = {"xml": XMLField(tag=tag, field_type=XMLFieldType.CHILD_LIST, default=None)} + return field(default_factory=list, metadata=metadata) + + +# ============================================================================= +# ODTC Data Classes with XML Schema +# ============================================================================= + + +@dataclass +class ODTCStep: + """A single step in an ODTC method.""" + + 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) + + +@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 ODTCPreMethod: + """ODTC PreMethod for initial temperature conditioning.""" + + name: str = xml_attr(tag="methodName", default="") + target_block_temperature: float = xml_field(tag="TargetBlockTemperature", default=0.0) + target_lid_temperature: float = xml_field(tag="TargetLidTemp", default=0.0) + creator: Optional[str] = xml_attr(tag="creator", default=None) + description: Optional[str] = xml_attr(tag="description", default=None) + datetime: Optional[str] = xml_attr(tag="dateTime", default=None) + + +@dataclass +class ODTCMethod: + """Full ODTC Method with thermal cycling parameters.""" + + name: str = xml_attr(tag="methodName", default="") + variant: int = xml_field(tag="Variant", default=960000) + plate_type: int = xml_field(tag="PlateType", default=0) + fluid_quantity: int = xml_field(tag="FluidQuantity", default=0) + post_heating: bool = xml_field(tag="PostHeating", default=False) + start_block_temperature: float = xml_field(tag="StartBlockTemperature", default=0.0) + start_lid_temperature: float = xml_field(tag="StartLidTemperature", default=0.0) + steps: List[ODTCStep] = xml_child_list(tag="Step") + pid_set: List[ODTCPID] = xml_child_list(tag="PID") + creator: Optional[str] = xml_attr(tag="creator", default=None) + description: Optional[str] = xml_attr(tag="description", default=None) + datetime: Optional[str] = xml_attr(tag="dateTime", default=None) + + def get_loop_structure(self) -> List[tuple]: + """ + Analyze loop structure and return list of (loop_start_step, loop_end_step, repeat_count). + Step numbers are 1-indexed as in the XML. + """ + loops = [] + for step in self.steps: + if step.goto_number > 0 and step.loop_number > 0: + loops.append((step.goto_number, step.number, step.loop_number + 1)) + return loops + + +@dataclass +class ODTCMethodSet: + """Container for all methods and premethods.""" + + delete_all_methods: bool = xml_field(tag="DeleteAllMethods", default=False) + premethods: List[ODTCPreMethod] = xml_child_list(tag="PreMethod") + methods: List[ODTCMethod] = xml_child_list(tag="Method") + + +@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) + + +# ============================================================================= +# Protocol Conversion Config Classes +# ============================================================================= + + +@dataclass +class ODTCStepSettings: + """Per-step ODTC parameters for Protocol to ODTCMethod conversion. + + When converting ODTCMethod to Protocol, these capture the original values. + When converting Protocol to ODTCMethod, these override defaults. + """ + + slope: Optional[float] = None + overshoot_slope1: Optional[float] = None + overshoot_temperature: Optional[float] = None + overshoot_time: Optional[float] = None + overshoot_slope2: Optional[float] = None + lid_temp: Optional[float] = None + pid_number: Optional[int] = None + + +@dataclass +class ODTCConfig: + """ODTC-specific configuration for running a Protocol. + + This class serves two purposes: + 1. When creating new protocols: Specify ODTC-specific parameters + 2. When extracting from ODTCMethod: Captures all params for lossless round-trip + + Validation is performed on construction by default. Set _validate=False to skip + validation (useful when reading data from a trusted source like the device). + """ + + # Method identification/metadata + name: str = "converted_protocol" + creator: Optional[str] = None + description: Optional[str] = None + datetime: Optional[str] = None + + # Device calibration + fluid_quantity: int = 1 # -1=verification, 0=10-29ul, 1=30-74ul, 2=75-100ul + variant: int = 960000 # 96-well ODTC + plate_type: int = 0 + + # Temperature settings + lid_temperature: float = 110.0 + start_lid_temperature: Optional[float] = None # If different from lid_temperature + post_heating: bool = True + + # Default ramp rates (°C/s) - defaults to hardware max for fastest transitions + # Used when Step.rate is None and no step_settings override + default_heating_slope: float = 4.4 # Will be validated against variant constraints + default_cooling_slope: float = 2.2 # Will be validated against variant constraints + + # PID configuration (full set for round-trip preservation) + pid_set: List[ODTCPID] = field(default_factory=lambda: [ODTCPID(number=1)]) + + # Per-step overrides/captures (keyed by step index, 0-based) + step_settings: Dict[int, ODTCStepSettings] = field(default_factory=dict) + + # Validation control - set to False to skip validation on construction + _validate: bool = field(default=True, repr=False) + + def __post_init__(self): + if self._validate: + self.validate() + + @property + def constraints(self) -> ODTCHardwareConstraints: + """Get hardware constraints for this config's variant.""" + return get_constraints(self.variant) + + def validate(self) -> List[str]: + """Validate config against hardware constraints. + + Returns: + List of validation error messages (empty if valid). + + Raises: + ValueError: If any validation fails. + """ + errors: List[str] = [] + c = self.constraints + + # Validate fluid_quantity + if c.valid_fluid_quantities and self.fluid_quantity not in c.valid_fluid_quantities: + errors.append( + f"fluid_quantity={self.fluid_quantity} invalid for {c.variant_name}. " + f"Valid: {c.valid_fluid_quantities}" + ) + + # Validate plate_type + if self.plate_type not in c.valid_plate_types: + errors.append( + f"plate_type={self.plate_type} invalid for {c.variant_name}. " + f"Valid: {c.valid_plate_types}" + ) + + # Validate lid_temperature + if not c.min_lid_temp <= self.lid_temperature <= c.max_lid_temp: + errors.append( + f"lid_temperature={self.lid_temperature}°C outside range " + f"[{c.min_lid_temp}, {c.max_lid_temp}] for {c.variant_name}" + ) + + # Validate default slopes + if self.default_heating_slope > c.max_heating_slope: + errors.append( + f"default_heating_slope={self.default_heating_slope}°C/s exceeds max " + f"{c.max_heating_slope}°C/s for {c.variant_name}" + ) + if self.default_cooling_slope > c.max_cooling_slope: + errors.append( + f"default_cooling_slope={self.default_cooling_slope}°C/s exceeds max " + f"{c.max_cooling_slope}°C/s for {c.variant_name}" + ) + + # Validate step_settings + for idx, settings in self.step_settings.items(): + if settings.lid_temp is not None: + if not c.min_lid_temp <= settings.lid_temp <= c.max_lid_temp: + errors.append( + f"step_settings[{idx}].lid_temp={settings.lid_temp}°C outside range " + f"[{c.min_lid_temp}, {c.max_lid_temp}]" + ) + if settings.slope is not None: + # Can't easily check heating vs cooling without knowing step sequence, + # so just check against the higher max + max_slope = max(c.max_heating_slope, c.max_cooling_slope) + if settings.slope > max_slope: + errors.append( + f"step_settings[{idx}].slope={settings.slope}°C/s exceeds max {max_slope}°C/s" + ) + + if errors: + raise ValueError(f"ODTCConfig validation failed:\n - " + "\n - ".join(errors)) + + return errors + + +# ============================================================================= +# 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 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]: + """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 args[0] + if origin is Union and type(None) in args: + # Optional[T] is Union[T, None] + return next((a for a in args if a is not type(None)), 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 f in fields(cls): + 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] = [] + + 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 f in fields(type(obj)): + 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 (handles PIDSet wrapper) +# ============================================================================= + + +def _parse_method(elem: ET.Element) -> ODTCMethod: + """Parse a Method element, handling the PIDSet wrapper for PID elements.""" + # First parse the basic fields + method = from_xml(elem, ODTCMethod) + + # Handle PIDSet wrapper specially + pid_set_elem = elem.find("PIDSet") + if pid_set_elem is not None: + pids = [] + for pid_elem in pid_set_elem.findall("PID"): + pids.append(from_xml(pid_elem, ODTCPID)) + method.pid_set = pids + + return method + + +def _method_to_xml(method: ODTCMethod, parent: ET.Element) -> ET.Element: + """Serialize a Method to XML, handling the PIDSet wrapper.""" + elem = ET.SubElement(parent, "Method") + + # Set attributes + elem.set("methodName", method.name) + if method.creator: + elem.set("creator", method.creator) + if method.description: + elem.set("description", method.description) + if method.datetime: + elem.set("dateTime", method.datetime) + + # Add child elements + ET.SubElement(elem, "Variant").text = str(method.variant) + ET.SubElement(elem, "PlateType").text = str(method.plate_type) + ET.SubElement(elem, "FluidQuantity").text = str(method.fluid_quantity) + ET.SubElement(elem, "PostHeating").text = "true" if method.post_heating else "false" + ET.SubElement(elem, "StartBlockTemperature").text = str(method.start_block_temperature) + ET.SubElement(elem, "StartLidTemperature").text = str(method.start_lid_temperature) + + # Add steps + for step in method.steps: + to_xml(step, "Step", elem) + + # Add PIDSet wrapper if there are PIDs + if method.pid_set: + pid_set_elem = ET.SubElement(elem, "PIDSet") + for pid in method.pid_set: + to_xml(pid, "PID", pid_set_elem) + + return elem + + +# ============================================================================= +# Convenience Functions +# ============================================================================= + + +def parse_method_set(xml_str: str) -> ODTCMethodSet: + """Parse a MethodSet XML string.""" + root = ET.fromstring(xml_str) + + # Parse DeleteAllMethods + 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" + + # Parse PreMethods + premethods = [from_xml(pm, ODTCPreMethod) for pm in root.findall("PreMethod")] + + # Parse Methods (with special PIDSet handling) + methods = [_parse_method(m) for m in root.findall("Method")] + + return ODTCMethodSet( + delete_all_methods=delete_all, + premethods=premethods, + methods=methods, + ) + + +def parse_method_set_file(filepath: str) -> ODTCMethodSet: + """Parse a MethodSet XML file.""" + tree = ET.parse(filepath) + root = tree.getroot() + + # Parse DeleteAllMethods + 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" + + # Parse PreMethods + premethods = [from_xml(pm, ODTCPreMethod) for pm in root.findall("PreMethod")] + + # Parse Methods (with special PIDSet handling) + methods = [_parse_method(m) for m in root.findall("Method")] + + return ODTCMethodSet( + delete_all_methods=delete_all, + premethods=premethods, + methods=methods, + ) + + +def method_set_to_xml(method_set: ODTCMethodSet) -> str: + """Serialize a MethodSet to XML string.""" + root = ET.Element("MethodSet") + + # Add DeleteAllMethods + ET.SubElement(root, "DeleteAllMethods").text = "true" if method_set.delete_all_methods else "false" + + # Add PreMethods + for pm in method_set.premethods: + to_xml(pm, "PreMethod", root) + + # Add Methods (with special PIDSet handling) + for m in method_set.methods: + _method_to_xml(m, root) + + return ET.tostring(root, encoding="unicode", xml_declaration=True) + + +def parse_sensor_values(xml_str: str) -> ODTCSensorValues: + """Parse SensorValues XML string.""" + root = ET.fromstring(xml_str) + return from_xml(root, ODTCSensorValues) + + +# ============================================================================= +# Method Lookup Helpers +# ============================================================================= + + +def get_method_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCMethod]: + """Find a method by name.""" + return next((m for m in method_set.methods if m.name == name), None) + + +def get_premethod_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCPreMethod]: + """Find a premethod by name.""" + return next((pm for pm in method_set.premethods if pm.name == name), None) + + +def list_method_names(method_set: ODTCMethodSet) -> List[str]: + """Get all method names.""" + return [m.name for m in method_set.methods] + + +def list_premethod_names(method_set: ODTCMethodSet) -> List[str]: + """Get all premethod names.""" + return [pm.name for pm in method_set.premethods] + + +# ============================================================================= +# 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_method( + protocol: "Protocol", + config: Optional[ODTCConfig] = None, +) -> ODTCMethod: + """Convert a standard Protocol to an ODTCMethod. + + Args: + protocol: Standard Protocol object with stages and steps. + config: Optional ODTC config for device-specific parameters. + If None, defaults are used. + + Returns: + ODTCMethod ready for upload to ODTC device. + + Note: + This function handles sequential stages with repeats. Each stage with + repeats > 1 is converted to an ODTC loop using GotoNumber/LoopNumber. + """ + # Import here to avoid circular imports + from pylabrobot.thermocycling.standard import Protocol + + if config is None: + config = ODTCConfig() + + odtc_steps: List[ODTCStep] = [] + step_number = 1 + + # Track previous temperature for slope calculation + # Start from room temperature - first step needs to ramp from ambient + prev_temp = 25.0 + + for stage_idx, stage in enumerate(protocol.stages): + stage_start_step = step_number + + for step_idx, step in enumerate(stage.steps): + # Get the target temperature (use first zone for ODTC single-zone) + target_temp = step.temperature[0] if step.temperature else 25.0 + + # Calculate slope + slope = _calculate_slope(prev_temp, target_temp, step.rate, config) + + # Get step settings overrides if any + # Use global step index (across all stages) + global_step_idx = step_number - 1 + step_setting = config.step_settings.get(global_step_idx, ODTCStepSettings()) + + # Create ODTC step with defaults or overrides + odtc_step = ODTCStep( + number=step_number, + slope=step_setting.slope if step_setting.slope is not None else slope, + plateau_temperature=target_temp, + plateau_time=step.hold_seconds, + overshoot_slope1=( + step_setting.overshoot_slope1 if step_setting.overshoot_slope1 is not None else 0.1 + ), + overshoot_temperature=( + step_setting.overshoot_temperature if step_setting.overshoot_temperature is not None else 0.0 + ), + overshoot_time=( + step_setting.overshoot_time if step_setting.overshoot_time is not None else 0.0 + ), + overshoot_slope2=( + step_setting.overshoot_slope2 if step_setting.overshoot_slope2 is not None else 0.1 + ), + goto_number=0, # Will be set below for loops + loop_number=0, # Will be set below for loops + pid_number=step_setting.pid_number if step_setting.pid_number is not None else 1, + lid_temp=( + step_setting.lid_temp if step_setting.lid_temp is not None else config.lid_temperature + ), + ) + + odtc_steps.append(odtc_step) + prev_temp = target_temp + step_number += 1 + + # If stage has repeats > 1, add loop on the last step of the stage + if stage.repeats > 1 and odtc_steps: + last_step = odtc_steps[-1] + last_step.goto_number = stage_start_step + last_step.loop_number = stage.repeats - 1 # ODTC uses 0-based loop count + + # 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 + ) + + return ODTCMethod( + name=config.name, + variant=config.variant, + plate_type=config.plate_type, + fluid_quantity=config.fluid_quantity, + post_heating=config.post_heating, + start_block_temperature=start_block_temp, + start_lid_temperature=start_lid_temp, + steps=odtc_steps, + pid_set=list(config.pid_set), # Copy the list + creator=config.creator, + description=config.description, + datetime=config.datetime, + ) + + +def _validate_no_nested_loops(method: ODTCMethod) -> None: + """Validate that an ODTCMethod has no nested loops. + + Args: + method: The ODTCMethod to validate. + + Raises: + ValueError: If the method contains nested/overlapping loops. + """ + loops = [] + for step in method.steps: + if step.goto_number > 0: + # (start_step, end_step, repeat_count) - using 1-based step numbers + loops.append((step.goto_number, step.number, step.loop_number + 1)) + + # Check all pairs for nesting/overlap + for i, (start1, end1, _) in enumerate(loops): + for j, (start2, end2, _) in enumerate(loops): + if i >= j: + continue # Only check each pair once + + # Check for any kind of nesting or overlap: + # 1. Loop 2 fully contained in loop 1: start1 <= start2 AND end2 <= end1 + # (and they're not identical) + # 2. Loop 1 fully contained in loop 2: start2 <= start1 AND end1 <= end2 + # 3. Partial overlap: start1 < start2 < end1 < end2 + # 4. Partial overlap: start2 < start1 < end2 < end1 + + # For sequential loops, ranges don't overlap: end1 < start2 OR end2 < start1 + ranges_overlap = not (end1 < start2 or end2 < start1) + + # If ranges overlap and they're not identical, that's a problem + if ranges_overlap and not (start1 == start2 and end1 == end2): + raise ValueError( + f"ODTCMethod '{method.name}' contains nested loops " + f"(steps {start1}-{end1} and {start2}-{end2}) which cannot be " + "converted to Protocol. Use ODTCMethod directly." + ) + + +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: + loops.append((step.goto_number, step.number, step.loop_number + 1)) + return sorted(loops, key=lambda x: x[1]) # Sort by end position + + +def odtc_method_to_protocol( + method: ODTCMethod, +) -> Tuple["Protocol", ODTCConfig]: + """Convert an ODTCMethod to a Protocol with companion config for lossless round-trip. + + Args: + method: The ODTCMethod to convert. + + Returns: + Tuple of (Protocol, ODTCConfig) where the config captures + all ODTC-specific parameters needed to reconstruct the original method. + + Raises: + ValueError: If method contains nested loops that can't be expressed in Protocol. + + Note: + For methods without nested loops, the conversion is lossless. The returned + config captures all ODTC-specific parameters (slopes, overshoot, PID, etc.) + so that protocol_to_odtc_method(protocol, config) produces an equivalent method. + """ + # Import here to avoid circular imports + from pylabrobot.thermocycling.standard import Protocol, Stage, Step + + # Validate no nested loops + _validate_no_nested_loops(method) + + # Analyze loop structure + loops = _analyze_loop_structure(method.steps) + + # Build step settings for all ODTC-specific parameters + step_settings: Dict[int, ODTCStepSettings] = {} + for i, step in enumerate(method.steps): + step_settings[i] = ODTCStepSettings( + slope=step.slope, + overshoot_slope1=step.overshoot_slope1, + overshoot_temperature=step.overshoot_temperature, + overshoot_time=step.overshoot_time, + overshoot_slope2=step.overshoot_slope2, + lid_temp=step.lid_temp, + pid_number=step.pid_number, + ) + + # Build the config capturing all method-level parameters + config = ODTCConfig( + name=method.name, + creator=method.creator, + description=method.description, + datetime=method.datetime, + fluid_quantity=method.fluid_quantity, + variant=method.variant, + plate_type=method.plate_type, + lid_temperature=method.start_lid_temperature, + start_lid_temperature=method.start_lid_temperature, + post_heating=method.post_heating, + pid_set=list(method.pid_set) if method.pid_set else [ODTCPID(number=1)], + step_settings=step_settings, + _validate=False, # Skip validation for data read from device + ) + + # Group steps into stages based on loop boundaries + # Create a map of which step ends a loop and what its repeat count is + loop_ends: Dict[int, int] = {} # step_number -> repeat_count + loop_starts: Dict[int, int] = {} # end_step_number -> start_step_number + + for start, end, repeats in loops: + loop_ends[end] = repeats + loop_starts[end] = start + + # Build stages + stages: List[Stage] = [] + current_stage_steps: List[Step] = [] + current_stage_start = 1 # 1-based step number where current stage starts + + for i, odtc_step in enumerate(method.steps): + step_number = odtc_step.number # 1-based + + # Create Protocol Step + protocol_step = Step( + temperature=[odtc_step.plateau_temperature], + hold_seconds=odtc_step.plateau_time, + rate=odtc_step.slope, + ) + current_stage_steps.append(protocol_step) + + # Check if this step ends a loop + if step_number in loop_ends: + loop_start = loop_starts[step_number] + repeats = loop_ends[step_number] + + # If the loop starts at the beginning of current stage, this is a repeating stage + if loop_start == current_stage_start: + # This entire stage repeats + stages.append(Stage(steps=current_stage_steps, repeats=repeats)) + current_stage_steps = [] + current_stage_start = step_number + 1 + else: + # Loop doesn't start at stage beginning - need to split + # Steps before loop_start form a non-repeating stage + # Steps from loop_start to here form a repeating stage + pre_loop_count = loop_start - current_stage_start + if pre_loop_count > 0: + pre_loop_steps = current_stage_steps[:pre_loop_count] + stages.append(Stage(steps=pre_loop_steps, repeats=1)) + + loop_steps = current_stage_steps[pre_loop_count:] + stages.append(Stage(steps=loop_steps, repeats=repeats)) + + current_stage_steps = [] + current_stage_start = step_number + 1 + + # Add any remaining steps as a final stage + if current_stage_steps: + stages.append(Stage(steps=current_stage_steps, repeats=1)) + + protocol = Protocol(stages=stages) + return protocol, config From 1358d29b8948cbba06b54cda5dcf79e2e5ee5c08 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:20:50 -0800 Subject: [PATCH 02/28] Consolidate classes, async commands and rquest_id handling --- pylabrobot/thermocycling/inheco/__init__.py | 4 +- .../thermocycling/inheco/docs/README.md | 527 ++++++++++++++++++ pylabrobot/thermocycling/inheco/odtc.py | 281 +++++++--- .../thermocycling/inheco/odtc_backend.py | 171 +++++- .../inheco/odtc_backend_tests.py | 174 +++++- .../inheco/odtc_sila_interface.py | 55 +- 6 files changed, 1101 insertions(+), 111 deletions(-) create mode 100644 pylabrobot/thermocycling/inheco/docs/README.md diff --git a/pylabrobot/thermocycling/inheco/__init__.py b/pylabrobot/thermocycling/inheco/__init__.py index 3268864ad0f..69832bb7b34 100644 --- a/pylabrobot/thermocycling/inheco/__init__.py +++ b/pylabrobot/thermocycling/inheco/__init__.py @@ -1,6 +1,6 @@ """Inheco ODTC thermocycler implementation.""" -from .odtc import InhecoODTC384, InhecoODTC96 +from .odtc import InhecoODTC from .odtc_backend import ODTCBackend -__all__ = ["InhecoODTC96", "InhecoODTC384", "ODTCBackend"] +__all__ = ["InhecoODTC", "ODTCBackend"] diff --git a/pylabrobot/thermocycling/inheco/docs/README.md b/pylabrobot/thermocycling/inheco/docs/README.md new file mode 100644 index 00000000000..3283068b953 --- /dev/null +++ b/pylabrobot/thermocycling/inheco/docs/README.md @@ -0,0 +1,527 @@ +# ODTC (On-Deck Thermocycler) Implementation Guide + +## Overview + +The ODTC implementation provides a complete interface for controlling Inheco ODTC thermocyclers via the SiLA (Standard for Laboratory Automation) protocol. It supports: + +- **Asynchronous method execution** with blocking and non-blocking modes +- **Round-trip protocol conversion** between ODTC XML format and PyLabRobot's generic `Protocol` format +- **Lossless parameter preservation** including ODTC-specific overtemp/overshoot parameters +- **Parallel command execution** (e.g., reading temperatures during method execution) +- **State tracking and DataEvent collection** for monitoring method progress + +## Architecture + +### Components + +1. **`ODTCSiLAInterface`** (`odtc_sila_interface.py`) + - Low-level SiLA communication (SOAP over HTTP) + - Handles parallelism rules, state management, and lockId validation + - Tracks pending async commands and collects DataEvents + - Manages state transitions (Startup → Standby → Idle → Busy) + +2. **`ODTCBackend`** (`odtc_backend.py`) + - High-level device control interface + - Implements `ThermocyclerBackend` interface + - Provides method execution, status checking, and data retrieval + - Handles protocol conversion and method upload/download + +3. **`InhecoODTC`** (`odtc.py`) + - Public-facing resource class + - Supports both 96-well and 384-well formats via `model` parameter + - Exposes backend methods with PyLabRobot resource interface + +4. **`odtc_xml.py`** + - XML serialization/deserialization for ODTC MethodSet format + - Conversion between `ODTCMethod` and generic `Protocol` + - `ODTCConfig` class for preserving ODTC-specific parameters + +## Connection and Setup + +### Basic Connection + +```python +from pylabrobot.thermocycling.inheco import InhecoODTC, ODTCBackend + +# Create thermocycler instance (96-well format) +tc = InhecoODTC( + name="odtc", + backend=ODTCBackend(odtc_ip="192.168.1.100"), + model="96" # Use "384" for 384-well format +) + +# Setup establishes HTTP event receiver and initializes device +await tc.setup() +# Device transitions: Startup → Standby → Idle +``` + +The `setup()` method: +1. Starts HTTP server for receiving SiLA events (ResponseEvent, StatusEvent, DataEvent) +2. Calls `Reset()` to register event receiver URI and move to Standby +3. Calls `Initialize()` to move to Idle (ready for commands) + +### Cleanup + +```python +await tc.stop() # Closes HTTP server and connections +``` + +## Running Commands + +### Synchronous Commands + +Most commands are synchronous and return immediately: + +```python +# Get device status +status = await tc.get_status() # Returns "idle", "busy", "standby", etc. + +# Read temperatures +temps = await tc.read_temperatures() # Returns ODTCSensorValues + +# Open/close door +await tc.open_door() +await tc.close_door() +``` + +### Asynchronous Method Execution + +The ODTC supports both blocking and non-blocking method execution: + +#### Blocking Execution (Default) + +```python +# Block until method completes +await tc.execute_method("PCR_30cycles", wait=True) +# Returns None when complete +``` + +#### Non-Blocking Execution with Handle + +```python +# Start method and get execution handle +execution = await tc.execute_method("PCR_30cycles", wait=False) +# Returns MethodExecution handle immediately + +# Do parallel operations while method runs +temps = await tc.read_temperatures() # Allowed in parallel! +await tc.open_door() # Allowed in parallel! + +# Wait for completion (multiple options) +await execution # Await the handle directly +# OR +await execution.wait() # Explicit wait method +# OR +await tc.wait_for_method_completion() # Poll-based wait +``` + +#### MethodExecution Handle + +The `MethodExecution` handle provides: + +- **`request_id`**: SiLA request ID for tracking DataEvents +- **`method_name`**: Name of executing method +- **Awaitable interface**: Can be awaited like `asyncio.Task` +- **`is_running()`**: Check if method is still running +- **`wait()`**: Explicit wait for completion + +```python +execution = await tc.execute_method("PCR_30cycles", wait=False) + +# Check status +if await execution.is_running(): + print(f"Method {execution.method_name} still running (ID: {execution.request_id})") + +# Get DataEvents for this execution +events = await tc.get_data_events(execution.request_id) + +# Wait for completion +await execution +``` + +### State Checking + +```python +# Check if method is running +is_running = await tc.is_method_running() # Returns True if state is "busy" + +# Wait for method completion with polling +await tc.wait_for_method_completion( + poll_interval=5.0, # Check every 5 seconds + timeout=3600.0 # Timeout after 1 hour +) +``` + +### Parallel Operations + +Per ODTC SiLA spec, certain commands can run in parallel with `ExecuteMethod`: + +- ✅ `ReadActualTemperature` - Read temperatures during execution +- ✅ `OpenDoor` / `CloseDoor` - Door operations +- ✅ `StopMethod` - Stop current method +- ❌ `SetParameters` / `GetParameters` - Sequential +- ❌ `GetLastData` - Sequential +- ❌ Another `ExecuteMethod` - Only one method at a time + +```python +# Start method +execution = await tc.execute_method("PCR_30cycles", wait=False) + +# These can run in parallel: +temps = await tc.read_temperatures() +await tc.open_door() + +# These will queue/wait: +method2 = await tc.execute_method("PCR_40cycles", wait=False) # Waits for method1 +``` + +## Getting Protocols from Device + +### Get Full MethodSet + +```python +# Download all methods and premethods from device +method_set = await tc.get_method_set() # Returns ODTCMethodSet + +# Access methods +for method in method_set.methods: + print(f"Method: {method.name}, Steps: {len(method.steps)}") + +for premethod in method_set.premethods: + print(f"PreMethod: {premethod.name}") +``` + +### Get Specific Method by Name + +```python +# Get a specific method +method = await tc.get_method_by_name("PCR_30cycles") +if method: + print(f"Found method: {method.name}") +``` + +### Convert to Protocol + Config + +```python +from pylabrobot.thermocycling.inheco.odtc_xml import odtc_method_to_protocol + +# Get method from device +method = await tc.get_method_by_name("PCR_30cycles") + +# Convert to Protocol + ODTCConfig (lossless) +protocol, config = odtc_method_to_protocol(method) + +# protocol: Generic PyLabRobot Protocol (stages, steps, temperatures, times) +# config: ODTCConfig preserving all ODTC-specific parameters +``` + +## Running Protocols + +### Upload and Execute from XML File + +```python +# Upload MethodSet XML file to device +await tc.upload_method_set_from_file("my_methods.xml") + +# Execute a method +await tc.execute_method("PCR_30cycles") +``` + +### Upload and Execute from ODTCMethodSet Object + +```python +from pylabrobot.thermocycling.inheco.odtc_xml import parse_method_set_file + +# Parse XML file +method_set = parse_method_set_file("my_methods.xml") + +# Upload to device +await tc.upload_method_set(method_set) + +# Execute +await tc.execute_method("PCR_30cycles") +``` + +### Convert Protocol to ODTC and Execute + +```python +from pylabrobot.thermocycling.inheco.odtc_xml import protocol_to_odtc_method +from pylabrobot.thermocycling.standard import Protocol, Stage, Step + +# Create a Protocol +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 + ) + ] +) + +# Convert to ODTCMethod (with default config) +# Note: Overshoot parameters will use defaults (minimal overshoot) +# Future work will automatically derive optimal overshoot parameters +odtc_method = protocol_to_odtc_method(protocol) + +# Upload method to device (need to wrap in MethodSet) +from pylabrobot.thermocycling.inheco.odtc_xml import ODTCMethodSet +method_set = ODTCMethodSet(methods=[odtc_method], premethods=[]) +await tc.upload_method_set(method_set) + +# Execute +await tc.execute_method(odtc_method.name) +``` + +**Note on performance:** Protocols created directly in PyLabRobot (without an `ODTCConfig` from an existing XML protocol) will use default overshoot parameters, which may result in slower heating compared to manually-tuned ODTC protocols. Future enhancements will automatically derive optimal overshoot parameters for improved thermal performance. + +## XML to Protocol + Config Conversion + +### Lossless Round-Trip Conversion + +The conversion system ensures **lossless round-trip** conversion between ODTC XML format and PyLabRobot's generic `Protocol` format. This is achieved through the `ODTCConfig` companion object that preserves all ODTC-specific parameters. + +### How It Works + +#### 1. ODTC → Protocol + Config + +```python +from pylabrobot.thermocycling.inheco.odtc_xml import odtc_method_to_protocol + +# Convert ODTC method to Protocol + Config +protocol, config = odtc_method_to_protocol(odtc_method) +``` + +**What gets preserved in `ODTCConfig`:** + +- **Method-level parameters:** + - `name`, `creator`, `description`, `datetime` + - `fluid_quantity`, `variant`, `plate_type` + - `lid_temperature`, `start_lid_temperature` + - `post_heating` + - `pid_set` (PID controller parameters) + +- **Per-step parameters** (stored in `config.step_settings[step_index]`): + - `slope` - Temperature ramp rate (°C/s) + - `overshoot_slope1` - First overshoot ramp rate + - `overshoot_temperature` - Overshoot target temperature + - `overshoot_time` - Overshoot hold time + - `overshoot_slope2` - Second overshoot ramp rate + - `lid_temp` - Lid temperature for this step + - `pid_number` - PID controller to use + +**What goes into `Protocol`:** + +- Temperature targets (from `plateau_temperature`) +- Hold times (from `plateau_time`) +- Stage structure (from loop analysis) +- Repeat counts (from `loop_number`) + +#### 2. Protocol + Config → ODTC + +```python +from pylabrobot.thermocycling.inheco.odtc_xml import protocol_to_odtc_method + +# Convert back to ODTC method (lossless if config preserved) +odtc_method = protocol_to_odtc_method(protocol, config=config) +``` + +The conversion uses: +- `Protocol` for temperature/time structure +- `ODTCConfig.step_settings` for per-step overtemp parameters +- `ODTCConfig` defaults for method-level parameters + +### Overtemp/Overshoot Parameter Preservation + +**Overtemp parameters** (overshoot settings) are ODTC-specific features that allow temperature overshooting for faster heating and improved thermal performance: + +- **`overshoot_temperature`**: Target temperature to overshoot to +- **`overshoot_time`**: How long to hold at overshoot temperature +- **`overshoot_slope1`**: Ramp rate to overshoot temperature +- **`overshoot_slope2`**: Ramp rate back to target temperature + +These parameters are **not part of the generic Protocol** (which only has target temperature and hold time), so they are preserved in `ODTCConfig.step_settings`. + +**Why preservation matters:** + +When converting existing ODTC XML protocols to PyLabRobot `Protocol` format, **preserving overshoot parameters is critical for maintaining equivalent thermal performance**. Without these parameters, the converted protocol may have different heating characteristics, potentially affecting PCR efficiency or other temperature-sensitive reactions. + +**Current behavior:** +- ✅ **Preserved from XML**: When converting ODTC XML → Protocol+Config, all overshoot parameters are captured in `ODTCConfig.step_settings` +- ✅ **Restored to XML**: When converting Protocol+Config → ODTC XML, overshoot parameters are restored from `ODTCConfig.step_settings` +- ⚠️ **Not generated**: When creating new protocols in PyLabRobot, overshoot parameters default to minimal values (0.0 for temperature/time, 0.1 for slopes) + +**Future work:** +- 🔮 **Automatic derivation**: Future enhancements will automatically derive optimal overshoot parameters for PyLabRobot-created protocols based on: + - Temperature transitions (large jumps benefit more from overshoot) + - Hardware constraints (variant-specific limits) + - Thermal characteristics (fluid quantity, plate type) +- 🔮 **Performance optimization**: This will enable PyLabRobot-created protocols to achieve equivalent or improved thermal performance compared to manually-tuned ODTC protocols + +**Example of preservation:** + +```python +# When converting ODTC → Protocol + Config +protocol, config = odtc_method_to_protocol(odtc_method) + +# Overtemp params stored per step (preserved from original XML) +step_0_overtemp = config.step_settings[0] +print(step_0_overtemp.overshoot_temperature) # e.g., 100.0 (from original XML) +print(step_0_overtemp.overshoot_time) # e.g., 5.0 (from original XML) + +# When converting back Protocol + Config → ODTC +odtc_method_restored = protocol_to_odtc_method(protocol, config=config) + +# Overtemp params restored from config.step_settings +# This ensures equivalent thermal performance to original +assert odtc_method_restored.steps[0].overshoot_temperature == 100.0 +``` + +**Important:** Always preserve the `ODTCConfig` when modifying protocols converted from ODTC XML to maintain equivalent thermal performance. If you create a new protocol without a config, overshoot parameters will use defaults which may result in slower heating. + +### Example: Round-Trip Conversion + +```python +from pylabrobot.thermocycling.inheco.odtc_xml import ( + odtc_method_to_protocol, + protocol_to_odtc_method, + method_set_to_xml, + parse_method_set +) + +# 1. Get method from device (or parse from XML) +method_set = await tc.get_method_set() +method = method_set.methods[0] + +# 2. Convert to Protocol + Config +protocol, config = odtc_method_to_protocol(method) + +# 3. Modify protocol (generic changes) +protocol.stages[0].repeats = 35 # Change cycle count + +# 4. Convert back to ODTC (preserves all ODTC-specific params) +method_modified = protocol_to_odtc_method(protocol, config=config) + +# 5. Upload and execute +method_set_modified = ODTCMethodSet(methods=[method_modified], premethods=[]) +await tc.upload_method_set(method_set_modified) +await tc.execute_method(method_modified.name) +``` + +### Round-Trip from Device XML + +```python +# Full round-trip: Device XML → Protocol+Config → Device XML + +# 1. Get from device +method_set = await tc.get_method_set() +method = method_set.methods[0] + +# 2. Convert to Protocol + Config +protocol, config = odtc_method_to_protocol(method) + +# 3. Convert back to XML +method_restored = protocol_to_odtc_method(protocol, config=config) +method_set_restored = ODTCMethodSet(methods=[method_restored], premethods=[]) +xml_restored = method_set_to_xml(method_set_restored) + +# 4. Verify round-trip (should be equivalent) +# (Note: XML formatting may differ, but content should match) +``` + +## DataEvent Collection + +During method execution, the ODTC sends `DataEvent` messages containing experimental data. These are automatically collected: + +```python +# Start method +execution = await tc.execute_method("PCR_30cycles", wait=False) + +# Get DataEvents for this execution +events = await tc.get_data_events(execution.request_id) +# Returns: {request_id: [event1, event2, ...]} + +# Get all collected events +all_events = await tc.get_data_events() +# Returns: {request_id1: [...], request_id2: [...]} +``` + +**Note:** DataEvent parsing and progress tracking are planned for future implementation. Currently, raw event payloads are stored for later analysis. + +## Error Handling + +The implementation handles SiLA return codes and state transitions: + +- **Return code 1**: Synchronous success (GetStatus, GetDeviceIdentification) +- **Return code 2**: Asynchronous command accepted (ExecuteMethod, OpenDoor, etc.) +- **Return code 3**: Asynchronous command completed successfully (in ResponseEvent) +- **Return code 4**: Device busy (command rejected due to parallelism) +- **Return code 5**: LockId mismatch +- **Return code 6**: Invalid/duplicate requestId +- **Return code 9**: Command not allowed in current state + +State transitions are tracked automatically: +- `Startup` → `Standby` (via Reset) +- `Standby` → `Idle` (via Initialize) +- `Idle` → `Busy` (when async command starts) +- `Busy` → `Idle` (when all commands complete) + +## Best Practices + +1. **Always call `setup()`** before using the device +2. **Use `wait=False`** for long-running methods to enable parallel operations +3. **Check state** with `is_method_running()` before starting new methods +4. **Preserve `ODTCConfig`** when converting protocols to maintain ODTC-specific parameters (especially overshoot parameters for equivalent thermal performance) +5. **Handle timeouts** when waiting for method completion +6. **Clean up** with `stop()` when done + +### Protocol Conversion Best Practices + +- **When converting from ODTC XML**: Always preserve the returned `ODTCConfig` alongside the `Protocol` to maintain overshoot parameters and ensure equivalent thermal performance +- **When modifying converted protocols**: Keep the original `ODTCConfig` and only modify the `Protocol` structure (temperatures, times, repeats) +- **When creating new protocols**: Be aware that overshoot parameters will use defaults until automatic derivation is implemented (future work) + +## Complete Example + +```python +from pylabrobot.thermocycling.inheco import InhecoODTC, ODTCBackend +from pylabrobot.thermocycling.inheco.odtc_xml import odtc_method_to_protocol + +# Setup +tc = InhecoODTC( + name="odtc", + backend=ODTCBackend(odtc_ip="192.168.1.100"), + model="96" # Use "384" for 384-well format +) +await tc.setup() + +# Get method from device +method = await tc.get_method_by_name("PCR_30cycles") +if not method: + raise ValueError("Method not found") + +# Convert to Protocol + Config +protocol, config = odtc_method_to_protocol(method) + +# Modify protocol +protocol.stages[0].repeats = 35 + +# Convert back (preserves all ODTC params including overtemp) +from pylabrobot.thermocycling.inheco.odtc_xml import protocol_to_odtc_method, ODTCMethodSet +method_modified = protocol_to_odtc_method(protocol, config=config) + +# Upload and execute +method_set = ODTCMethodSet(methods=[method_modified], premethods=[]) +await tc.upload_method_set(method_set) + +# Execute with parallel operations +execution = await tc.execute_method(method_modified.name, wait=False) +temps = await tc.read_temperatures() # Parallel operation +await execution # Wait for completion + +# Cleanup +await tc.stop() +``` diff --git a/pylabrobot/thermocycling/inheco/odtc.py b/pylabrobot/thermocycling/inheco/odtc.py index 38727ecd9c1..4b0ff970d81 100644 --- a/pylabrobot/thermocycling/inheco/odtc.py +++ b/pylabrobot/thermocycling/inheco/odtc.py @@ -1,20 +1,37 @@ """Inheco ODTC (On-Deck Thermocycler) resource class.""" -from typing import Optional +from typing import Any, Dict, List, Literal, Optional from pylabrobot.resources import Coordinate, ItemizedResource from pylabrobot.thermocycling.thermocycler import Thermocycler from .odtc_backend import ODTCBackend +from .odtc_xml import ODTCMethodSet, ODTCConfig -class InhecoODTC96(Thermocycler): - """Inheco ODTC 96-well On-Deck Thermocycler. +# Mapping from model string to variant integer (960000 for 96-well, 384000 for 384-well) +_MODEL_TO_VARIANT: Dict[str, int] = { + "96": 960000, + "384": 384000, +} + + +class InhecoODTC(Thermocycler): + """Inheco ODTC (On-Deck Thermocycler). The ODTC is a compact thermocycler designed for integration into liquid handling systems. It features a motorized drawer for plate access and supports PCR protocols via XML-defined methods. + Available models: + - "96": 96-well plate format (variant=960000) + - "384": 384-well plate format (variant=384000) + + The model parameter affects: + - Default hardware constraints (max heating slope, max lid temp) + - Default variant used in protocol conversion + - Resource identification in PyLabRobot + Approximate dimensions: - Width (X): 147 mm - Depth (Y): 298 mm @@ -22,23 +39,30 @@ class InhecoODTC96(Thermocycler): Example usage: ```python - from pylabrobot.thermocycling.inheco import InhecoODTC96, ODTCBackend + from pylabrobot.thermocycling.inheco import InhecoODTC, ODTCBackend + from pylabrobot.thermocycling.inheco.odtc_xml import protocol_to_odtc_method - # Create backend and thermocycler + # Create backend and thermocycler (384-well) backend = ODTCBackend(odtc_ip="192.168.1.100") - tc = InhecoODTC96(name="odtc1", backend=backend) + tc = InhecoODTC(name="odtc1", backend=backend, model="384") # Initialize await tc.setup() - # Upload and run protocols - await backend.upload_method_set_from_file("protocols.xml") - await backend.execute_method("PRE25") # Set initial temperatures - await backend.execute_method("PCR_30cycles") # Run PCR + # Create a protocol with model-aware defaults + from pylabrobot.thermocycling.standard import Protocol, Stage, Step + protocol = Protocol(stages=[ + Stage(steps=[Step(temperature=[95.0], hold_seconds=30.0)], repeats=1) + ]) + + # Convert to ODTC method using model's variant (384000) + config = tc.get_default_config(name="my_protocol") + method = protocol_to_odtc_method(protocol, config=config) + # method.variant will be 384000 (not 960000) - # Read temperatures - temp = await tc.get_block_current_temperature() - print(f"Block temperature: {temp[0]}°C") + # Upload and execute + await tc.upload_method_set(ODTCMethodSet(methods=[method], premethods=[])) + await tc.execute_method("my_protocol") # Clean up await tc.stop() @@ -49,6 +73,7 @@ def __init__( self, name: str, backend: ODTCBackend, + model: Literal["96", "384"] = "96", child_location: Coordinate = Coordinate(x=10.0, y=10.0, z=50.0), child: Optional[ItemizedResource] = None, ): @@ -57,10 +82,12 @@ def __init__( Args: name: Human-readable name for this resource. backend: ODTCBackend instance configured with device IP. + model: ODTC model variant - "96" for 96-well or "384" for 384-well format. child_location: Position where a plate sits on the block. Defaults to approximate center of the block area. child: Optional plate/rack already loaded on the module. """ + model_name = f"InhecoODTC{model}" super().__init__( name=name, size_x=147.0, # mm - approximate width @@ -69,10 +96,13 @@ def __init__( backend=backend, child_location=child_location, category="thermocycler", - model="InhecoODTC96", + model=model_name, ) self.backend: ODTCBackend = backend + self.model: Literal["96", "384"] = model + # Get variant integer from model string via lookup + self._variant: int = _MODEL_TO_VARIANT[model] self.child = child if child is not None: self.assign_child_resource(child, location=child_location) @@ -87,21 +117,48 @@ def serialize(self) -> dict: # Convenience methods that expose ODTC-specific functionality - async def execute_method(self, method_name: str) -> None: + async def execute_method( + self, + method_name: str, + priority: Optional[int] = None, + wait: bool = True, + ): """Execute a method or premethod by name. Args: method_name: Name of the method or premethod to execute. + priority: Priority (not used by ODTC, but part of SiLA spec). + wait: If True, block until completion. If False, return MethodExecution handle. + + Returns: + If wait=True: None (blocks until complete) + If wait=False: MethodExecution handle (awaitable, has request_id) """ - await self.backend.execute_method(method_name) + return await self.backend.execute_method(method_name, priority, wait) async def stop_method(self) -> None: """Stop any currently running method.""" await self.backend.stop_method() - async def list_methods(self) -> tuple: - """Return (premethod_names, method_names) available on device.""" - return await self.backend.list_methods() + async def get_method_set(self): + """Get the full MethodSet from the device.""" + return await self.backend.get_method_set() + + async def get_method_by_name(self, method_name: str): + """Get a specific method by name from the device.""" + return await self.backend.get_method_by_name(method_name) + + async def is_method_running(self) -> bool: + """Check if a method is currently running.""" + return await self.backend.is_method_running() + + async def wait_for_method_completion( + self, + poll_interval: float = 5.0, + timeout: Optional[float] = None, + ) -> None: + """Wait until method execution completes.""" + await self.backend.wait_for_method_completion(poll_interval, timeout) async def upload_method_set_from_file(self, filepath: str) -> None: """Load a MethodSet XML file and upload to device.""" @@ -111,6 +168,14 @@ async def save_method_set_to_file(self, filepath: str) -> None: """Download methods from device and save to file.""" await self.backend.save_method_set_to_file(filepath) + async def get_status(self) -> str: + """Get device status state. + + Returns: + Device state string (e.g., "Idle", "Busy", "Standby"). + """ + return await self.backend.get_status() + async def read_temperatures(self): """Read all temperature sensors. @@ -119,74 +184,140 @@ async def read_temperatures(self): """ return await self.backend.read_temperatures() + # Device control methods -class InhecoODTC384(Thermocycler): - """Inheco ODTC 384-well On-Deck Thermocycler. - - Similar to the 96-well variant but configured for 384-well plates. - """ + async def initialize(self) -> None: + """Initialize the device (must be in Standby state).""" + await self.backend.initialize() - def __init__( + async def reset( self, - name: str, - backend: ODTCBackend, - child_location: Coordinate = Coordinate(x=10.0, y=10.0, z=50.0), - child: Optional[ItemizedResource] = None, - ): - """Initialize the ODTC 384-well thermocycler. + device_id: str = "ODTC", + event_receiver_uri: Optional[str] = None, + simulation_mode: bool = False, + ) -> None: + """Reset the device. Args: - name: Human-readable name for this resource. - backend: ODTCBackend instance configured with device IP. - child_location: Position where a plate sits on the block. - child: Optional plate/rack already loaded on the module. + device_id: Device identifier. + event_receiver_uri: Event receiver URI (auto-detected if None). + simulation_mode: Enable simulation mode. """ - super().__init__( - name=name, - size_x=147.0, # mm - approximate width - size_y=298.0, # mm - approximate depth - size_z=130.0, # mm - approximate height - backend=backend, - child_location=child_location, - category="thermocycler", - model="InhecoODTC384", - ) + await self.backend.reset(device_id, event_receiver_uri, simulation_mode) - self.backend: ODTCBackend = backend - self.child = child - if child is not None: - self.assign_child_resource(child, location=child_location) + async def get_device_identification(self) -> dict: + """Get device identification information. - def serialize(self) -> dict: - """Return a serialized representation of the thermocycler.""" - return { - **super().serialize(), - "odtc_ip": self.backend._sila._machine_ip, - "port": self.backend._sila.bound_port, - } + Returns: + Device identification dictionary. + """ + return await self.backend.get_device_identification() - # Convenience methods (same as 96-well) + async def lock_device(self, lock_id: str, lock_timeout: Optional[float] = None) -> None: + """Lock the device for exclusive access. - async def execute_method(self, method_name: str) -> None: - """Execute a method or premethod by name.""" - await self.backend.execute_method(method_name) + Args: + lock_id: Unique lock identifier. + lock_timeout: Lock timeout in seconds (optional). + """ + await self.backend.lock_device(lock_id, lock_timeout) - async def stop_method(self) -> None: - """Stop any currently running method.""" - await self.backend.stop_method() + async def unlock_device(self) -> None: + """Unlock the device.""" + await self.backend.unlock_device() - async def list_methods(self) -> tuple: - """Return (premethod_names, method_names) available on device.""" - return await self.backend.list_methods() + # Door control methods - async def upload_method_set_from_file(self, filepath: str) -> None: - """Load a MethodSet XML file and upload to device.""" - await self.backend.upload_method_set_from_file(filepath) + async def open_door(self) -> None: + """Open the drawer door (equivalent to PrepareForOutput).""" + await self.backend.open_door() - async def save_method_set_to_file(self, filepath: str) -> None: - """Download methods from device and save to file.""" - await self.backend.save_method_set_to_file(filepath) + async def close_door(self) -> None: + """Close the drawer door (equivalent to PrepareForInput).""" + await self.backend.close_door() - async def read_temperatures(self): - """Read all temperature sensors.""" - return await self.backend.read_temperatures() + async def prepare_for_output(self, position: Optional[int] = None) -> None: + """Prepare for output (equivalent to OpenDoor). + + Args: + position: Position parameter (ignored by ODTC, but part of SiLA spec). + """ + await self.backend.prepare_for_output(position) + + async def prepare_for_input(self, position: Optional[int] = None) -> None: + """Prepare for input (equivalent to CloseDoor). + + Args: + position: Position parameter (ignored by ODTC, but part of SiLA spec). + """ + await self.backend.prepare_for_input(position) + + # Data retrieval methods + + async def get_data_events( + self, request_id: Optional[int] = None + ) -> Dict[int, List[Dict[str, Any]]]: + """Get collected DataEvents. + + Args: + request_id: If provided, return events for this request_id only. + If None, return all collected events. + + Returns: + Dict mapping request_id to list of DataEvent payloads. + """ + return await self.backend.get_data_events(request_id) + + async def get_last_data(self) -> str: + """Get temperature trace of last executed method (CSV format). + + Returns: + CSV string with temperature trace data. + """ + return await self.backend.get_last_data() + + # Method upload methods + + async def upload_method_set(self, method_set: ODTCMethodSet) -> None: + """Upload a MethodSet to the device. + + Args: + method_set: ODTCMethodSet to upload. + """ + await self.backend.upload_method_set(method_set) + + # Protocol conversion helpers with model-aware defaults + + def get_default_config(self, **kwargs) -> ODTCConfig: + """Get a default ODTCConfig with variant set to match this thermocycler's model. + + Args: + **kwargs: Additional parameters to override defaults (e.g., name, lid_temperature). + + Returns: + ODTCConfig with variant matching the thermocycler model (96 or 384-well). + + Example: + ```python + # For a 384-well ODTC, this returns config with variant=384000 + config = tc.get_default_config(name="my_protocol", lid_temperature=115.0) + method = protocol_to_odtc_method(protocol, config=config) + ``` + """ + return ODTCConfig(variant=self._variant, **kwargs) + + def get_constraints(self): + """Get hardware constraints for this thermocycler's model. + + Returns: + ODTCHardwareConstraints for the current model (96 or 384-well). + + Example: + ```python + constraints = tc.get_constraints() + print(f"Max heating slope: {constraints.max_heating_slope} °C/s") + print(f"Max lid temp: {constraints.max_lid_temp} °C") + ``` + """ + from .odtc_xml import get_constraints + return get_constraints(self._variant) diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index 064d3e7eee3..4936208741c 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -2,8 +2,10 @@ from __future__ import annotations +import asyncio import logging -from typing import List, Optional, Tuple +from dataclasses import dataclass +from typing import Any, Dict, List, Optional from pylabrobot.machines.backend import MachineBackend from pylabrobot.thermocycling.backend import ThermocyclerBackend @@ -11,8 +13,10 @@ from .odtc_sila_interface import ODTCSiLAInterface from .odtc_xml import ( + ODTCMethod, ODTCMethodSet, ODTCSensorValues, + get_method_by_name, method_set_to_xml, parse_method_set, parse_method_set_file, @@ -20,6 +24,34 @@ ) +@dataclass +class MethodExecution: + """Handle for an executing method that can be awaited or checked. + + This handle is returned from execute_method(wait=False) and provides: + - Awaitable interface (can be awaited like a Task) + - Request ID access for DataEvent tracking + - Status checking methods + """ + + request_id: int + method_name: str + _future: asyncio.Future[Any] + backend: "ODTCBackend" + + def __await__(self): + """Make this awaitable like a Task.""" + return self._future.__await__() + + async def wait(self) -> None: + """Wait for method completion.""" + await self._future + + async def is_running(self) -> bool: + """Check if method is still running.""" + return await self.backend.is_method_running() + + class ODTCBackend(ThermocyclerBackend): """ODTC backend using ODTC-specific SiLA interface. @@ -248,57 +280,153 @@ async def get_last_data(self) -> str: return str(resp) # type: ignore # Method control commands - async def execute_method(self, method_name: str, priority: Optional[int] = None) -> None: + async def execute_method( + self, + method_name: str, + priority: Optional[int] = None, + wait: bool = True, + ) -> Optional[MethodExecution]: """Execute a method or premethod by name. Args: method_name: Name of the method or premethod to execute. priority: Priority (not used by ODTC, but part of SiLA spec). + wait: If True, block until completion and return None. + If False, return MethodExecution handle immediately. + + Returns: + If wait=True: None (blocks until complete) + If wait=False: MethodExecution handle (awaitable, has request_id) """ params: dict = {"methodName": method_name} if priority is not None: params["priority"] = priority - await self._sila.send_command("ExecuteMethod", **params) + + if wait: + # Blocking: await send_command normally + await self._sila.send_command("ExecuteMethod", return_request_id=False, **params) + return None + else: + # Use send_command with return_request_id=True to get Future and request_id + fut, request_id = await self._sila.send_command( + "ExecuteMethod", + return_request_id=True, + **params + ) + + return MethodExecution( + request_id=request_id, + method_name=method_name, + _future=fut, + backend=self + ) async def stop_method(self) -> None: """Stop currently running method.""" await self._sila.send_command("StopMethod") - async def list_methods(self) -> Tuple[List[str], List[str]]: - """List available methods and premethods. + async def is_method_running(self) -> bool: + """Check if a method is currently running. + + Uses GetStatus to check device state. Returns True if state is 'busy', + indicating a method execution is in progress. Returns: - Tuple of (premethod_names, method_names). + True if method is running (state is 'busy'), False otherwise. + """ + status = await self.get_status() + return status.lower() == "busy" + + async def wait_for_method_completion( + self, + poll_interval: float = 5.0, + timeout: Optional[float] = None, + ) -> None: + """Wait until method execution completes. + + Polls GetStatus at poll_interval until state returns to 'idle'. + Useful when method was started with wait=False and you need to wait. + + Args: + poll_interval: Seconds between status checks. Default 5.0. + timeout: Maximum seconds to wait. None for no timeout. + + Raises: + TimeoutError: If timeout is exceeded. + """ + import time + + start_time = time.time() + while await self.is_method_running(): + if timeout is not None: + elapsed = time.time() - start_time + if elapsed > timeout: + raise TimeoutError( + f"Method execution did not complete within {timeout}s" + ) + await asyncio.sleep(poll_interval) + + async def get_data_events(self, request_id: Optional[int] = None) -> Dict[int, List[Dict[str, Any]]]: + """Get collected DataEvents. + + Args: + request_id: If provided, return events for this request_id only. + If None, return all collected events. + + Returns: + Dict mapping request_id to list of DataEvent payloads. + """ + all_events = self._sila._data_events_by_request_id.copy() + + if request_id is not None: + return {request_id: all_events.get(request_id, [])} + + return all_events + + async def get_method_set(self) -> ODTCMethodSet: + """Get the full MethodSet from the device. + + Returns: + ODTCMethodSet containing all methods and premethods. + + Raises: + ValueError: If response is empty or MethodsXML parameter not found. """ resp = await self._sila.send_command("GetParameters") if resp is None: - return ([], []) + raise ValueError("Empty response from GetParameters") # Extract MethodsXML parameter param = resp.find(".//Parameter[@name='MethodsXML']") if param is None: - return ([], []) + raise ValueError("MethodsXML parameter not found in response") string_elem = param.find("String") if string_elem is None or string_elem.text is None: - return ([], []) + raise ValueError("MethodsXML String element not found") # Parse MethodSet XML (it's escaped in the response) method_set_xml = string_elem.text - method_set = parse_method_set(method_set_xml) + return parse_method_set(method_set_xml) + + async def get_method_by_name(self, method_name: str) -> Optional[ODTCMethod]: + """Get a specific method by name from the device. - premethod_names = [pm.name for pm in method_set.premethods] - method_names = [m.name for m in method_set.methods] + Args: + method_name: Name of the method to retrieve. - return (premethod_names, method_names) + Returns: + ODTCMethod if found, None otherwise. + """ + method_set = await self.get_method_set() + return get_method_by_name(method_set, method_name) - async def upload_method_set_from_file(self, filepath: str) -> None: - """Load and upload a MethodSet XML file to the device. + async def upload_method_set(self, method_set: ODTCMethodSet) -> None: + """Upload a MethodSet to the device. Args: - filepath: Path to MethodSet XML file. + method_set: ODTCMethodSet to upload. """ - method_set = parse_method_set_file(filepath) method_set_xml = method_set_to_xml(method_set) # SetParameters expects paramsXML in ResponseType_1.2.xsd format @@ -313,6 +441,15 @@ async def upload_method_set_from_file(self, filepath: str) -> None: params_xml = ET.tostring(param_set, encoding="unicode", xml_declaration=False) await self._sila.send_command("SetParameters", paramsXML=params_xml) + async def upload_method_set_from_file(self, filepath: str) -> None: + """Load and upload a MethodSet XML file to the device. + + Args: + filepath: Path to MethodSet XML file. + """ + method_set = parse_method_set_file(filepath) + await self.upload_method_set(method_set) + async def save_method_set_to_file(self, filepath: str) -> None: """Download methods from device and save to file. diff --git a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py index bbb73641193..e731e69c9cd 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py @@ -1,10 +1,11 @@ """Tests for ODTC backend and SiLA interface.""" +import asyncio import unittest import xml.etree.ElementTree as ET from unittest.mock import AsyncMock, MagicMock, patch -from pylabrobot.thermocycling.inheco.odtc_backend import ODTCBackend +from pylabrobot.thermocycling.inheco.odtc_backend import MethodExecution, ODTCBackend from pylabrobot.thermocycling.inheco.odtc_sila_interface import ODTCSiLAInterface, SiLAState @@ -244,6 +245,177 @@ async def test_get_lid_current_temperature(self): self.assertEqual(len(temps), 1) self.assertAlmostEqual(temps[0], 26.0, places=2) + async def test_execute_method_wait_true(self): + """Test execute_method with wait=True (blocking).""" + self.backend._sila.send_command = AsyncMock(return_value=None) + result = await self.backend.execute_method("PCR_30cycles", wait=True) + self.assertIsNone(result) + self.backend._sila.send_command.assert_called_once_with( + "ExecuteMethod", return_request_id=False, methodName="PCR_30cycles" + ) + + async def test_execute_method_wait_false(self): + """Test execute_method with wait=False (returns handle).""" + fut = asyncio.Future() + fut.set_result(None) + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + execution = await self.backend.execute_method("PCR_30cycles", wait=False) + self.assertIsInstance(execution, MethodExecution) + self.assertEqual(execution.request_id, 12345) + self.assertEqual(execution.method_name, "PCR_30cycles") + self.backend._sila.send_command.assert_called_once_with( + "ExecuteMethod", return_request_id=True, methodName="PCR_30cycles" + ) + + async def test_method_execution_awaitable(self): + """Test that MethodExecution is awaitable.""" + fut = asyncio.Future() + fut.set_result("success") + execution = MethodExecution( + request_id=12345, + method_name="PCR_30cycles", + _future=fut, + backend=self.backend + ) + result = await execution + self.assertEqual(result, "success") + + async def test_method_execution_wait(self): + """Test MethodExecution.wait() method.""" + fut = asyncio.Future() + fut.set_result(None) + execution = MethodExecution( + request_id=12345, + method_name="PCR_30cycles", + _future=fut, + backend=self.backend + ) + await execution.wait() # Should not raise + + async def test_method_execution_is_running(self): + """Test MethodExecution.is_running() method.""" + fut = asyncio.Future() + execution = MethodExecution( + request_id=12345, + method_name="PCR_30cycles", + _future=fut, + backend=self.backend + ) + self.backend.get_status = AsyncMock(return_value="busy") + is_running = await execution.is_running() + self.assertTrue(is_running) + + async def test_is_method_running(self): + """Test is_method_running().""" + self.backend.get_status = AsyncMock(return_value="busy") + self.assertTrue(await self.backend.is_method_running()) + + self.backend.get_status = AsyncMock(return_value="idle") + self.assertFalse(await self.backend.is_method_running()) + + self.backend.get_status = AsyncMock(return_value="BUSY") + self.assertTrue(await self.backend.is_method_running()) + + async def test_wait_for_method_completion(self): + """Test wait_for_method_completion().""" + call_count = 0 + + async def mock_get_status(): + nonlocal call_count + call_count += 1 + if call_count < 3: + return "busy" + return "idle" + + self.backend.get_status = AsyncMock(side_effect=mock_get_status) + await self.backend.wait_for_method_completion(poll_interval=0.1) + self.assertEqual(call_count, 3) + + async def test_wait_for_method_completion_timeout(self): + """Test wait_for_method_completion() with timeout.""" + self.backend.get_status = AsyncMock(return_value="busy") + with self.assertRaises(TimeoutError): + await self.backend.wait_for_method_completion(poll_interval=0.1, timeout=0.3) + + async def test_get_data_events(self): + """Test get_data_events().""" + self.backend._sila._data_events_by_request_id = { + 12345: [{"requestId": 12345, "data": "test1"}, {"requestId": 12345, "data": "test2"}], + 67890: [{"requestId": 67890, "data": "test3"}], + } + + # Get all events + all_events = await self.backend.get_data_events() + self.assertEqual(len(all_events), 2) + self.assertEqual(len(all_events[12345]), 2) + + # Get events for specific request_id + events = await self.backend.get_data_events(request_id=12345) + self.assertEqual(len(events), 1) + self.assertEqual(len(events[12345]), 2) + + # Get events for non-existent request_id + events = await self.backend.get_data_events(request_id=99999) + self.assertEqual(len(events), 1) + self.assertEqual(len(events[99999]), 0) + + +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 = {} + + # 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: + 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: + 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: + 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) + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py index 906ef8c04d6..411fbeeaf0a 100644 --- a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py +++ b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py @@ -16,7 +16,7 @@ import urllib.request from dataclasses import dataclass from enum import Enum -from typing import Any, Dict, Optional, Set +from typing import Any, Dict, List, Optional, Set import xml.etree.ElementTree as ET @@ -270,6 +270,9 @@ def __init__( # Lock for parallelism checking (separate from base class's _making_request) self._parallelism_lock = asyncio.Lock() + # DataEvent storage by request_id + self._data_events_by_request_id: Dict[int, List[Dict[str, Any]]] = {} + def _check_state_allowability(self, command: str) -> bool: """Check if command is allowed in current state. @@ -531,11 +534,20 @@ async def _on_http(self, req: InhecoSiLAInterface._HTTPRequest) -> bytes: # Handle DataEvent (intermediate data, e.g., during ExecuteMethod) if "DataEvent" in decoded: - # For now, just log DataEvents - design allows future exposure data_event = decoded["DataEvent"] request_id = data_event.get("requestId") - self._logger.debug(f"DataEvent received for requestId {request_id}") - # TODO: Could expose via callback/stream in future + + if request_id is not None: + # Store the full DataEvent payload + if request_id not in self._data_events_by_request_id: + self._data_events_by_request_id[request_id] = [] + self._data_events_by_request_id[request_id].append(data_event) + + self._logger.debug( + f"DataEvent received for requestId {request_id} " + f"(total: {len(self._data_events_by_request_id[request_id])})" + ) + return SOAP_RESPONSE_DataEventResponse.encode("utf-8") # Handle ErrorEvent (recoverable errors with continuation tasks) @@ -568,8 +580,9 @@ async def send_command( self, command: str, lock_id: Optional[str] = None, + return_request_id: bool = False, **kwargs, - ) -> Any: + ) -> Any | tuple[asyncio.Future[Any], int]: """Send a SiLA command with parallelism, state, and lockId validation. Overrides base class to add: @@ -582,10 +595,15 @@ async def send_command( Args: command: Command name. lock_id: LockId (defaults to None, validated if device is locked). + return_request_id: If True and command is async (return_code==2), + return (Future, request_id) tuple instead of awaiting Future. + Caller must await the Future themselves. **kwargs: Additional command parameters. Returns: - Command response (parsed XML ElementTree for async, decoded dict for sync). + - For sync commands (return_code==1): decoded response dict + - For async commands with return_request_id=False: result after awaiting Future + - For async commands with return_request_id=True: (Future, request_id) tuple Raises: RuntimeError: For validation failures, return code errors, or state violations. @@ -731,16 +749,21 @@ def _do_request() -> bytes: if self._current_state == SiLAState.IDLE: self._current_state = SiLAState.BUSY - # Wait for async response - try: - result = await fut - return result - except asyncio.TimeoutError: - # Clean up on timeout - self._pending_by_id.pop(request_id, None) - self._active_request_ids.discard(request_id) - self._executing_commands.discard(normalized_cmd) - raise RuntimeError(f"Command {command} timed out waiting for ResponseEvent") + # Handle return_request_id parameter + if return_request_id: + # Return Future and request_id immediately (caller awaits) + return (fut, request_id) + else: + # Existing behavior: await Future + try: + result = await fut + return result + except asyncio.TimeoutError: + # Clean up on timeout + self._pending_by_id.pop(request_id, None) + self._active_request_ids.discard(request_id) + self._executing_commands.discard(normalized_cmd) + raise RuntimeError(f"Command {command} timed out waiting for ResponseEvent") else: # Error return code From 92143c6e9a2a7a603fddab17384d7aa07172f4b1 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:42:14 -0800 Subject: [PATCH 03/28] Add async command pattern: CommandExecution base class and MethodExecution subclass --- .../thermocycling/inheco/docs/README.md | 93 +++++- pylabrobot/thermocycling/inheco/odtc.py | 95 +++++-- .../thermocycling/inheco/odtc_backend.py | 264 +++++++++++++++--- .../inheco/odtc_backend_tests.py | 160 ++++++++++- 4 files changed, 523 insertions(+), 89 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/docs/README.md b/pylabrobot/thermocycling/inheco/docs/README.md index 3283068b953..5863b3159a8 100644 --- a/pylabrobot/thermocycling/inheco/docs/README.md +++ b/pylabrobot/thermocycling/inheco/docs/README.md @@ -70,18 +70,65 @@ await tc.stop() # Closes HTTP server and connections ### Synchronous Commands -Most commands are synchronous and return immediately: +Some commands are synchronous and return immediately: ```python # Get device status status = await tc.get_status() # Returns "idle", "busy", "standby", etc. -# Read temperatures -temps = await tc.read_temperatures() # Returns ODTCSensorValues +# Get device identification +device_info = await tc.get_device_identification() +``` + +### Asynchronous Commands + +Most ODTC commands are asynchronous and support both blocking and non-blocking execution: + +#### Blocking Execution (Default) -# Open/close door -await tc.open_door() +```python +# Block until command completes +await tc.open_door() # Returns None when complete await tc.close_door() +await tc.initialize() +await tc.reset() +``` + +#### Non-Blocking Execution with Handle + +```python +# Start command and get execution handle +door_opening = await tc.open_door(wait=False) +# Returns CommandExecution handle immediately + +# Do other work while command runs +temps = await tc.read_temperatures() # Can run in parallel if allowed + +# Wait for completion +await door_opening # Await the handle directly +# OR +await door_opening.wait() # Explicit wait method +``` + +#### CommandExecution Handle + +The `CommandExecution` handle provides: + +- **`request_id`**: SiLA request ID for tracking DataEvents +- **`command_name`**: Name of the executing command +- **Awaitable interface**: Can be awaited like `asyncio.Task` +- **`wait()`**: Explicit wait for completion +- **`get_data_events()`**: Get DataEvents for this command execution + +```python +# Non-blocking door operation +door_opening = await tc.open_door(wait=False) + +# Get DataEvents for this execution +events = await door_opening.get_data_events() + +# Wait for completion +await door_opening ``` ### Asynchronous Method Execution @@ -117,13 +164,12 @@ await tc.wait_for_method_completion() # Poll-based wait #### MethodExecution Handle -The `MethodExecution` handle provides: +The `MethodExecution` handle extends `CommandExecution` with method-specific features: -- **`request_id`**: SiLA request ID for tracking DataEvents -- **`method_name`**: Name of executing method -- **Awaitable interface**: Can be awaited like `asyncio.Task` -- **`is_running()`**: Check if method is still running -- **`wait()`**: Explicit wait for completion +- **All `CommandExecution` features**: `request_id`, `command_name`, awaitable interface, `wait()`, `get_data_events()` +- **`method_name`**: Name of executing method (more semantic than `command_name`) +- **`is_running()`**: Check if method is still running (checks device busy state) +- **`stop()`**: Stop the currently running method ```python execution = await tc.execute_method("PCR_30cycles", wait=False) @@ -169,12 +215,35 @@ execution = await tc.execute_method("PCR_30cycles", wait=False) # These can run in parallel: temps = await tc.read_temperatures() -await tc.open_door() +door_opening = await tc.open_door(wait=False) + +# Wait for door to complete +await door_opening # These will queue/wait: method2 = await tc.execute_method("PCR_40cycles", wait=False) # Waits for method1 ``` +### CommandExecution vs MethodExecution + +- **`CommandExecution`**: Base class for all async commands (door operations, initialize, reset, etc.) +- **`MethodExecution`**: Subclass of `CommandExecution` for method execution with additional features: + - `is_running()`: Checks if device is in "busy" state + - `stop()`: Stops the currently running method + - `method_name`: More semantic than `command_name` for methods + +```python +# CommandExecution example +door_opening = await tc.open_door(wait=False) +await door_opening # Wait for door to open + +# MethodExecution example (has additional features) +method_exec = await tc.execute_method("PCR_30cycles", wait=False) +if await method_exec.is_running(): + print(f"Method {method_exec.method_name} is running") + await method_exec.stop() # Stop the method +``` + ## Getting Protocols from Device ### Get Full MethodSet diff --git a/pylabrobot/thermocycling/inheco/odtc.py b/pylabrobot/thermocycling/inheco/odtc.py index 4b0ff970d81..f60d68ca492 100644 --- a/pylabrobot/thermocycling/inheco/odtc.py +++ b/pylabrobot/thermocycling/inheco/odtc.py @@ -5,7 +5,7 @@ from pylabrobot.resources import Coordinate, ItemizedResource from pylabrobot.thermocycling.thermocycler import Thermocycler -from .odtc_backend import ODTCBackend +from .odtc_backend import CommandExecution, MethodExecution, ODTCBackend from .odtc_xml import ODTCMethodSet, ODTCConfig @@ -122,7 +122,7 @@ async def execute_method( method_name: str, priority: Optional[int] = None, wait: bool = True, - ): + ) -> Optional[MethodExecution]: """Execute a method or premethod by name. Args: @@ -136,9 +136,17 @@ async def execute_method( """ return await self.backend.execute_method(method_name, priority, wait) - async def stop_method(self) -> None: - """Stop any currently running method.""" - await self.backend.stop_method() + async def stop_method(self, wait: bool = True) -> Optional[CommandExecution]: + """Stop any currently running method. + + Args: + wait: If True, block until completion. If False, return CommandExecution handle. + + Returns: + If wait=True: None (blocks until complete) + If wait=False: CommandExecution handle (awaitable, has request_id) + """ + return await self.backend.stop_method(wait=wait) async def get_method_set(self): """Get the full MethodSet from the device.""" @@ -186,24 +194,38 @@ async def read_temperatures(self): # Device control methods - async def initialize(self) -> None: - """Initialize the device (must be in Standby state).""" - await self.backend.initialize() + async def initialize(self, wait: bool = True) -> Optional[CommandExecution]: + """Initialize the device (must be in Standby state). + + Args: + wait: If True, block until completion. If False, return CommandExecution handle. + + Returns: + If wait=True: None (blocks until complete) + If wait=False: CommandExecution handle (awaitable, has request_id) + """ + return await self.backend.initialize(wait=wait) async def reset( self, device_id: str = "ODTC", event_receiver_uri: Optional[str] = None, simulation_mode: bool = False, - ) -> None: + wait: bool = True, + ) -> Optional[CommandExecution]: """Reset the device. Args: device_id: Device identifier. event_receiver_uri: Event receiver URI (auto-detected if None). simulation_mode: Enable simulation mode. + wait: If True, block until completion. If False, return CommandExecution handle. + + Returns: + If wait=True: None (blocks until complete) + If wait=False: CommandExecution handle (awaitable, has request_id) """ - await self.backend.reset(device_id, event_receiver_uri, simulation_mode) + return await self.backend.reset(device_id, event_receiver_uri, simulation_mode, wait=wait) async def get_device_identification(self) -> dict: """Get device identification information. @@ -213,44 +235,57 @@ async def get_device_identification(self) -> dict: """ return await self.backend.get_device_identification() - async def lock_device(self, lock_id: str, lock_timeout: Optional[float] = None) -> None: + async def lock_device(self, lock_id: str, lock_timeout: Optional[float] = None, wait: bool = True) -> Optional[CommandExecution]: """Lock the device for exclusive access. Args: lock_id: Unique lock identifier. lock_timeout: Lock timeout in seconds (optional). + wait: If True, block until completion. If False, return CommandExecution handle. + + Returns: + If wait=True: None (blocks until complete) + If wait=False: CommandExecution handle (awaitable, has request_id) """ - await self.backend.lock_device(lock_id, lock_timeout) + return await self.backend.lock_device(lock_id, lock_timeout, wait=wait) - async def unlock_device(self) -> None: - """Unlock the device.""" - await self.backend.unlock_device() + async def unlock_device(self, wait: bool = True) -> Optional[CommandExecution]: + """Unlock the device. - # Door control methods + Args: + wait: If True, block until completion. If False, return CommandExecution handle. - async def open_door(self) -> None: - """Open the drawer door (equivalent to PrepareForOutput).""" - await self.backend.open_door() + Returns: + If wait=True: None (blocks until complete) + If wait=False: CommandExecution handle (awaitable, has request_id) + """ + return await self.backend.unlock_device(wait=wait) - async def close_door(self) -> None: - """Close the drawer door (equivalent to PrepareForInput).""" - await self.backend.close_door() + # Door control methods - async def prepare_for_output(self, position: Optional[int] = None) -> None: - """Prepare for output (equivalent to OpenDoor). + async def open_door(self, wait: bool = True) -> Optional[CommandExecution]: + """Open the drawer door. Args: - position: Position parameter (ignored by ODTC, but part of SiLA spec). + wait: If True, block until completion. If False, return CommandExecution handle. + + Returns: + If wait=True: None (blocks until complete) + If wait=False: CommandExecution handle (awaitable, has request_id) """ - await self.backend.prepare_for_output(position) + return await self.backend.open_door(wait=wait) - async def prepare_for_input(self, position: Optional[int] = None) -> None: - """Prepare for input (equivalent to CloseDoor). + async def close_door(self, wait: bool = True) -> Optional[CommandExecution]: + """Close the drawer door. Args: - position: Position parameter (ignored by ODTC, but part of SiLA spec). + wait: If True, block until completion. If False, return CommandExecution handle. + + Returns: + If wait=True: None (blocks until complete) + If wait=False: CommandExecution handle (awaitable, has request_id) """ - await self.backend.prepare_for_input(position) + return await self.backend.close_door(wait=wait) # Data retrieval methods diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index 4936208741c..217e92ad112 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -25,17 +25,17 @@ @dataclass -class MethodExecution: - """Handle for an executing method that can be awaited or checked. +class CommandExecution: + """Base handle for an executing async command that can be awaited or checked. - This handle is returned from execute_method(wait=False) and provides: + This handle is returned from async commands when wait=False and provides: - Awaitable interface (can be awaited like a Task) - Request ID access for DataEvent tracking - - Status checking methods + - Command completion waiting """ request_id: int - method_name: str + command_name: str _future: asyncio.Future[Any] backend: "ODTCBackend" @@ -44,13 +44,48 @@ def __await__(self): return self._future.__await__() async def wait(self) -> None: - """Wait for method completion.""" + """Wait for command completion.""" await self._future + async def get_data_events(self) -> List[Dict[str, Any]]: + """Get DataEvents for this command execution. + + Returns: + List of DataEvent payloads for this request_id. + """ + events_dict = await self.backend.get_data_events(self.request_id) + return events_dict.get(self.request_id, []) + + +@dataclass +class MethodExecution(CommandExecution): + """Handle for an executing method (protocol) with method-specific features. + + This handle is returned from execute_method(wait=False) and provides: + - All features from CommandExecution (awaitable, request_id, DataEvents) + - Method-specific status checking + - Method stopping capability + """ + + method_name: str + + def __post_init__(self): + """Set command_name to ExecuteMethod for parent class.""" + # Override command_name from parent to be ExecuteMethod + object.__setattr__(self, 'command_name', "ExecuteMethod") + async def is_running(self) -> bool: - """Check if method is still running.""" + """Check if method is still running (checks device busy state). + + Returns: + True if device state is 'busy', False otherwise. + """ return await self.backend.is_method_running() + async def stop(self) -> None: + """Stop the currently running method.""" + await self.backend.stop_method() + class ODTCBackend(ThermocyclerBackend): """ODTC backend using ODTC-specific SiLA interface. @@ -164,31 +199,75 @@ async def get_status(self) -> str: self.logger.warning(f"GetStatus returned non-dict response: {type(resp)}") return "Unknown" - async def initialize(self) -> None: - """Initialize the device (must be in Standby state).""" - await self._sila.send_command("Initialize") + async def initialize(self, wait: bool = True) -> Optional[CommandExecution]: + """Initialize the device (must be in Standby state). + + Args: + wait: If True, block until completion. If False, return CommandExecution handle. + + Returns: + If wait=True: None (blocks until complete) + If wait=False: CommandExecution handle (awaitable, has request_id) + """ + if wait: + await self._sila.send_command("Initialize", return_request_id=False) + return None + else: + fut, request_id = await self._sila.send_command( + "Initialize", + return_request_id=True + ) + return CommandExecution( + request_id=request_id, + command_name="Initialize", + _future=fut, + backend=self + ) async def reset( self, device_id: str = "ODTC", event_receiver_uri: Optional[str] = None, simulation_mode: bool = False, - ) -> None: + wait: bool = True, + ) -> Optional[CommandExecution]: """Reset the device. Args: device_id: Device identifier. event_receiver_uri: Event receiver URI (auto-detected if None). simulation_mode: Enable simulation mode. + wait: If True, block until completion. If False, return CommandExecution handle. + + Returns: + If wait=True: None (blocks until complete) + If wait=False: CommandExecution handle (awaitable, has request_id) """ if event_receiver_uri is None: event_receiver_uri = f"http://{self._sila._client_ip}:{self._sila.bound_port}/" - await self._sila.send_command( - "Reset", - deviceId=device_id, - eventReceiverURI=event_receiver_uri, - simulationMode=simulation_mode, - ) + if wait: + await self._sila.send_command( + "Reset", + return_request_id=False, + deviceId=device_id, + eventReceiverURI=event_receiver_uri, + simulationMode=simulation_mode, + ) + return None + else: + fut, request_id = await self._sila.send_command( + "Reset", + return_request_id=True, + deviceId=device_id, + eventReceiverURI=event_receiver_uri, + simulationMode=simulation_mode, + ) + return CommandExecution( + request_id=request_id, + command_name="Reset", + _future=fut, + backend=self + ) async def get_device_identification(self) -> dict: """Get device identification information. @@ -203,47 +282,118 @@ async def get_device_identification(self) -> dict: else: return {} - async def lock_device(self, lock_id: str, lock_timeout: Optional[float] = None) -> None: + async def lock_device(self, lock_id: str, lock_timeout: Optional[float] = None, wait: bool = True) -> Optional[CommandExecution]: """Lock the device for exclusive access. Args: lock_id: Unique lock identifier. lock_timeout: Lock timeout in seconds (optional). + wait: If True, block until completion. If False, return CommandExecution handle. + + Returns: + If wait=True: None (blocks until complete) + If wait=False: CommandExecution handle (awaitable, has request_id) """ params: dict = {"lockId": lock_id, "PMSId": "PyLabRobot"} if lock_timeout is not None: params["lockTimeout"] = lock_timeout - await self._sila.send_command("LockDevice", lock_id=lock_id, **params) + if wait: + await self._sila.send_command("LockDevice", return_request_id=False, lock_id=lock_id, **params) + return None + else: + fut, request_id = await self._sila.send_command( + "LockDevice", + return_request_id=True, + lock_id=lock_id, + **params + ) + return CommandExecution( + request_id=request_id, + command_name="LockDevice", + _future=fut, + backend=self + ) + + async def unlock_device(self, wait: bool = True) -> Optional[CommandExecution]: + """Unlock the device. + + Args: + wait: If True, block until completion. If False, return CommandExecution handle. - async def unlock_device(self) -> None: - """Unlock the device.""" + Returns: + If wait=True: None (blocks until complete) + If wait=False: CommandExecution handle (awaitable, has request_id) + """ # Must provide the lockId that was used to lock it if self._sila._lock_id is None: raise RuntimeError("Device is not locked") - await self._sila.send_command("UnlockDevice", lock_id=self._sila._lock_id) + if wait: + await self._sila.send_command("UnlockDevice", return_request_id=False, lock_id=self._sila._lock_id) + return None + else: + fut, request_id = await self._sila.send_command( + "UnlockDevice", + return_request_id=True, + lock_id=self._sila._lock_id + ) + return CommandExecution( + request_id=request_id, + command_name="UnlockDevice", + _future=fut, + backend=self + ) # Door control commands - async def open_door(self) -> None: - """Open the drawer door (equivalent to PrepareForOutput).""" - await self._sila.send_command("OpenDoor") - - async def close_door(self) -> None: - """Close the drawer door (equivalent to PrepareForInput).""" - await self._sila.send_command("CloseDoor") - - async def prepare_for_output(self, position: Optional[int] = None) -> None: - """Prepare for output (equivalent to OpenDoor).""" - params = {} - if position is not None: - params["position"] = position - await self._sila.send_command("PrepareForOutput", **params) - - async def prepare_for_input(self, position: Optional[int] = None) -> None: - """Prepare for input (equivalent to CloseDoor).""" - params = {} - if position is not None: - params["position"] = position - await self._sila.send_command("PrepareForInput", **params) + async def open_door(self, wait: bool = True) -> Optional[CommandExecution]: + """Open the drawer door. + + Args: + wait: If True, block until completion. If False, return CommandExecution handle. + + Returns: + If wait=True: None (blocks until complete) + If wait=False: CommandExecution handle (awaitable, has request_id) + """ + if wait: + await self._sila.send_command("OpenDoor", return_request_id=False) + return None + else: + fut, request_id = await self._sila.send_command( + "OpenDoor", + return_request_id=True + ) + return CommandExecution( + request_id=request_id, + command_name="OpenDoor", + _future=fut, + backend=self + ) + + async def close_door(self, wait: bool = True) -> Optional[CommandExecution]: + """Close the drawer door. + + Args: + wait: If True, block until completion. If False, return CommandExecution handle. + + Returns: + If wait=True: None (blocks until complete) + If wait=False: CommandExecution handle (awaitable, has request_id) + """ + if wait: + await self._sila.send_command("CloseDoor", return_request_id=False) + return None + else: + fut, request_id = await self._sila.send_command( + "CloseDoor", + return_request_id=True + ) + return CommandExecution( + request_id=request_id, + command_name="CloseDoor", + _future=fut, + backend=self + ) + # Sensor commands async def read_temperatures(self) -> ODTCSensorValues: @@ -316,14 +466,36 @@ async def execute_method( return MethodExecution( request_id=request_id, + command_name="ExecuteMethod", # Will be set correctly by __post_init__ method_name=method_name, _future=fut, backend=self ) - async def stop_method(self) -> None: - """Stop currently running method.""" - await self._sila.send_command("StopMethod") + async def stop_method(self, wait: bool = True) -> Optional[CommandExecution]: + """Stop currently running method. + + Args: + wait: If True, block until completion. If False, return CommandExecution handle. + + Returns: + If wait=True: None (blocks until complete) + If wait=False: CommandExecution handle (awaitable, has request_id) + """ + if wait: + await self._sila.send_command("StopMethod", return_request_id=False) + return None + else: + fut, request_id = await self._sila.send_command( + "StopMethod", + return_request_id=True + ) + return CommandExecution( + request_id=request_id, + command_name="StopMethod", + _future=fut, + backend=self + ) async def is_method_running(self) -> bool: """Check if a method is currently running. diff --git a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py index e731e69c9cd..e54507efbf8 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py @@ -5,7 +5,7 @@ import xml.etree.ElementTree as ET from unittest.mock import AsyncMock, MagicMock, patch -from pylabrobot.thermocycling.inheco.odtc_backend import MethodExecution, ODTCBackend +from pylabrobot.thermocycling.inheco.odtc_backend import CommandExecution, MethodExecution, ODTCBackend from pylabrobot.thermocycling.inheco.odtc_sila_interface import ODTCSiLAInterface, SiLAState @@ -273,6 +273,7 @@ async def test_method_execution_awaitable(self): fut.set_result("success") execution = MethodExecution( request_id=12345, + command_name="ExecuteMethod", method_name="PCR_30cycles", _future=fut, backend=self.backend @@ -286,6 +287,7 @@ async def test_method_execution_wait(self): fut.set_result(None) execution = MethodExecution( request_id=12345, + command_name="ExecuteMethod", method_name="PCR_30cycles", _future=fut, backend=self.backend @@ -297,6 +299,7 @@ async def test_method_execution_is_running(self): fut = asyncio.Future() execution = MethodExecution( request_id=12345, + command_name="ExecuteMethod", method_name="PCR_30cycles", _future=fut, backend=self.backend @@ -305,6 +308,161 @@ async def test_method_execution_is_running(self): is_running = await execution.is_running() self.assertTrue(is_running) + async def test_method_execution_stop(self): + """Test MethodExecution.stop() method.""" + fut = asyncio.Future() + execution = MethodExecution( + request_id=12345, + command_name="ExecuteMethod", + method_name="PCR_30cycles", + _future=fut, + backend=self.backend + ) + self.backend._sila.send_command = AsyncMock() + await execution.stop() + self.backend._sila.send_command.assert_called_once_with("StopMethod", return_request_id=False) + + async def test_method_execution_inheritance(self): + """Test that MethodExecution is a subclass of CommandExecution.""" + fut = asyncio.Future() + fut.set_result(None) + execution = MethodExecution( + request_id=12345, + command_name="ExecuteMethod", + method_name="PCR_30cycles", + _future=fut, + backend=self.backend + ) + self.assertIsInstance(execution, CommandExecution) + self.assertEqual(execution.command_name, "ExecuteMethod") + self.assertEqual(execution.method_name, "PCR_30cycles") + + async def test_command_execution_awaitable(self): + """Test that CommandExecution is awaitable.""" + fut = asyncio.Future() + fut.set_result("success") + execution = CommandExecution( + request_id=12345, + command_name="OpenDoor", + _future=fut, + backend=self.backend + ) + result = await execution + self.assertEqual(result, "success") + + async def test_command_execution_wait(self): + """Test CommandExecution.wait() method.""" + fut = asyncio.Future() + fut.set_result(None) + execution = CommandExecution( + request_id=12345, + command_name="OpenDoor", + _future=fut, + backend=self.backend + ) + await execution.wait() # Should not raise + + async def test_command_execution_get_data_events(self): + """Test CommandExecution.get_data_events() method.""" + fut = asyncio.Future() + fut.set_result(None) + execution = CommandExecution( + request_id=12345, + command_name="OpenDoor", + _future=fut, + backend=self.backend + ) + self.backend._sila._data_events_by_request_id = { + 12345: [{"requestId": 12345, "data": "test1"}, {"requestId": 12345, "data": "test2"}], + 67890: [{"requestId": 67890, "data": "test3"}], + } + events = await execution.get_data_events() + self.assertEqual(len(events), 2) + self.assertEqual(events[0]["requestId"], 12345) + + async def test_open_door_wait_false(self): + """Test open_door with wait=False (returns handle).""" + fut = asyncio.Future() + fut.set_result(None) + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + execution = await self.backend.open_door(wait=False) + self.assertIsInstance(execution, CommandExecution) + self.assertEqual(execution.request_id, 12345) + self.assertEqual(execution.command_name, "OpenDoor") + self.backend._sila.send_command.assert_called_once_with( + "OpenDoor", return_request_id=True + ) + + async def test_open_door_wait_true(self): + """Test open_door with wait=True (blocking).""" + self.backend._sila.send_command = AsyncMock(return_value=None) + result = await self.backend.open_door(wait=True) + self.assertIsNone(result) + self.backend._sila.send_command.assert_called_once_with( + "OpenDoor", return_request_id=False + ) + + async def test_close_door_wait_false(self): + """Test close_door with wait=False (returns handle).""" + fut = asyncio.Future() + fut.set_result(None) + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + execution = await self.backend.close_door(wait=False) + self.assertIsInstance(execution, CommandExecution) + self.assertEqual(execution.request_id, 12345) + self.assertEqual(execution.command_name, "CloseDoor") + + async def test_initialize_wait_false(self): + """Test initialize with wait=False (returns handle).""" + fut = asyncio.Future() + fut.set_result(None) + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + execution = await self.backend.initialize(wait=False) + self.assertIsInstance(execution, CommandExecution) + self.assertEqual(execution.request_id, 12345) + self.assertEqual(execution.command_name, "Initialize") + + async def test_reset_wait_false(self): + """Test reset with wait=False (returns handle).""" + fut = asyncio.Future() + fut.set_result(None) + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + execution = await self.backend.reset(wait=False) + self.assertIsInstance(execution, CommandExecution) + self.assertEqual(execution.request_id, 12345) + self.assertEqual(execution.command_name, "Reset") + + async def test_lock_device_wait_false(self): + """Test lock_device with wait=False (returns handle).""" + fut = asyncio.Future() + fut.set_result(None) + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + execution = await self.backend.lock_device("my_lock", wait=False) + self.assertIsInstance(execution, CommandExecution) + self.assertEqual(execution.request_id, 12345) + self.assertEqual(execution.command_name, "LockDevice") + + async def test_unlock_device_wait_false(self): + """Test unlock_device with wait=False (returns handle).""" + fut = asyncio.Future() + fut.set_result(None) + self.backend._sila._lock_id = "my_lock" + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + execution = await self.backend.unlock_device(wait=False) + self.assertIsInstance(execution, CommandExecution) + self.assertEqual(execution.request_id, 12345) + self.assertEqual(execution.command_name, "UnlockDevice") + + async def test_stop_method_wait_false(self): + """Test stop_method with wait=False (returns handle).""" + fut = asyncio.Future() + fut.set_result(None) + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + execution = await self.backend.stop_method(wait=False) + self.assertIsInstance(execution, CommandExecution) + self.assertEqual(execution.request_id, 12345) + self.assertEqual(execution.command_name, "StopMethod") + async def test_is_method_running(self): """Test is_method_running().""" self.backend.get_status = AsyncMock(return_value="busy") From 813e569f168cd8f5e48c907cafdf82259c2a188f Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:59:32 -0800 Subject: [PATCH 04/28] Doc and test notebook --- .../inheco/{docs => dev}/README.md | 18 + .../inheco/dev/test_door_commands.ipynb | 365 ++++++++++++++++++ 2 files changed, 383 insertions(+) rename pylabrobot/thermocycling/inheco/{docs => dev}/README.md (96%) create mode 100644 pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb diff --git a/pylabrobot/thermocycling/inheco/docs/README.md b/pylabrobot/thermocycling/inheco/dev/README.md similarity index 96% rename from pylabrobot/thermocycling/inheco/docs/README.md rename to pylabrobot/thermocycling/inheco/dev/README.md index 5863b3159a8..3c50782a1bd 100644 --- a/pylabrobot/thermocycling/inheco/docs/README.md +++ b/pylabrobot/thermocycling/inheco/dev/README.md @@ -198,6 +198,24 @@ await tc.wait_for_method_completion( ) ``` +### Temperature Control + +The ODTC supports direct temperature control via `set_block_temperature()` and `set_lid_temperature()`: + +```python +# Set block temperature (uses PreMethod internally) +await tc.set_block_temperature([95.0]) # Sets block to 95°C + +# Set lid temperature (uses PreMethod internally) +await tc.set_lid_temperature([105.0]) # Sets lid to 105°C + +# Both methods coordinate temperatures: +# - set_block_temperature() uses existing lid temp or defaults to 105°C +# - set_lid_temperature() uses existing block temp or defaults to 25°C +``` + +**Note**: These methods use PreMethods internally to achieve constant temperature holds. They automatically stop any running method, wait for idle state, upload a PreMethod, and execute it. + ### Parallel Operations Per ODTC SiLA spec, certain commands can run in parallel with `ExecuteMethod`: diff --git a/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb b/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb new file mode 100644 index 00000000000..da58c825d08 --- /dev/null +++ b/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb @@ -0,0 +1,365 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ODTC Door Command Test\n", + "\n", + "This notebook demonstrates connecting to an ODTC device and testing the open/close door commands.\n", + "\n", + "## Setup\n", + "\n", + "Before running, ensure:\n", + "1. The ODTC device is powered on and connected to the network\n", + "2. You know the IP address of the ODTC device\n", + "3. Your computer is on the same network as the ODTC\n", + "4. The ODTC device is accessible (not locked by another client)" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "# Import required modules\n", + "import asyncio\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "from pylabrobot.thermocycling.inheco import InhecoODTC, ODTCBackend" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Will connect to ODTC at 192.168.1.50\n" + ] + } + ], + "source": [ + "# Configuration\n", + "# TODO: Replace with your ODTC device IP address\n", + "ODTC_IP = \"192.168.1.50\" # Change this to your ODTC's IP address\n", + "\n", + "print(f\"Will connect to ODTC at {ODTC_IP}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Backend and thermocycler objects created.\n" + ] + } + ], + "source": [ + "# Use model=\"96\" for 96-well or model=\"384\" for 384-well format\n", + "tc = InhecoODTC(name=\"odtc_test\", backend=ODTCBackend(odtc_ip=ODTC_IP), model=\"96\")\n", + "\n", + "print(\"Backend and thermocycler objects created.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connection Management" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-01-26 13:57:19,244 - pylabrobot.storage.inheco.scila.inheco_sila_interface - INFO - Device reset (unlocked)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Connected and initialized successfully!\n", + "Device should now be in Idle state and ready for commands.\n" + ] + } + ], + "source": [ + "# Connect and initialize the device\n", + "# This performs the full SiLA connection lifecycle:\n", + "# 1. Sets up HTTP event receiver server\n", + "# 2. Calls Reset (Startup -> Standby) to register event receiver\n", + "# 3. Calls Initialize (Standby -> Idle) to ready the device\n", + "await tc.setup()\n", + "print(\"✓ Connected and initialized successfully!\")\n", + "print(\"Device should now be in Idle state and ready for commands.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Status Commands" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Device status: idle\n" + ] + } + ], + "source": [ + "# Get device status\n", + "# After setup(), device should be in \"Idle\" state\n", + "status = await tc.get_status()\n", + "print(f\"Device status: {status}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "PreMethods (15):\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", + "Methods (61):\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" + ] + } + ], + "source": [ + "# Get methods and premethods stored on the device\n", + "# Note: Requires device to be in Idle state (after setup/initialize)\n", + "# Returns ODTCMethodSet object with premethods and methods attributes\n", + "method_set = await tc.get_method_set()\n", + "\n", + "print(f\"PreMethods ({len(method_set.premethods)}):\")\n", + "if method_set.premethods:\n", + " for pm in method_set.premethods:\n", + " print(f\" - {pm.name}\")\n", + "else:\n", + " print(\" (none)\")\n", + "\n", + "print(f\"\\nMethods ({len(method_set.methods)}):\")\n", + "if method_set.methods:\n", + " for m in method_set.methods:\n", + " print(f\" - {m.name}\")\n", + "else:\n", + " print(\" (none)\")\n", + "\n", + "# You can also get a specific method by name:\n", + "# method = await tc.get_method_by_name(\"PCR_30cycles\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Non-Blocking Door Operations\n", + "\n", + "The door commands support non-blocking execution using the `wait=False` parameter. This returns a `CommandExecution` handle that you can await later, allowing you to do other work while the door operation completes. \n", + "\n", + "Just set wait=True to treat these as blocking and run them as normal commands" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Starting door close (non-blocking)...\n", + "✓ Door close command started! Request ID: 1783384016\n" + ] + } + ], + "source": [ + "# Non-blocking door close\n", + "print(\"Starting door close (non-blocking)...\")\n", + "door_closing = await tc.close_door(wait=False)\n", + "print(f\"✓ Door close command started! Request ID: {door_closing.request_id}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Waiting for door to close...\n", + "✓ Door close completed!\n", + "Door opening with request ID: 1991913242\n", + "DataEvents collected: 0\n", + "✓ Door open completed!\n" + ] + } + ], + "source": [ + "# Access DataEvents for a command execution\n", + "# (Note: DataEvents are primarily used for method execution, but the pattern works for all commands)\n", + "# You can also use the wait() method explicitly\n", + "print(\"Waiting for door to close...\")\n", + "await door_closing.wait()\n", + "print(\"✓ Door close completed!\")\n", + "\n", + "door_opening = await tc.open_door(wait=False)\n", + "print(f\"Door opening with request ID: {door_opening.request_id}\")\n", + "\n", + "# Get DataEvents for this command (if any were collected)\n", + "events = await door_opening.get_data_events()\n", + "print(f\"DataEvents collected: {len(events)}\")\n", + "\n", + "# Wait for completion\n", + "await door_opening\n", + "print(\"✓ Door open completed!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Close the connection" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Close the connection\n", + "await tc.stop()\n", + "print(\"✓ Connection closed.\")" + ] + } + ], + "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 +} From d41c26a09a82fd25fef26fc09bd54f9d5379d88e Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:32:49 -0800 Subject: [PATCH 05/28] States --- pylabrobot/thermocycling/inheco/odtc.py | 4 +- .../thermocycling/inheco/odtc_backend.py | 100 +++++++++++------- .../inheco/odtc_sila_interface.py | 20 ++-- 3 files changed, 76 insertions(+), 48 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/odtc.py b/pylabrobot/thermocycling/inheco/odtc.py index f60d68ca492..b8e95610ecc 100644 --- a/pylabrobot/thermocycling/inheco/odtc.py +++ b/pylabrobot/thermocycling/inheco/odtc.py @@ -180,7 +180,7 @@ async def get_status(self) -> str: """Get device status state. Returns: - Device state string (e.g., "Idle", "Busy", "Standby"). + Device state string (e.g., "idle", "busy", "standby"). """ return await self.backend.get_status() @@ -195,7 +195,7 @@ async def read_temperatures(self): # Device control methods async def initialize(self, wait: bool = True) -> Optional[CommandExecution]: - """Initialize the device (must be in Standby state). + """Initialize the device (must be in standby state). Args: wait: If True, block until completion. If False, return CommandExecution handle. diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index 217e92ad112..046f1e260e1 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -11,7 +11,7 @@ from pylabrobot.thermocycling.backend import ThermocyclerBackend from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol -from .odtc_sila_interface import ODTCSiLAInterface +from .odtc_sila_interface import ODTCSiLAInterface, SiLAState from .odtc_xml import ( ODTCMethod, ODTCMethodSet, @@ -117,14 +117,15 @@ async def setup(self) -> None: Performs the full SiLA connection lifecycle: 1. Sets up the HTTP event receiver server - 2. Calls Reset to move from Startup -> Standby and register event receiver + 2. Calls Reset to move from startup -> standby and register event receiver 3. Waits for Reset to complete and checks state - 4. Calls Initialize to move from Standby -> Idle + 4. Calls Initialize to move from standby -> idle + 5. Verifies device is in idle state after Initialize """ # Step 1: Set up the HTTP event receiver server await self._sila.setup() - # Step 2: Reset (Startup -> Standby) - registers event receiver URI + # Step 2: Reset (startup -> standby) - registers event receiver URI # Reset is async, so we wait for it to complete event_receiver_uri = f"http://{self._sila._client_ip}:{self._sila.bound_port}/" await self.reset( @@ -136,19 +137,33 @@ async def setup(self) -> None: # Step 3: Check state after Reset completes # GetStatus is synchronous and will update our internal state tracking status = await self.get_status() + self.logger.info(f"GetStatus returned raw state: {status!r} (type: {type(status).__name__})") - # Normalize state string for comparison (device returns lowercase) - status_normalized = status.lower() if status else status + # Compare against enum values directly (no normalization - we want to see what device actually returns) + self.logger.debug(f"Comparing state '{status}' == SiLAState.STANDBY.value '{SiLAState.STANDBY.value}': {status == SiLAState.STANDBY.value}") + self.logger.debug(f"Comparing state '{status}' == SiLAState.IDLE.value '{SiLAState.IDLE.value}': {status == SiLAState.IDLE.value}") - # Step 4: Initialize (Standby -> Idle) if we're in Standby - if status_normalized == "standby": + if status == SiLAState.STANDBY.value: + self.logger.info("Device is in standby state, calling Initialize...") await self.initialize() - elif status_normalized == "idle": - # Already in Idle, nothing to do - self.logger.info("Device already in Idle state after Reset") + + # Step 4: Verify device is in idle state after Initialize + status_after_init = await self.get_status() + self.logger.info(f"GetStatus after Initialize returned state: {status_after_init!r}") + + if status_after_init == SiLAState.IDLE.value: + self.logger.info("Device successfully initialized and is in idle state") + else: + raise RuntimeError( + f"Device is not in idle state after Initialize. Expected {SiLAState.IDLE.value!r}, " + f"but got {status_after_init!r}." + ) + elif status == SiLAState.IDLE.value: + # Already in idle, nothing to do + self.logger.info("Device already in idle state after Reset") else: raise RuntimeError( - f"Unexpected device state after Reset: {status}. Expected standby or idle." + f"Unexpected device state after Reset: {status!r}. Expected {SiLAState.STANDBY.value!r} or {SiLAState.IDLE.value!r}." ) async def stop(self) -> None: @@ -171,36 +186,45 @@ async def get_status(self) -> str: """Get device status state. Returns: - Device state string (e.g., "Idle", "Busy", "Standby"). + Device state string (e.g., "idle", "busy", "standby"). + + Raises: + ValueError: If response format is unexpected and state cannot be extracted. """ resp = await self._sila.send_command("GetStatus") # GetStatus is synchronous - resp is a dict from soap_decode - if isinstance(resp, dict): - # Try different possible response structures - # Structure 1: GetStatusResponse -> state (like SCILABackend) - state = resp.get("GetStatusResponse", {}).get("state") - if state: - return state # type: ignore - # Structure 2: GetStatusResponse -> GetStatusResult -> state - state = resp.get("GetStatusResponse", {}).get("GetStatusResult", {}).get("state") - if state: - return state # type: ignore - # Structure 3: Direct state key - state = resp.get("state") - if state: - return state # type: ignore - # Debug: log the actual response structure to help diagnose - self.logger.debug(f"GetStatus response keys: {list(resp.keys())}") - if "GetStatusResponse" in resp: - self.logger.debug(f"GetStatusResponse keys: {list(resp['GetStatusResponse'].keys())}") - return "Unknown" - else: - # Fallback if response format is different - self.logger.warning(f"GetStatus returned non-dict response: {type(resp)}") - return "Unknown" + + if not isinstance(resp, dict): + self.logger.warning(f"GetStatus returned non-dict response: {type(resp)}, value: {resp!r}") + raise ValueError(f"GetStatus returned unexpected type: {type(resp).__name__}") + + # ODTC devices use: GetStatusResponse -> state + # Format: {"GetStatusResponse": {"state": "idle", "GetStatusResult": {...}, ...}} + # GetStatusResult contains return code info, but state is a direct child of GetStatusResponse + get_status_response = resp.get("GetStatusResponse") + if not isinstance(get_status_response, dict): + raise ValueError( + f"GetStatus: GetStatusResponse is not a dict. Response: {resp}" + ) + + # Check for state in GetStatusResponse (ODTC standard structure) + if "state" in get_status_response: + state = get_status_response["state"] + self.logger.debug(f"GetStatus returned state: {state!r}") + return str(state) + + # Unexpected structure - log and raise with full context + self.logger.error( + f"GetStatus: Could not find state in GetStatusResponse.state. " + f"Response: {resp}" + ) + raise ValueError( + f"GetStatus: Could not find state in GetStatusResponse.state. " + f"Response structure: {resp}" + ) async def initialize(self, wait: bool = True) -> Optional[CommandExecution]: - """Initialize the device (must be in Standby state). + """Initialize the device (must be in standby state). Args: wait: If True, block until completion. If False, return CommandExecution handle. @@ -507,7 +531,7 @@ async def is_method_running(self) -> bool: True if method is running (state is 'busy'), False otherwise. """ status = await self.get_status() - return status.lower() == "busy" + return status == SiLAState.BUSY.value async def wait_for_method_completion( self, diff --git a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py index 411fbeeaf0a..8cdd2633e72 100644 --- a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py +++ b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py @@ -85,7 +85,7 @@ class SiLAState(str, Enum): """SiLA device states per specification. Note: State values match what the ODTC device actually returns. - Based on SCILABackend comment, devices return: "standBy", "inError", "startup" (mixed/lowercase). + Based on SCILABackend comment, devices return: "standby", "inError", "startup" (mixed/lowercase). However, the actual ODTC device returns "standby" (all lowercase) as seen in practice. """ @@ -364,11 +364,15 @@ def _update_state_from_status(self, state_str: str) -> None: """Update internal state from GetStatus or StatusEvent response. Args: - state_str: State string from device response (must match enum values exactly). + state_str: State string from device response. Must match enum values exactly. """ if not state_str: + self._logger.debug("_update_state_from_status: Empty state string, skipping update") return + self._logger.debug(f"_update_state_from_status: Received state: {state_str!r} (type: {type(state_str).__name__})") + + # Match exactly against enum values (no normalization - we want to see what device actually returns) try: self._current_state = SiLAState(state_str) self._logger.debug(f"State updated to: {self._current_state.value}") @@ -517,7 +521,7 @@ async def _on_http(self, req: InhecoSiLAInterface._HTTPRequest) -> bytes: normalized_cmd = self._normalize_command_name(pending.name) self._executing_commands.discard(normalized_cmd) - # Update state: if no more commands executing, transition Busy -> Idle + # Update state: if no more commands executing, transition busy -> idle if not self._executing_commands and self._current_state == SiLAState.BUSY: self._current_state = SiLAState.IDLE @@ -695,10 +699,10 @@ def _do_request() -> bytes: # Synchronous success (GetStatus, GetDeviceIdentification) # Update state from GetStatus response if applicable if command == "GetStatus": - # Try different possible response structures - state = decoded.get("GetStatusResponse", {}).get("state") - if not state: - state = decoded.get("GetStatusResponse", {}).get("GetStatusResult", {}).get("state") + # ODTC standard: GetStatusResponse -> state + # GetStatusResult contains return code info, but state is a direct child of GetStatusResponse + get_status_response = decoded.get("GetStatusResponse", {}) + state = get_status_response.get("state") if state: self._update_state_from_status(state) return decoded @@ -745,7 +749,7 @@ def _do_request() -> bytes: self._active_request_ids.add(request_id) self._executing_commands.add(normalized_cmd) - # Update state: Idle -> Busy + # Update state: idle -> busy if self._current_state == SiLAState.IDLE: self._current_state = SiLAState.BUSY From e8d3d3aa787f0961f5724cf5e192c62616e743af Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:47:57 -0800 Subject: [PATCH 06/28] refactor(odtc): centralize response parsing and verify idle state after init Extract parsing utilities (_extract_dict_path, _extract_xml_parameter) and refactor 5 methods to use them. Add state verification after Initialize. Remove redundant logs and simplify error handling. --- pylabrobot/thermocycling/inheco/dev/README.md | 12 +- .../thermocycling/inheco/odtc_backend.py | 156 +++++++++--------- .../inheco/odtc_backend_tests.py | 39 ++++- 3 files changed, 120 insertions(+), 87 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/dev/README.md b/pylabrobot/thermocycling/inheco/dev/README.md index 3c50782a1bd..b79e88c018d 100644 --- a/pylabrobot/thermocycling/inheco/dev/README.md +++ b/pylabrobot/thermocycling/inheco/dev/README.md @@ -57,8 +57,8 @@ await tc.setup() The `setup()` method: 1. Starts HTTP server for receiving SiLA events (ResponseEvent, StatusEvent, DataEvent) -2. Calls `Reset()` to register event receiver URI and move to Standby -3. Calls `Initialize()` to move to Idle (ready for commands) +2. Calls `Reset()` to register event receiver URI and move to `standby` +3. Calls `Initialize()` to move to `idle` (ready for commands) ### Cleanup @@ -551,10 +551,10 @@ The implementation handles SiLA return codes and state transitions: - **Return code 9**: Command not allowed in current state State transitions are tracked automatically: -- `Startup` → `Standby` (via Reset) -- `Standby` → `Idle` (via Initialize) -- `Idle` → `Busy` (when async command starts) -- `Busy` → `Idle` (when all commands complete) +- `startup` → `standby` (via Reset) +- `standby` → `idle` (via Initialize) +- `idle` → `busy` (when async command starts) +- `busy` → `idle` (when all commands complete) ## Best Practices diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index 046f1e260e1..d65ea5ed145 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -139,17 +139,12 @@ async def setup(self) -> None: status = await self.get_status() self.logger.info(f"GetStatus returned raw state: {status!r} (type: {type(status).__name__})") - # Compare against enum values directly (no normalization - we want to see what device actually returns) - self.logger.debug(f"Comparing state '{status}' == SiLAState.STANDBY.value '{SiLAState.STANDBY.value}': {status == SiLAState.STANDBY.value}") - self.logger.debug(f"Comparing state '{status}' == SiLAState.IDLE.value '{SiLAState.IDLE.value}': {status == SiLAState.IDLE.value}") - if status == SiLAState.STANDBY.value: self.logger.info("Device is in standby state, calling Initialize...") await self.initialize() # Step 4: Verify device is in idle state after Initialize status_after_init = await self.get_status() - self.logger.info(f"GetStatus after Initialize returned state: {status_after_init!r}") if status_after_init == SiLAState.IDLE.value: self.logger.info("Device successfully initialized and is in idle state") @@ -179,7 +174,74 @@ def serialize(self) -> dict: } # ============================================================================ - # Basic ODTC Commands (from plan) + # Response Parsing Utilities + # ============================================================================ + + def _extract_dict_path( + self, resp: dict, path: List[str], command_name: str, required: bool = True + ) -> Any: + """Extract nested value from dict response using path. + + Args: + resp: Response dict from send_command (SOAP-decoded). + path: List of keys to traverse (e.g., ["GetStatusResponse", "state"]). + command_name: Command name for error messages. + required: If True, raise ValueError if path not found. + + Returns: + Extracted value, or None if not required and not found. + + Raises: + ValueError: If required=True and path not found or invalid structure. + """ + value = resp + for key in path: + if not isinstance(value, dict): + if required: + raise ValueError( + f"{command_name}: Expected dict at path {path}, got {type(value).__name__}" + ) + return None + value = value.get(key, {}) + + if value is None or (isinstance(value, dict) and not value and required): + if required: + raise ValueError( + f"{command_name}: Could not find value at path {path}. Response: {resp}" + ) + return None + self.logger.debug(f"{command_name} extracted value at path {path}: {value!r}") + return value + + def _extract_xml_parameter(self, resp, param_name: str, command_name: str) -> str: + """Extract parameter value from ElementTree XML response. + + Args: + resp: ElementTree root from send_command. + param_name: Name of parameter to extract. + command_name: Command name for error messages. + + Returns: + Parameter text value. + + Raises: + ValueError: If response is None or parameter not found. + """ + if resp is None: + raise ValueError(f"Empty response from {command_name}") + + param = resp.find(f".//Parameter[@name='{param_name}']") + if param is None: + raise ValueError(f"{param_name} parameter not found in {command_name} response") + + string_elem = param.find("String") + if string_elem is None or string_elem.text is None: + raise ValueError(f"{param_name} String element not found in {command_name} response") + + return string_elem.text + + # ============================================================================ + # Basic ODTC Commands # ============================================================================ async def get_status(self) -> str: @@ -193,35 +255,9 @@ async def get_status(self) -> str: """ resp = await self._sila.send_command("GetStatus") # GetStatus is synchronous - resp is a dict from soap_decode - - if not isinstance(resp, dict): - self.logger.warning(f"GetStatus returned non-dict response: {type(resp)}, value: {resp!r}") - raise ValueError(f"GetStatus returned unexpected type: {type(resp).__name__}") - - # ODTC devices use: GetStatusResponse -> state - # Format: {"GetStatusResponse": {"state": "idle", "GetStatusResult": {...}, ...}} - # GetStatusResult contains return code info, but state is a direct child of GetStatusResponse - get_status_response = resp.get("GetStatusResponse") - if not isinstance(get_status_response, dict): - raise ValueError( - f"GetStatus: GetStatusResponse is not a dict. Response: {resp}" - ) - - # Check for state in GetStatusResponse (ODTC standard structure) - if "state" in get_status_response: - state = get_status_response["state"] - self.logger.debug(f"GetStatus returned state: {state!r}") - return str(state) - - # Unexpected structure - log and raise with full context - self.logger.error( - f"GetStatus: Could not find state in GetStatusResponse.state. " - f"Response: {resp}" - ) - raise ValueError( - f"GetStatus: Could not find state in GetStatusResponse.state. " - f"Response structure: {resp}" - ) + # ODTC standard structure: {"GetStatusResponse": {"state": "idle", ...}} + state = self._extract_dict_path(resp, ["GetStatusResponse", "state"], "GetStatus") + return str(state) async def initialize(self, wait: bool = True) -> Optional[CommandExecution]: """Initialize the device (must be in standby state). @@ -301,10 +337,13 @@ async def get_device_identification(self) -> dict: """ resp = await self._sila.send_command("GetDeviceIdentification") # GetDeviceIdentification is synchronous - resp is a dict from soap_decode - if isinstance(resp, dict): - return resp.get("GetDeviceIdentificationResponse", {}).get("GetDeviceIdentificationResult", {}) # type: ignore - else: - return {} + result = self._extract_dict_path( + resp, + ["GetDeviceIdentificationResponse", "GetDeviceIdentificationResult"], + "GetDeviceIdentification", + required=False, + ) + return result if isinstance(result, dict) else {} async def lock_device(self, lock_id: str, lock_timeout: Optional[float] = None, wait: bool = True) -> Optional[CommandExecution]: """Lock the device for exclusive access. @@ -427,19 +466,10 @@ async def read_temperatures(self) -> ODTCSensorValues: ODTCSensorValues with temperatures in °C. """ resp = await self._sila.send_command("ReadActualTemperature") - # Response is ElementTree root - find SensorValues parameter - if resp is None: - raise ValueError("Empty response from ReadActualTemperature") - + # Response is ElementTree root - extract SensorValues parameter # Response structure: ResponseData/Parameter[@name='SensorValues']/String - param = resp.find(".//Parameter[@name='SensorValues']") - if param is None: - raise ValueError("SensorValues parameter not found in response") - sensor_str_elem = param.find("String") - if sensor_str_elem is None or sensor_str_elem.text is None: - raise ValueError("SensorValues String element not found") + sensor_xml = self._extract_xml_parameter(resp, "SensorValues", "ReadActualTemperature") # Parse the XML string (it's escaped in the response) - sensor_xml = sensor_str_elem.text return parse_sensor_values(sensor_xml) async def get_last_data(self) -> str: @@ -589,20 +619,9 @@ async def get_method_set(self) -> ODTCMethodSet: ValueError: If response is empty or MethodsXML parameter not found. """ resp = await self._sila.send_command("GetParameters") - if resp is None: - raise ValueError("Empty response from GetParameters") - # Extract MethodsXML parameter - param = resp.find(".//Parameter[@name='MethodsXML']") - if param is None: - raise ValueError("MethodsXML parameter not found in response") - - string_elem = param.find("String") - if string_elem is None or string_elem.text is None: - raise ValueError("MethodsXML String element not found") - + method_set_xml = self._extract_xml_parameter(resp, "MethodsXML", "GetParameters") # Parse MethodSet XML (it's escaped in the response) - method_set_xml = string_elem.text return parse_method_set(method_set_xml) async def get_method_by_name(self, method_name: str) -> Optional[ODTCMethod]: @@ -653,20 +672,9 @@ async def save_method_set_to_file(self, filepath: str) -> None: filepath: Path to save MethodSet XML file. """ resp = await self._sila.send_command("GetParameters") - if resp is None: - raise ValueError("Empty response from GetParameters") - # Extract MethodsXML parameter - param = resp.find(".//Parameter[@name='MethodsXML']") - if param is None: - raise ValueError("MethodsXML parameter not found in response") - - string_elem = param.find("String") - if string_elem is None or string_elem.text is None: - raise ValueError("MethodsXML String element not found") - + method_set_xml = self._extract_xml_parameter(resp, "MethodsXML", "GetParameters") # XML is escaped in the response, so we get it as-is - method_set_xml = string_elem.text # Write to file with open(filepath, "w", encoding="utf-8") as f: f.write(method_set_xml) diff --git a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py index e54507efbf8..1071cb92d6c 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py @@ -74,15 +74,35 @@ def test_validate_lock_id(self): self.assertIn("5", error_msg) # Return code 5 def test_update_state_from_status(self): - """Test state updates from status strings.""" - self.interface._update_state_from_status("Idle") + """Test state updates from status strings - exact matching only.""" + # Test lowercase enum values (exact match required) + self.interface._update_state_from_status("idle") self.assertEqual(self.interface._current_state, SiLAState.IDLE) - self.interface._update_state_from_status("Busy") + self.interface._update_state_from_status("busy") self.assertEqual(self.interface._current_state, SiLAState.BUSY) - self.interface._update_state_from_status("Standby") + self.interface._update_state_from_status("standby") self.assertEqual(self.interface._current_state, SiLAState.STANDBY) + + # Test camelCase enum values (exact match required) + self.interface._update_state_from_status("inError") + self.assertEqual(self.interface._current_state, SiLAState.INERROR) + + self.interface._update_state_from_status("errorHandling") + self.assertEqual(self.interface._current_state, SiLAState.ERRORHANDLING) + + # Test that case mismatches are NOT accepted (exact matching only) + # These should keep the current state and log a warning + initial_state = self.interface._current_state + self.interface._update_state_from_status("Idle") # Wrong case + self.assertEqual(self.interface._current_state, initial_state) # Should remain unchanged + + self.interface._update_state_from_status("BUSY") # Wrong case + self.assertEqual(self.interface._current_state, initial_state) # Should remain unchanged + + self.interface._update_state_from_status("INERROR") # Wrong case + self.assertEqual(self.interface._current_state, initial_state) # Should remain unchanged def test_handle_return_code(self): """Test return code handling.""" @@ -93,7 +113,7 @@ def test_handle_return_code(self): # Code 4 should raise with self.assertRaises(RuntimeError) as cm: - self.interface._handle_return_code(4, "Busy", "ExecuteMethod", 123) + self.interface._handle_return_code(4, "busy", "ExecuteMethod", 123) self.assertIn("return code 4", str(cm.exception)) # Code 5 should raise @@ -139,10 +159,15 @@ async def test_stop(self): async def test_get_status(self): """Test get_status.""" self.backend._sila.send_command = AsyncMock( - return_value={"GetStatusResponse": {"GetStatusResult": {"state": "Idle"}}} + return_value={ + "GetStatusResponse": { + "state": "idle", + "GetStatusResult": {"returnCode": 1, "message": "Success."} + } + } ) status = await self.backend.get_status() - self.assertEqual(status, "Idle") + self.assertEqual(status, "idle") self.backend._sila.send_command.assert_called_once_with("GetStatus") async def test_open_door(self): From 1f9417f4b77abfa1d1bfe2c8061ac059d9709fe3 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:02:23 -0800 Subject: [PATCH 07/28] Formatting and notebook example --- .../inheco/dev/test_door_commands.ipynb | 142 +++++++++--------- .../thermocycling/inheco/odtc_backend.py | 27 ++-- .../inheco/odtc_backend_tests.py | 112 ++++++++------ pylabrobot/thermocycling/inheco/odtc_xml.py | 22 +-- 4 files changed, 164 insertions(+), 139 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb b/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb index da58c825d08..aa622cb84e9 100644 --- a/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb +++ b/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb @@ -18,129 +18,122 @@ ] }, { - "cell_type": "code", - "execution_count": 1, + "cell_type": "markdown", "metadata": {}, - "outputs": [], "source": [ - "# Import required modules\n", - "import asyncio\n", - "import sys\n", - "from pathlib import Path\n", - "\n", - "from pylabrobot.thermocycling.inheco import InhecoODTC, ODTCBackend" + "# 1. Libraries and configuration" ] }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 1, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Will connect to ODTC at 192.168.1.50\n" + "Logging configured. Debug information will be displayed.\n" ] } ], "source": [ - "# Configuration\n", - "# TODO: Replace with your ODTC device IP address\n", - "ODTC_IP = \"192.168.1.50\" # Change this to your ODTC's IP address\n", + "# Import required modules\n", + "import logging\n", + "\n", + "from pylabrobot.thermocycling.inheco import InhecoODTC, ODTCBackend\n", + "\n", + "# Configure logging to show debug information\n", + "logging.basicConfig(\n", + " level=logging.DEBUG, # Set to DEBUG for more verbose output\n", + " format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',\n", + " datefmt='%H:%M:%S'\n", + ")\n", + "\n", + "# Get logger for ODTC backend to see status debugging\n", + "odtc_logger = logging.getLogger('pylabrobot.thermocycling.inheco.odtc_backend')\n", + "odtc_logger.setLevel(logging.DEBUG) # Set to DEBUG for more verbose output\n", "\n", - "print(f\"Will connect to ODTC at {ODTC_IP}\")" + "sila_logger = logging.getLogger('pylabrobot.thermocycling.inheco.odtc_sila_interface')\n", + "sila_logger.setLevel(logging.DEBUG) # Set to DEBUG for more verbose output\n", + "\n", + "print(\"Logging configured. Debug information will be displayed.\")" ] }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Backend and thermocycler objects created.\n" + "Backend and thermocycler objects created.\n", + "Backend logger level: 10 (DEBUG)\n" ] } ], "source": [ - "# Use model=\"96\" for 96-well or model=\"384\" for 384-well format\n", - "tc = InhecoODTC(name=\"odtc_test\", backend=ODTCBackend(odtc_ip=ODTC_IP), model=\"96\")\n", - "\n", - "print(\"Backend and thermocycler objects created.\")" + "# Configuration\n", + "backend = ODTCBackend(odtc_ip=\"192.168.1.50\", logger=odtc_logger)\n", + "tc = InhecoODTC(name=\"odtc_test\", backend=backend, model=\"96\")\n", + "print(\"Backend and thermocycler objects created.\")\n", + "print(f\"Backend logger level: {backend.logger.level} ({logging.getLevelName(backend.logger.level)})\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Connection Management" + "# 2. Connection and method introspection" ] }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 3, "metadata": {}, "outputs": [ { - "name": "stderr", + "name": "stdout", "output_type": "stream", "text": [ - "2026-01-26 13:57:19,244 - pylabrobot.storage.inheco.scila.inheco_sila_interface - INFO - Device reset (unlocked)\n" + "Starting device setup...\n", + "============================================================\n" ] }, { - "name": "stdout", + "name": "stderr", "output_type": "stream", "text": [ - "✓ Connected and initialized successfully!\n", - "Device should now be in Idle state and ready for commands.\n" + "2026-01-26 16:00:53,874 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device reset (unlocked)\n", + "2026-01-26 16:00:54,149 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - GetStatus returned raw state: 'standby' (type: str)\n", + "2026-01-26 16:00:54,149 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device is in standby state, calling Initialize...\n", + "2026-01-26 16:01:02,209 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device successfully initialized and is in idle state\n" ] - } - ], - "source": [ - "# Connect and initialize the device\n", - "# This performs the full SiLA connection lifecycle:\n", - "# 1. Sets up HTTP event receiver server\n", - "# 2. Calls Reset (Startup -> Standby) to register event receiver\n", - "# 3. Calls Initialize (Standby -> Idle) to ready the device\n", - "await tc.setup()\n", - "print(\"✓ Connected and initialized successfully!\")\n", - "print(\"Device should now be in Idle state and ready for commands.\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Status Commands" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ + }, { "name": "stdout", "output_type": "stream", "text": [ - "Device status: idle\n" + "============================================================\n", + "✓ Connected and initialized successfully!\n" ] } ], "source": [ - "# Get device status\n", - "# After setup(), device should be in \"Idle\" state\n", - "status = await tc.get_status()\n", - "print(f\"Device status: {status}\")" + "print(\"Starting device setup...\")\n", + "print(\"=\" * 60)\n", + "\n", + "await tc.setup()\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"✓ Connected and initialized successfully!\")" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 4, "metadata": {}, "outputs": [ { @@ -253,6 +246,13 @@ "# method = await tc.get_method_by_name(\"PCR_30cycles\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 3. Commands" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -261,12 +261,12 @@ "\n", "The door commands support non-blocking execution using the `wait=False` parameter. This returns a `CommandExecution` handle that you can await later, allowing you to do other work while the door operation completes. \n", "\n", - "Just set wait=True to treat these as blocking and run them as normal commands" + "Note: Just set wait=True to treat these as blocking and run them as normal commands" ] }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 5, "metadata": {}, "outputs": [ { @@ -274,7 +274,7 @@ "output_type": "stream", "text": [ "Starting door close (non-blocking)...\n", - "✓ Door close command started! Request ID: 1783384016\n" + "✓ Door close command started! Request ID: 613788033\n" ] } ], @@ -287,7 +287,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 6, "metadata": {}, "outputs": [ { @@ -296,7 +296,7 @@ "text": [ "Waiting for door to close...\n", "✓ Door close completed!\n", - "Door opening with request ID: 1991913242\n", + "Door opening with request ID: 1835317567\n", "DataEvents collected: 0\n", "✓ Door open completed!\n" ] @@ -326,14 +326,22 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Close the connection" + "# 4. Close the connection" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Connection closed.\n" + ] + } + ], "source": [ "# Close the connection\n", "await tc.stop()\n", diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index d65ea5ed145..d2f290267bc 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -5,9 +5,8 @@ import asyncio import logging from dataclasses import dataclass -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, cast -from pylabrobot.machines.backend import MachineBackend from pylabrobot.thermocycling.backend import ThermocyclerBackend from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol @@ -114,7 +113,7 @@ def __init__( async def setup(self) -> None: """Initialize the ODTC device connection. - + Performs the full SiLA connection lifecycle: 1. Sets up the HTTP event receiver server 2. Calls Reset to move from startup -> standby and register event receiver @@ -124,7 +123,7 @@ async def setup(self) -> None: """ # Step 1: Set up the HTTP event receiver server await self._sila.setup() - + # Step 2: Reset (startup -> standby) - registers event receiver URI # Reset is async, so we wait for it to complete event_receiver_uri = f"http://{self._sila._client_ip}:{self._sila.bound_port}/" @@ -133,19 +132,19 @@ async def setup(self) -> None: event_receiver_uri=event_receiver_uri, simulation_mode=False, ) - + # Step 3: Check state after Reset completes # GetStatus is synchronous and will update our internal state tracking status = await self.get_status() self.logger.info(f"GetStatus returned raw state: {status!r} (type: {type(status).__name__})") - + if status == SiLAState.STANDBY.value: self.logger.info("Device is in standby state, calling Initialize...") await self.initialize() - + # Step 4: Verify device is in idle state after Initialize status_after_init = await self.get_status() - + if status_after_init == SiLAState.IDLE.value: self.logger.info("Device successfully initialized and is in idle state") else: @@ -213,7 +212,7 @@ def _extract_dict_path( self.logger.debug(f"{command_name} extracted value at path {path}: {value!r}") return value - def _extract_xml_parameter(self, resp, param_name: str, command_name: str) -> str: + def _extract_xml_parameter(self, resp: Any, param_name: str, command_name: str) -> str: """Extract parameter value from ElementTree XML response. Args: @@ -238,7 +237,7 @@ def _extract_xml_parameter(self, resp, param_name: str, command_name: str) -> st if string_elem is None or string_elem.text is None: raise ValueError(f"{param_name} String element not found in {command_name} response") - return string_elem.text + return str(string_elem.text) # ============================================================================ # Basic ODTC Commands @@ -249,14 +248,15 @@ async def get_status(self) -> str: Returns: Device state string (e.g., "idle", "busy", "standby"). - + Raises: ValueError: If response format is unexpected and state cannot be extracted. """ resp = await self._sila.send_command("GetStatus") # GetStatus is synchronous - resp is a dict from soap_decode # ODTC standard structure: {"GetStatusResponse": {"state": "idle", ...}} - state = self._extract_dict_path(resp, ["GetStatusResponse", "state"], "GetStatus") + resp_dict = cast(Dict[str, Any], resp) + state = self._extract_dict_path(resp_dict, ["GetStatusResponse", "state"], "GetStatus") return str(state) async def initialize(self, wait: bool = True) -> Optional[CommandExecution]: @@ -337,8 +337,9 @@ async def get_device_identification(self) -> dict: """ resp = await self._sila.send_command("GetDeviceIdentification") # GetDeviceIdentification is synchronous - resp is a dict from soap_decode + resp_dict = cast(Dict[str, Any], resp) result = self._extract_dict_path( - resp, + resp_dict, ["GetDeviceIdentificationResponse", "GetDeviceIdentificationResult"], "GetDeviceIdentification", required=False, diff --git a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py index 1071cb92d6c..01d62450762 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py @@ -3,6 +3,7 @@ import asyncio import unittest import xml.etree.ElementTree as ET +from typing import Any, Dict, List from unittest.mock import AsyncMock, MagicMock, patch from pylabrobot.thermocycling.inheco.odtc_backend import CommandExecution, MethodExecution, ODTCBackend @@ -146,19 +147,24 @@ def setUp(self): async def test_setup(self): """Test backend setup.""" - self.backend._sila.setup = AsyncMock() + self.backend._sila.setup = AsyncMock() # type: ignore[method-assign] + self.backend._sila._client_ip = "192.168.1.1" # type: ignore[attr-defined] + # Mock the read-only property by setting it on the mock object + setattr(self.backend._sila, 'bound_port', 8080) # type: ignore[misc] + self.backend.reset = AsyncMock() # type: ignore[method-assign] + self.backend.get_status = AsyncMock(return_value="idle") # type: ignore[method-assign] await self.backend.setup() self.backend._sila.setup.assert_called_once() async def test_stop(self): """Test backend stop.""" - self.backend._sila.close = AsyncMock() + self.backend._sila.close = AsyncMock() # type: ignore[method-assign] await self.backend.stop() self.backend._sila.close.assert_called_once() async def test_get_status(self): """Test get_status.""" - self.backend._sila.send_command = AsyncMock( + self.backend._sila.send_command = AsyncMock( # type: ignore[method-assign] return_value={ "GetStatusResponse": { "state": "idle", @@ -172,13 +178,13 @@ async def test_get_status(self): async def test_open_door(self): """Test open_door.""" - self.backend._sila.send_command = AsyncMock() + self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign] await self.backend.open_door() self.backend._sila.send_command.assert_called_once_with("OpenDoor") async def test_close_door(self): """Test close_door.""" - self.backend._sila.send_command = AsyncMock() + self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign] await self.backend.close_door() self.backend._sila.send_command.assert_called_once_with("CloseDoor") @@ -204,26 +210,26 @@ async def test_read_temperatures(self): string_elem = ET.SubElement(param, "String") string_elem.text = sensor_xml - self.backend._sila.send_command = AsyncMock(return_value=root) + self.backend._sila.send_command = AsyncMock(return_value=root) # type: ignore[method-assign] sensor_values = await self.backend.read_temperatures() self.assertAlmostEqual(sensor_values.mount, 24.63, places=2) # 2463 * 0.01 self.assertAlmostEqual(sensor_values.lid, 25.75, places=2) # 2575 * 0.01 async def test_execute_method(self): """Test execute_method.""" - self.backend._sila.send_command = AsyncMock() + self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign] await self.backend.execute_method("MyMethod") self.backend._sila.send_command.assert_called_once_with("ExecuteMethod", methodName="MyMethod") async def test_stop_method(self): """Test stop_method.""" - self.backend._sila.send_command = AsyncMock() + self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign] await self.backend.stop_method() self.backend._sila.send_command.assert_called_once_with("StopMethod") async def test_lock_device(self): """Test lock_device.""" - self.backend._sila.send_command = AsyncMock() + self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign] await self.backend.lock_device("my_lock_id") self.backend._sila.send_command.assert_called_once() call_kwargs = self.backend._sila.send_command.call_args[1] @@ -233,7 +239,7 @@ async def test_lock_device(self): async def test_unlock_device(self): """Test unlock_device.""" self.backend._sila._lock_id = "my_lock_id" - self.backend._sila.send_command = AsyncMock() + self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign] await self.backend.unlock_device() self.backend._sila.send_command.assert_called_once_with("UnlockDevice", lock_id="my_lock_id") @@ -252,7 +258,7 @@ async def test_get_block_current_temperature(self): string_elem = ET.SubElement(param, "String") string_elem.text = sensor_xml - self.backend._sila.send_command = AsyncMock(return_value=root) + 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) @@ -265,14 +271,14 @@ async def test_get_lid_current_temperature(self): string_elem = ET.SubElement(param, "String") string_elem.text = sensor_xml - self.backend._sila.send_command = AsyncMock(return_value=root) + 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_execute_method_wait_true(self): """Test execute_method with wait=True (blocking).""" - self.backend._sila.send_command = AsyncMock(return_value=None) + self.backend._sila.send_command = AsyncMock(return_value=None) # type: ignore[method-assign] result = await self.backend.execute_method("PCR_30cycles", wait=True) self.assertIsNone(result) self.backend._sila.send_command.assert_called_once_with( @@ -281,10 +287,11 @@ async def test_execute_method_wait_true(self): async def test_execute_method_wait_false(self): """Test execute_method with wait=False (returns handle).""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] execution = await self.backend.execute_method("PCR_30cycles", wait=False) + assert execution is not None # Type narrowing self.assertIsInstance(execution, MethodExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.method_name, "PCR_30cycles") @@ -294,7 +301,7 @@ async def test_execute_method_wait_false(self): async def test_method_execution_awaitable(self): """Test that MethodExecution is awaitable.""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() fut.set_result("success") execution = MethodExecution( request_id=12345, @@ -308,7 +315,7 @@ async def test_method_execution_awaitable(self): async def test_method_execution_wait(self): """Test MethodExecution.wait() method.""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) execution = MethodExecution( request_id=12345, @@ -321,7 +328,7 @@ async def test_method_execution_wait(self): async def test_method_execution_is_running(self): """Test MethodExecution.is_running() method.""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() execution = MethodExecution( request_id=12345, command_name="ExecuteMethod", @@ -329,13 +336,13 @@ async def test_method_execution_is_running(self): _future=fut, backend=self.backend ) - self.backend.get_status = AsyncMock(return_value="busy") + self.backend.get_status = AsyncMock(return_value="busy") # type: ignore[method-assign] is_running = await execution.is_running() self.assertTrue(is_running) async def test_method_execution_stop(self): """Test MethodExecution.stop() method.""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() execution = MethodExecution( request_id=12345, command_name="ExecuteMethod", @@ -343,13 +350,13 @@ async def test_method_execution_stop(self): _future=fut, backend=self.backend ) - self.backend._sila.send_command = AsyncMock() + self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign] await execution.stop() self.backend._sila.send_command.assert_called_once_with("StopMethod", return_request_id=False) async def test_method_execution_inheritance(self): """Test that MethodExecution is a subclass of CommandExecution.""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) execution = MethodExecution( request_id=12345, @@ -364,7 +371,7 @@ async def test_method_execution_inheritance(self): async def test_command_execution_awaitable(self): """Test that CommandExecution is awaitable.""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() fut.set_result("success") execution = CommandExecution( request_id=12345, @@ -377,7 +384,7 @@ async def test_command_execution_awaitable(self): async def test_command_execution_wait(self): """Test CommandExecution.wait() method.""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) execution = CommandExecution( request_id=12345, @@ -389,7 +396,7 @@ async def test_command_execution_wait(self): async def test_command_execution_get_data_events(self): """Test CommandExecution.get_data_events() method.""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) execution = CommandExecution( request_id=12345, @@ -407,10 +414,11 @@ async def test_command_execution_get_data_events(self): async def test_open_door_wait_false(self): """Test open_door with wait=False (returns handle).""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] execution = await self.backend.open_door(wait=False) + assert execution is not None # Type narrowing self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "OpenDoor") @@ -420,7 +428,7 @@ async def test_open_door_wait_false(self): async def test_open_door_wait_true(self): """Test open_door with wait=True (blocking).""" - self.backend._sila.send_command = AsyncMock(return_value=None) + self.backend._sila.send_command = AsyncMock(return_value=None) # type: ignore[method-assign] result = await self.backend.open_door(wait=True) self.assertIsNone(result) self.backend._sila.send_command.assert_called_once_with( @@ -429,74 +437,80 @@ async def test_open_door_wait_true(self): async def test_close_door_wait_false(self): """Test close_door with wait=False (returns handle).""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] execution = await self.backend.close_door(wait=False) + assert execution is not None # Type narrowing self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "CloseDoor") async def test_initialize_wait_false(self): """Test initialize with wait=False (returns handle).""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] execution = await self.backend.initialize(wait=False) + assert execution is not None # Type narrowing self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "Initialize") async def test_reset_wait_false(self): """Test reset with wait=False (returns handle).""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] execution = await self.backend.reset(wait=False) + assert execution is not None # Type narrowing self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "Reset") async def test_lock_device_wait_false(self): """Test lock_device with wait=False (returns handle).""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] execution = await self.backend.lock_device("my_lock", wait=False) + assert execution is not None # Type narrowing self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "LockDevice") async def test_unlock_device_wait_false(self): """Test unlock_device with wait=False (returns handle).""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) self.backend._sila._lock_id = "my_lock" - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] execution = await self.backend.unlock_device(wait=False) + assert execution is not None # Type narrowing self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "UnlockDevice") async def test_stop_method_wait_false(self): """Test stop_method with wait=False (returns handle).""" - fut = asyncio.Future() + fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) + self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] execution = await self.backend.stop_method(wait=False) + assert execution is not None # Type narrowing self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "StopMethod") async def test_is_method_running(self): """Test is_method_running().""" - self.backend.get_status = AsyncMock(return_value="busy") + self.backend.get_status = AsyncMock(return_value="busy") # type: ignore[method-assign] self.assertTrue(await self.backend.is_method_running()) - self.backend.get_status = AsyncMock(return_value="idle") + self.backend.get_status = AsyncMock(return_value="idle") # type: ignore[method-assign] self.assertFalse(await self.backend.is_method_running()) - self.backend.get_status = AsyncMock(return_value="BUSY") + self.backend.get_status = AsyncMock(return_value="BUSY") # type: ignore[method-assign] self.assertTrue(await self.backend.is_method_running()) async def test_wait_for_method_completion(self): @@ -510,13 +524,13 @@ async def mock_get_status(): return "busy" return "idle" - self.backend.get_status = AsyncMock(side_effect=mock_get_status) + self.backend.get_status = AsyncMock(side_effect=mock_get_status) # type: ignore[method-assign] await self.backend.wait_for_method_completion(poll_interval=0.1) self.assertEqual(call_count, 3) async def test_wait_for_method_completion_timeout(self): """Test wait_for_method_completion() with timeout.""" - self.backend.get_status = AsyncMock(return_value="busy") + self.backend.get_status = AsyncMock(return_value="busy") # type: ignore[method-assign] with self.assertRaises(TimeoutError): await self.backend.wait_for_method_completion(poll_interval=0.1, timeout=0.3) @@ -550,7 +564,7 @@ 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 = {} + data_events_by_request_id: Dict[int, List[Dict[str, Any]]] = {} # Simulate receiving a DataEvent data_event = { @@ -560,7 +574,7 @@ def test_data_event_storage_logic(self): # Apply the same logic as in _on_http handler request_id = data_event.get("requestId") - if request_id is not None: + 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) @@ -579,7 +593,7 @@ def test_data_event_storage_logic(self): "data": "test_data2" } request_id = data_event2.get("requestId") - if request_id is not None: + 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) @@ -591,7 +605,7 @@ def test_data_event_storage_logic(self): "data": "test_data_no_id" } request_id = data_event_no_id.get("requestId") - if request_id is not None: + 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) diff --git a/pylabrobot/thermocycling/inheco/odtc_xml.py b/pylabrobot/thermocycling/inheco/odtc_xml.py index 889485e7447..335b3b64a24 100644 --- a/pylabrobot/thermocycling/inheco/odtc_xml.py +++ b/pylabrobot/thermocycling/inheco/odtc_xml.py @@ -11,7 +11,10 @@ import xml.etree.ElementTree as ET from dataclasses import dataclass, field, fields from enum import Enum -from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, Union, get_args, get_origin, get_type_hints +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, TypeVar, Union, cast, get_args, get_origin, get_type_hints + +if TYPE_CHECKING: + from pylabrobot.thermocycling.standard import Protocol logger = logging.getLogger(__name__) @@ -376,7 +379,7 @@ def validate(self) -> List[str]: ) if errors: - raise ValueError(f"ODTCConfig validation failed:\n - " + "\n - ".join(errors)) + raise ValueError("ODTCConfig validation failed:\n - " + "\n - ".join(errors)) return errors @@ -389,7 +392,7 @@ def validate(self) -> List[str]: def _get_xml_meta(f) -> XMLField: """Get XMLField metadata from a dataclass field, or create default.""" if "xml" in f.metadata: - return f.metadata["xml"] + return cast(XMLField, f.metadata["xml"]) # Default: element with field name as tag return XMLField(tag=None, field_type=XMLFieldType.ELEMENT) @@ -399,15 +402,16 @@ def _get_tag(f, meta: XMLField) -> str: return meta.tag if meta.tag else f.name -def _get_inner_type(type_hint) -> Optional[Type]: +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 args[0] + return cast(Type[Any], args[0]) if origin is Union and type(None) in args: # Optional[T] is Union[T, None] - return next((a for a in args if a is not type(None)), 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 @@ -461,7 +465,8 @@ def from_xml(elem: ET.Element, cls: Type[T]) -> T: # Use get_type_hints to resolve string annotations to actual types type_hints = get_type_hints(cls) - for f in fields(cls): + # Type narrowing: we've verified cls is a dataclass, so fields() is safe + for f in fields(cls): # type: ignore[arg-type] meta = _get_xml_meta(f) tag = _get_tag(f, meta) field_type = type_hints.get(f.name, f.type) @@ -777,9 +782,6 @@ def protocol_to_odtc_method( This function handles sequential stages with repeats. Each stage with repeats > 1 is converted to an ODTC loop using GotoNumber/LoopNumber. """ - # Import here to avoid circular imports - from pylabrobot.thermocycling.standard import Protocol - if config is None: config = ODTCConfig() From ad1a6f237e616df04943a2c2a35dcccba7631720 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:23:45 -0800 Subject: [PATCH 08/28] feat: Add protocol management and temperature control to Inheco ODTC - Enforce method vs protocol conventions --- .../inheco/dev/test_door_commands.ipynb | 234 +++++++++--- pylabrobot/thermocycling/inheco/odtc.py | 271 +++++++++++--- .../thermocycling/inheco/odtc_backend.py | 340 +++++++++++++++++- .../inheco/odtc_sila_interface.py | 73 ++-- pylabrobot/thermocycling/inheco/odtc_xml.py | 98 ++++- 5 files changed, 861 insertions(+), 155 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb b/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb index aa622cb84e9..3acbba3cea9 100644 --- a/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb +++ b/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb @@ -106,10 +106,10 @@ "name": "stderr", "output_type": "stream", "text": [ - "2026-01-26 16:00:53,874 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device reset (unlocked)\n", - "2026-01-26 16:00:54,149 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - GetStatus returned raw state: 'standby' (type: str)\n", - "2026-01-26 16:00:54,149 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device is in standby state, calling Initialize...\n", - "2026-01-26 16:01:02,209 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device successfully initialized and is in idle state\n" + "2026-01-26 22:08:28,661 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device reset (unlocked)\n", + "2026-01-26 22:08:28,859 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - GetStatus returned raw state: 'standby' (type: str)\n", + "2026-01-26 22:08:28,860 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device is in standby state, calling Initialize...\n", + "2026-01-26 22:08:30,396 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device successfully initialized and is in idle state\n" ] }, { @@ -140,24 +140,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "PreMethods (15):\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", - "Methods (61):\n", + "Methods and PreMethods (77):\n", " - M18_Abnahmetest\n", " - M23_LEAK\n", " - M24_LEAKCYCLE\n", @@ -218,32 +201,41 @@ " - 30-20min\n", " - Nifty_ER\n", " - Nifty_Ad Ligation\n", - " - Nifty_PCR\n" + " - Nifty_PCR\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", + " - plr_currentProtocol\n" ] } ], "source": [ - "# Get methods and premethods stored on the device\n", + "# List all methods and premethods stored on the device\n", "# Note: Requires device to be in Idle state (after setup/initialize)\n", - "# Returns ODTCMethodSet object with premethods and methods attributes\n", - "method_set = await tc.get_method_set()\n", - "\n", - "print(f\"PreMethods ({len(method_set.premethods)}):\")\n", - "if method_set.premethods:\n", - " for pm in method_set.premethods:\n", - " print(f\" - {pm.name}\")\n", - "else:\n", - " print(\" (none)\")\n", + "# Returns unified list of all method names (both methods and premethods)\n", + "method_names = await tc.list_methods()\n", "\n", - "print(f\"\\nMethods ({len(method_set.methods)}):\")\n", - "if method_set.methods:\n", - " for m in method_set.methods:\n", - " print(f\" - {m.name}\")\n", + "print(f\"Methods and PreMethods ({len(method_names)}):\")\n", + "if method_names:\n", + " for name in method_names:\n", + " print(f\" - {name}\")\n", "else:\n", " print(\" (none)\")\n", "\n", "# You can also get a specific method by name:\n", - "# method = await tc.get_method_by_name(\"PCR_30cycles\")" + "# method = await tc.get_method(\"PCR_30cycles\")" ] }, { @@ -274,7 +266,7 @@ "output_type": "stream", "text": [ "Starting door close (non-blocking)...\n", - "✓ Door close command started! Request ID: 613788033\n" + "✓ Door close command started! Request ID: 1351407823\n" ] } ], @@ -285,68 +277,196 @@ "print(f\"✓ Door close command started! Request ID: {door_closing.request_id}\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 5. Temperature Control" + ] + }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Waiting for door to close...\n", + "Waiting for door to close...\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-01-26 22:08:43,684 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - MethodSet XML saved to: debug_set_mount_temp.xml\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "✓ Door close completed!\n", - "Door opening with request ID: 1835317567\n", - "DataEvents collected: 0\n", - "✓ Door open completed!\n" + "Setting mount temperature to 37°C...\n", + "✓ Method started! Request ID: 712229516\n", + "Current temperatures:\n", + "ODTCSensorValues(timestamp='2026-01-26T23:27:44Z', mount=24.42, mount_monitor=24.27, lid=56.4, lid_monitor=56.81, ambient=22.95, pcb=28.84, heatsink=24.71, heatsink_tec=24.23)\n", + "Check debug_set_mount_temp.xml for the generated XML\n" ] } ], "source": [ - "# Access DataEvents for a command execution\n", - "# (Note: DataEvents are primarily used for method execution, but the pattern works for all commands)\n", - "# You can also use the wait() method explicitly\n", + "# Set mount temperature to 37°C\n", + "# This creates a minimal protocol and uploads/runs it to the scratch file\n", + "# post_heating=True keeps temperatures held after method completes\n", "print(\"Waiting for door to close...\")\n", "await door_closing.wait()\n", "print(\"✓ Door close completed!\")\n", "\n", - "door_opening = await tc.open_door(wait=False)\n", - "print(f\"Door opening with request ID: {door_opening.request_id}\")\n", + "print(\"Setting mount temperature to 37°C...\")\n", + "execution = await tc.set_mount_temperature(\n", + " 37.0,\n", + " wait=False,\n", + " debug_xml=True, # Enable XML logging (shows in console if DEBUG logging is on)\n", + " xml_output_path=\"debug_set_mount_temp.xml\" # Save XML to file\n", + ")\n", + "\n", + "if await tc.get_status() == \"busy\":\n", + " print(f\"✓ Method started! Request ID: {execution.request_id}\")\n", + " print(f\"Current temperatures:\\n{await tc.read_temperatures()}\")\n", + " print(\"Check debug_set_mount_temp.xml for the generated XML\")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "ODTCSensorValues(timestamp='2026-01-26T23:30:19Z', mount=37.0, mount_monitor=37.03, lid=111.71000000000001, lid_monitor=112.25, ambient=23.52, pcb=26.36, heatsink=23.54, heatsink_tec=23.16)" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await tc.read_temperatures()" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Waiting for mount to hit target temperature...\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-01-26 22:16:38,830 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - MethodSet XML saved to: debug_cycling_protocol.xml\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Running temperature cycling protocol...\n", + "✓ Protocol started! Request ID: 710292357\n", + "✓ Method started! Request ID: 710292357\n", + "Current temperatures:\n", + "ODTCSensorValues(timestamp='2026-01-26T23:35:39Z', mount=36.85, mount_monitor=36.96, lid=110.16, lid_monitor=111.19, ambient=23.580000000000002, pcb=25.69, heatsink=23.42, heatsink_tec=23.09)\n", + "Check debug_cycling_protocol.xml for the generated XML\n" + ] + } + ], + "source": [ + "from pylabrobot.thermocycling.standard import Protocol, Stage, Step\n", "\n", - "# Get DataEvents for this command (if any were collected)\n", - "events = await door_opening.get_data_events()\n", - "print(f\"DataEvents collected: {len(events)}\")\n", + "cycle_protocol = Protocol(stages=[\n", + " Stage(\n", + " steps=[\n", + " Step(temperature=[37.0], hold_seconds=10.0), # 10 second hold at 37°C\n", + " Step(temperature=[60.0], hold_seconds=10.0), # Ramp to 60°C\n", + " Step(temperature=[10.0], hold_seconds=10.0), # Ramp down to 10°C\n", + " ],\n", + " repeats=1\n", + " )\n", + "])\n", + "config = tc.get_default_config(post_heating=True)\n", "\n", - "# Wait for completion\n", - "await door_opening\n", - "print(\"✓ Door open completed!\")" + "print(\"Waiting for mount to hit target temperature...\")\n", + "await execution\n", + "\n", + "print(\"Running temperature cycling protocol...\")\n", + "execution = await tc.run_protocol(\n", + " protocol=cycle_protocol,\n", + " config=config,\n", + " method_name=None,\n", + " wait=False, # Non-blocking, returns execution handle\n", + " debug_xml=True, # Enable XML logging\n", + " xml_output_path=\"debug_cycling_protocol.xml\" # Save XML to file\n", + ")\n", + "\n", + "print(f\"✓ Protocol started! Request ID: {execution.request_id}\")\n", + "if await tc.get_status() == \"busy\":\n", + " print(f\"✓ Method started! Request ID: {execution.request_id}\")\n", + " print(f\"Current temperatures:\\n{await tc.read_temperatures()}\")\n", + " print(\"Check debug_cycling_protocol.xml for the generated XML\")\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "# 4. Close the connection" + "# 6. Close the connection" ] }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 15, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ + "Protocol Completed, door opening with request ID: 159742277\n", + "✓ Door open completed!\n", "✓ Connection closed.\n" ] } ], "source": [ + "# Wait for completion\n", + "await execution\n", + "\n", + "door_opening = await tc.open_door(wait=False)\n", + "print(f\"Protocol Completed, door opening with request ID: {door_opening.request_id}\")\n", + "await door_opening\n", + "print(\"✓ Door open completed!\")\n", + "\n", "# Close the connection\n", "await tc.stop()\n", "print(\"✓ Connection closed.\")" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/pylabrobot/thermocycling/inheco/odtc.py b/pylabrobot/thermocycling/inheco/odtc.py index b8e95610ecc..71614e173a2 100644 --- a/pylabrobot/thermocycling/inheco/odtc.py +++ b/pylabrobot/thermocycling/inheco/odtc.py @@ -1,12 +1,15 @@ """Inheco ODTC (On-Deck Thermocycler) resource class.""" -from typing import Any, Dict, List, Literal, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union from pylabrobot.resources import Coordinate, ItemizedResource from pylabrobot.thermocycling.thermocycler import Thermocycler from .odtc_backend import CommandExecution, MethodExecution, ODTCBackend -from .odtc_xml import ODTCMethodSet, ODTCConfig +from .odtc_xml import ODTCMethod, ODTCPreMethod, ODTCConfig + +if TYPE_CHECKING: + from pylabrobot.thermocycling.standard import Protocol # Mapping from model string to variant integer (960000 for 96-well, 384000 for 384-well) @@ -40,7 +43,7 @@ class InhecoODTC(Thermocycler): Example usage: ```python from pylabrobot.thermocycling.inheco import InhecoODTC, ODTCBackend - from pylabrobot.thermocycling.inheco.odtc_xml import protocol_to_odtc_method + from pylabrobot.thermocycling.standard import Protocol, Stage, Step # Create backend and thermocycler (384-well) backend = ODTCBackend(odtc_ip="192.168.1.100") @@ -49,20 +52,19 @@ class InhecoODTC(Thermocycler): # Initialize await tc.setup() - # Create a protocol with model-aware defaults - from pylabrobot.thermocycling.standard import Protocol, Stage, Step + # Create a protocol protocol = Protocol(stages=[ Stage(steps=[Step(temperature=[95.0], hold_seconds=30.0)], repeats=1) ]) - # Convert to ODTC method using model's variant (384000) - config = tc.get_default_config(name="my_protocol") - method = protocol_to_odtc_method(protocol, config=config) - # method.variant will be 384000 (not 960000) + # Upload protocol (uses model's variant 384000 automatically) + await tc.upload_protocol(protocol, name="my_method") + + # Run method by name + await tc.run_protocol(method_name="my_method") - # Upload and execute - await tc.upload_method_set(ODTCMethodSet(methods=[method], premethods=[])) - await tc.execute_method("my_protocol") + # Or upload and run in one call + await tc.run_protocol(protocol=protocol, method_name="my_pcr") # Clean up await tc.stop() @@ -115,26 +117,145 @@ def serialize(self) -> dict: "port": self.backend._sila.bound_port, } - # Convenience methods that expose ODTC-specific functionality + # Protocol management methods - async def execute_method( + async def upload_protocol( self, - method_name: str, - priority: Optional[int] = None, + protocol: "Protocol", + config: Optional["ODTCConfig"] = None, + name: Optional[str] = None, + allow_overwrite: bool = False, + debug_xml: bool = False, + xml_output_path: Optional[str] = None, + ) -> str: + """Upload a Protocol to the device. + + Args: + protocol: PyLabRobot Protocol to upload. + config: Optional ODTCConfig for device-specific parameters. If None, uses + model-aware defaults (variant matches thermocycler model). + name: Method name. If None, uses "plr_currentProtocol". + allow_overwrite: If False, raise ValueError if method name already exists. + 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. + + Returns: + Method name (resolved name, may be scratch name if not provided). + + Raises: + ValueError: If allow_overwrite=False and method name already exists. + """ + from .odtc_xml import ODTCConfig, protocol_to_odtc_method + + # Use model-aware defaults if config not provided + if config is None: + config = self.get_default_config() + + # Set name in config if provided + if name is not None: + config.name = name + + # Convert Protocol to ODTCMethod in resource layer + method = protocol_to_odtc_method(protocol, config=config) + + # Upload method to backend + await self.backend.upload_method( + method, + allow_overwrite=allow_overwrite, + execute=False, + debug_xml=debug_xml, + xml_output_path=xml_output_path, + ) + + return method.name + + async def run_protocol( + self, + protocol: Optional["Protocol"] = None, + config: Optional["ODTCConfig"] = None, + method_name: Optional[str] = None, wait: bool = True, + debug_xml: bool = False, + xml_output_path: Optional[str] = None, ) -> Optional[MethodExecution]: - """Execute a method or premethod by name. + """Run a protocol or method on the device. + + If protocol is provided: + - Converts Protocol to ODTCMethod + - Uploads it with name method_name (or uses scratch name "plr_currentProtocol" if method_name=None) + - Executes the method by the resolved name + + If only method_name is provided: + - Executes existing Method on device by that name Args: - method_name: Name of the method or premethod to execute. - priority: Priority (not used by ODTC, but part of SiLA spec). + protocol: Optional Protocol to convert and execute. If None, method_name must be provided. + config: Optional ODTCConfig for device-specific parameters. If None and protocol provided, + uses model-aware defaults. + method_name: Name of Method to execute. If protocol provided, this is the name for the + uploaded method. If only method_name provided, this is the existing method to run. + If None and protocol provided, uses "plr_currentProtocol". wait: If True, block until completion. If False, return MethodExecution handle. + 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. Returns: If wait=True: None (blocks until complete) If wait=False: MethodExecution handle (awaitable, has request_id) + + Raises: + ValueError: If neither protocol nor method_name is provided. + """ + if protocol is not None: + # Convert, upload, and execute protocol + if config is None: + config = self.get_default_config() + # Upload with allow_overwrite=True since we're about to execute it + method_name = await self.upload_protocol( + protocol, + config=config, + name=method_name, + allow_overwrite=True, + debug_xml=debug_xml, + xml_output_path=xml_output_path, + ) + return await self.backend.execute_method(method_name, wait=wait) + elif method_name is not None: + # Execute existing method by name + return await self.backend.execute_method(method_name, wait=wait) + else: + raise ValueError("Either protocol or method_name must be provided") + + async def get_method_set(self): + """Get the full MethodSet from the device. + + Returns: + ODTCMethodSet containing all methods and premethods. """ - return await self.backend.execute_method(method_name, priority, wait) + return await self.backend.get_method_set() + + async def get_method(self, name: str) -> Optional[Union[ODTCMethod, ODTCPreMethod]]: + """Get a method by name from the device (searches both methods and premethods). + + Args: + name: Method name to retrieve. + + Returns: + ODTCMethod or ODTCPreMethod if found, None otherwise. + """ + return await self.backend.get_method_by_name(name) + + async def list_methods(self) -> List[str]: + """List all method names (both methods and premethods) on the device. + + Returns: + List of method names. + """ + return await self.backend.list_method_names() async def stop_method(self, wait: bool = True) -> Optional[CommandExecution]: """Stop any currently running method. @@ -148,14 +269,6 @@ async def stop_method(self, wait: bool = True) -> Optional[CommandExecution]: """ return await self.backend.stop_method(wait=wait) - async def get_method_set(self): - """Get the full MethodSet from the device.""" - return await self.backend.get_method_set() - - async def get_method_by_name(self, method_name: str): - """Get a specific method by name from the device.""" - return await self.backend.get_method_by_name(method_name) - async def is_method_running(self) -> bool: """Check if a method is currently running.""" return await self.backend.is_method_running() @@ -168,13 +281,74 @@ async def wait_for_method_completion( """Wait until method execution completes.""" await self.backend.wait_for_method_completion(poll_interval, timeout) - async def upload_method_set_from_file(self, filepath: str) -> None: - """Load a MethodSet XML file and upload to device.""" - await self.backend.upload_method_set_from_file(filepath) + async def set_mount_temperature( + self, + temperature: float, + lid_temperature: Optional[float] = None, + wait: bool = True, + debug_xml: bool = False, + xml_output_path: Optional[str] = None, + ) -> Optional[MethodExecution]: + """Set mount (block) temperature and hold it. + + Creates and executes a PreMethod to set the mount and lid temperatures. + PreMethods are simpler than full Methods and are designed for temperature conditioning. + + Args: + temperature: Target mount (block) temperature in °C. + lid_temperature: Optional lid temperature in °C. If None, uses hardware-defined + default (max_lid_temp: 110°C for 96-well, 115°C for 384-well). + wait: If True, block until temperatures are set. If False, return MethodExecution handle. + debug_xml: If True, log the generated XML to the logger at DEBUG level. + xml_output_path: Optional file path to save the generated MethodSet XML. + + Returns: + If wait=True: None (blocks until complete) + If wait=False: MethodExecution handle (awaitable, has request_id) + + Example: + ```python + # Set mount to 95°C with default lid temperature (110°C) - blocking + await tc.set_mount_temperature(95.0) + + # Set mount to 95°C with custom lid temperature - blocking + await tc.set_mount_temperature(95.0, lid_temperature=115.0) - async def save_method_set_to_file(self, filepath: str) -> None: - """Download methods from device and save to file.""" - await self.backend.save_method_set_to_file(filepath) + # Non-blocking - returns MethodExecution handle + execution = await tc.set_mount_temperature(95.0, wait=False) + # Do other work... + await execution # Wait when ready + ``` + """ + from .odtc_xml import ODTCPreMethod, generate_odtc_timestamp, resolve_protocol_name + + # Use default lid temperature if not specified + if lid_temperature is not None: + target_lid_temp = lid_temperature + else: + # Use hardware-defined max as default (110°C for 96-well, 115°C for 384-well) + constraints = self.get_constraints() + target_lid_temp = constraints.max_lid_temp + + # Create PreMethod - much simpler than a full Method + # PreMethods just set target temperatures and hold them + premethod = ODTCPreMethod( + name=resolve_protocol_name(None), # Uses "plr_currentProtocol" + target_block_temperature=temperature, + target_lid_temperature=target_lid_temp, + datetime=generate_odtc_timestamp(), + ) + + # Upload PreMethod to backend + await self.backend.upload_premethod( + premethod, + allow_overwrite=True, # Always overwrite scratch name + debug_xml=debug_xml, + xml_output_path=xml_output_path, + ) + + # Execute the PreMethod (same command as Methods) + return await self.backend.execute_method(premethod.name, wait=wait) async def get_status(self) -> str: """Get device status state. @@ -192,6 +366,26 @@ async def read_temperatures(self): """ return await self.backend.read_temperatures() + async def monitor_temperatures( + self, + callback=None, + poll_interval: float = 2.0, + timeout=None, + stop_on_method_completion: bool = True, + show_updates: bool = True, + ): + """Monitor temperatures during method execution with controlled polling rate. + + See ODTCBackend.monitor_temperatures() for full documentation. + """ + return await self.backend.monitor_temperatures( + callback=callback, + poll_interval=poll_interval, + timeout=timeout, + stop_on_method_completion=stop_on_method_completion, + show_updates=show_updates, + ) + # Device control methods async def initialize(self, wait: bool = True) -> Optional[CommandExecution]: @@ -311,15 +505,6 @@ async def get_last_data(self) -> str: """ return await self.backend.get_last_data() - # Method upload methods - - async def upload_method_set(self, method_set: ODTCMethodSet) -> None: - """Upload a MethodSet to the device. - - Args: - method_set: ODTCMethodSet to upload. - """ - await self.backend.upload_method_set(method_set) # Protocol conversion helpers with model-aware defaults diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index d2f290267bc..3e603df7643 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -5,21 +5,27 @@ import asyncio import logging from dataclasses import dataclass -from typing import Any, Dict, List, Optional, cast +from typing import Any, Callable, Dict, List, Optional, Union, cast from pylabrobot.thermocycling.backend import ThermocyclerBackend from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol from .odtc_sila_interface import ODTCSiLAInterface, SiLAState from .odtc_xml import ( + ODTCConfig, ODTCMethod, ODTCMethodSet, + ODTCPreMethod, ODTCSensorValues, + generate_odtc_timestamp, get_method_by_name, + get_premethod_by_name, + list_method_names, method_set_to_xml, parse_method_set, parse_method_set_file, parse_sensor_values, + resolve_protocol_name, ) @@ -212,13 +218,17 @@ def _extract_dict_path( self.logger.debug(f"{command_name} extracted value at path {path}: {value!r}") return value - def _extract_xml_parameter(self, resp: Any, param_name: str, command_name: str) -> str: + def _extract_xml_parameter( + self, resp: Any, param_name: str, command_name: str, allow_root_fallback: bool = False + ) -> str: """Extract parameter value from ElementTree XML response. Args: resp: ElementTree root from send_command. - param_name: Name of parameter to extract. + param_name: Name of parameter to extract (matches 'name' attribute on Parameter element). command_name: Command name for error messages. + allow_root_fallback: If True, fall back to root-based behavior when parameter + with matching name is not found. If False, raise error if parameter not found. Returns: Parameter text value. @@ -229,13 +239,36 @@ def _extract_xml_parameter(self, resp: Any, param_name: str, command_name: str) if resp is None: raise ValueError(f"Empty response from {command_name}") - param = resp.find(f".//Parameter[@name='{param_name}']") + import xml.etree.ElementTree as ET + + # First, try strict matching by name attribute + # Look for Parameter[@name='param_name'] in ResponseData or anywhere in tree + param = None + if resp.tag == "Parameter" and resp.get("name") == param_name: + param = resp + else: + # Search for Parameter with matching name attribute + param = resp.find(f".//Parameter[@name='{param_name}']") + + # Fallback: if not found and fallback allowed, use root-based behavior + # (for cases where temperature data is in root without name attribute) + if param is None and allow_root_fallback: + # Either root is Parameter, or find first Parameter in ResponseData + param = resp if resp.tag == "Parameter" else resp.find(".//Parameter") + if param is None: - raise ValueError(f"{param_name} parameter not found in {command_name} response") + # Include full XML structure in error for debugging + xml_str = ET.tostring(resp, encoding='unicode') + raise ValueError( + f"Parameter '{param_name}' not found in {command_name} response. " + f"Root element tag: {resp.tag}\n" + f"Full XML response:\n{xml_str}" + ) + # Extract String element from Parameter (contains escaped XML) string_elem = param.find("String") if string_elem is None or string_elem.text is None: - raise ValueError(f"{param_name} String element not found in {command_name} response") + raise ValueError(f"String element not found in {command_name} Parameter response") return str(string_elem.text) @@ -467,9 +500,54 @@ async def read_temperatures(self) -> ODTCSensorValues: ODTCSensorValues with temperatures in °C. """ resp = await self._sila.send_command("ReadActualTemperature") - # Response is ElementTree root - extract SensorValues parameter - # Response structure: ResponseData/Parameter[@name='SensorValues']/String - sensor_xml = self._extract_xml_parameter(resp, "SensorValues", "ReadActualTemperature") + + # Debug logging to see what we actually received + self.logger.debug( + f"ReadActualTemperature response type: {type(resp).__name__}, " + f"isinstance dict: {isinstance(resp, dict)}, " + f"isinstance ElementTree: {hasattr(resp, 'find') if resp else False}" + ) + + # Handle both synchronous (dict) and asynchronous (ElementTree) responses + if isinstance(resp, dict): + # Synchronous response (return_code == 1) - extract from dict structure + # Structure: ReadActualTemperatureResponse -> ResponseData -> Parameter -> String + # Parameter might be a dict or list, so we need to find the one with name="SensorValues" + self.logger.debug(f"ReadActualTemperature dict response keys: {list(resp.keys())}") + response_data = self._extract_dict_path( + resp, ["ReadActualTemperatureResponse", "ResponseData"], "ReadActualTemperature" + ) + self.logger.debug(f"ResponseData structure: {response_data}") + + # Parameter might be a dict or list + param = response_data.get("Parameter") + if isinstance(param, list): + # Find parameter with name="SensorValues" + sensor_param = next((p for p in param if p.get("name") == "SensorValues"), None) + elif isinstance(param, dict): + # Single parameter dict + sensor_param = param if param.get("name") == "SensorValues" else None + else: + sensor_param = None + + if sensor_param is None: + raise ValueError( + "SensorValues parameter not found in ReadActualTemperature response" + ) + + sensor_xml = sensor_param.get("String") + if sensor_xml is None: + raise ValueError( + "String element not found in SensorValues parameter" + ) + else: + # Asynchronous response (return_code == 2) - resp is ElementTree root + # Response structure: ResponseData/Parameter[@name='SensorValues']/String + # Use fallback for temperature data which may be in root without name attribute + sensor_xml = self._extract_xml_parameter( + resp, "SensorValues", "ReadActualTemperature", allow_root_fallback=True + ) + # Parse the XML string (it's escaped in the response) return parse_sensor_values(sensor_xml) @@ -625,26 +703,100 @@ async def get_method_set(self) -> ODTCMethodSet: # Parse MethodSet XML (it's escaped in the response) return parse_method_set(method_set_xml) - async def get_method_by_name(self, method_name: str) -> Optional[ODTCMethod]: - """Get a specific method by name from the device. + async def get_method_by_name(self, method_name: str) -> Optional[Union[ODTCMethod, ODTCPreMethod]]: + """Get a method by name from the device (searches both methods and premethods). Args: method_name: Name of the method to retrieve. Returns: - ODTCMethod if found, None otherwise. + ODTCMethod or ODTCPreMethod if found, None otherwise. """ method_set = await self.get_method_set() return get_method_by_name(method_set, method_name) - async def upload_method_set(self, method_set: ODTCMethodSet) -> None: + async def list_method_names(self) -> List[str]: + """List all method names (both methods and premethods) on the device. + + Returns: + List of method names. + """ + method_set = await self.get_method_set() + return list_method_names(method_set) + + async def upload_method_set( + self, + method_set: ODTCMethodSet, + allow_overwrite: bool = False, + debug_xml: bool = False, + xml_output_path: Optional[str] = None, + ) -> None: """Upload a MethodSet to the device. Args: method_set: ODTCMethodSet to upload. + allow_overwrite: If False, raise ValueError if any method/premethod name + already exists on the device. If True, allow overwriting existing methods/premethods. + debug_xml: If True, log the generated XML to the logger at DEBUG level. + Useful for troubleshooting validation errors. + xml_output_path: Optional file path to save the generated MethodSet XML. + If provided, the XML will be written to this file before upload. + Useful for comparing with example XML files or debugging. + + Raises: + ValueError: If allow_overwrite=False and any method/premethod name already exists + on the device (checking both methods and premethods for conflicts). """ + # Check for name conflicts if overwrite not allowed + if not allow_overwrite: + existing_method_set = await self.get_method_set() + conflicts = [] + + # Check all method names (unified search) + for method in method_set.methods: + existing_method = get_method_by_name(existing_method_set, method.name) + if existing_method is not None: + method_type = "PreMethod" if isinstance(existing_method, ODTCPreMethod) else "Method" + conflicts.append(f"Method '{method.name}' already exists as {method_type}") + + # Check all premethod names (unified search) + for premethod in method_set.premethods: + existing_method = get_method_by_name(existing_method_set, premethod.name) + if existing_method is not None: + method_type = "PreMethod" if isinstance(existing_method, ODTCPreMethod) else "Method" + conflicts.append(f"Method '{premethod.name}' already exists as {method_type}") + + 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 @@ -655,16 +807,174 @@ async def upload_method_set(self, method_set: ODTCMethodSet) -> None: 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) -> None: + async def upload_method( + self, + method: ODTCMethod, + allow_overwrite: bool = False, + execute: bool = False, + wait: bool = True, + debug_xml: bool = False, + xml_output_path: Optional[str] = None, + ) -> Optional[MethodExecution]: + """Upload a single method to the device. + + Convenience wrapper that wraps method in MethodSet and uploads. + + Args: + method: ODTCMethod to upload. + allow_overwrite: If False, raise ValueError if method name already exists + on the device. If True, allow overwriting existing method/premethod. + If method name resolves to scratch name and this is not explicitly False, + it will be set to True automatically. + execute: If True, execute the method after uploading. If False, only upload. + wait: If execute=True and wait=True, block until method completes. + If execute=True and wait=False, return MethodExecution handle. + debug_xml: If True, log the generated XML to the logger at DEBUG level. + Passed through to upload_method_set. + xml_output_path: Optional file path to save the generated MethodSet XML. + Passed through to upload_method_set. + + Returns: + If execute=False: None + If execute=True and wait=True: None (blocks until complete) + If execute=True and wait=False: MethodExecution handle (awaitable, has request_id) + + Raises: + ValueError: If allow_overwrite=False and method name already exists + on the device (checking both methods and premethods for conflicts). + """ + # Resolve name (use scratch name if None/empty) + resolved_name = resolve_protocol_name(method.name) + # Check if we're using a scratch name (original name was None/empty) + is_scratch_name = not method.name or method.name == "" + + # Generate timestamp if not already set + resolved_datetime = method.datetime if method.datetime else generate_odtc_timestamp() + + # Auto-overwrite for scratch names unless explicitly disabled + if is_scratch_name and allow_overwrite is False: + # Check if user explicitly passed False (vs default) + # Since we can't distinguish, we'll auto-overwrite for scratch names + # but log a warning if they explicitly set False + allow_overwrite = True + if not method.name: # Only warn if name was actually None/empty (not just resolved) + self.logger.warning( + f"Method name resolved to scratch name '{resolved_name}'. " + "Auto-enabling allow_overwrite=True for scratch methods." + ) + + # Create method copy with resolved name and timestamp + method_copy = ODTCMethod( + name=resolved_name, + variant=method.variant, + plate_type=method.plate_type, + fluid_quantity=method.fluid_quantity, + post_heating=method.post_heating, + start_block_temperature=method.start_block_temperature, + start_lid_temperature=method.start_lid_temperature, + steps=method.steps, + pid_set=method.pid_set, + creator=method.creator, + description=method.description, + datetime=resolved_datetime, + ) + + method_set = ODTCMethodSet(methods=[method_copy], premethods=[]) + await self.upload_method_set( + method_set, + allow_overwrite=allow_overwrite, + debug_xml=debug_xml, + xml_output_path=xml_output_path, + ) + + if execute: + return await self.execute_method(resolved_name, wait=wait) + return None + + async def upload_premethod( + self, + premethod: ODTCPreMethod, + allow_overwrite: bool = False, + debug_xml: bool = False, + xml_output_path: Optional[str] = None, + ) -> None: + """Upload a single premethod to the device. + + Convenience wrapper that wraps premethod in MethodSet and uploads. + + Args: + premethod: ODTCPreMethod to upload. + allow_overwrite: If False, raise ValueError if premethod name already exists + on the device. If True, allow overwriting existing method/premethod. + If premethod name resolves to scratch name and this is not explicitly False, + it will be set to True automatically. + + Raises: + ValueError: If allow_overwrite=False and premethod name already exists + on the device (checking both methods and premethods for conflicts). + """ + # Resolve name (use scratch name if None/empty) + resolved_name = resolve_protocol_name(premethod.name) + # Check if we're using a scratch name (original name was None/empty) + is_scratch_name = not premethod.name or premethod.name == "" + + # Generate timestamp if not already set + resolved_datetime = premethod.datetime if premethod.datetime else generate_odtc_timestamp() + + # Auto-overwrite for scratch names unless explicitly disabled + if is_scratch_name and allow_overwrite is False: + # Check if user explicitly passed False (vs default) + # Since we can't distinguish, we'll auto-overwrite for scratch names + # but log a warning if they explicitly set False + allow_overwrite = True + if not premethod.name: # Only warn if name was actually None/empty (not just resolved) + self.logger.warning( + f"PreMethod name resolved to scratch name '{resolved_name}'. " + "Auto-enabling allow_overwrite=True for scratch premethods." + ) + + # Create premethod copy with resolved name and timestamp + premethod_copy = ODTCPreMethod( + name=resolved_name, + target_block_temperature=premethod.target_block_temperature, + target_lid_temperature=premethod.target_lid_temperature, + creator=premethod.creator, + description=premethod.description, + datetime=resolved_datetime, + ) + + method_set = ODTCMethodSet(methods=[], premethods=[premethod_copy]) + await self.upload_method_set( + method_set, + allow_overwrite=allow_overwrite, + debug_xml=debug_xml, + xml_output_path=xml_output_path, + ) + + async def upload_method_set_from_file( + self, + filepath: str, + allow_overwrite: bool = False, + ) -> None: """Load and upload a MethodSet XML file to the device. Args: filepath: Path to MethodSet XML file. + allow_overwrite: If False, raise ValueError if any method/premethod name + already exists on the device. If True, allow overwriting existing methods/premethods. + + Raises: + ValueError: If allow_overwrite=False and any method/premethod name already exists + on the device (checking both methods and premethods for conflicts). """ method_set = parse_method_set_file(filepath) - await self.upload_method_set(method_set) + await self.upload_method_set(method_set, allow_overwrite=allow_overwrite) async def save_method_set_to_file(self, filepath: str) -> None: """Download methods from device and save to file. diff --git a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py index 8cdd2633e72..998209453ce 100644 --- a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py +++ b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py @@ -83,7 +83,7 @@ class SiLAState(str, Enum): """SiLA device states per specification. - + Note: State values match what the ODTC device actually returns. Based on SCILABackend comment, devices return: "standby", "inError", "startup" (mixed/lowercase). However, the actual ODTC device returns "standby" (all lowercase) as seen in practice. @@ -302,25 +302,30 @@ def _check_parallelism(self, command: str) -> bool: if not self._executing_commands: return True + # Normalize the new command name + new_cmd = self._normalize_command_name(command) + # Check against each executing command + # The parallelism table is keyed by the EXECUTING command, then lists what can run in parallel for executing_cmd in self._executing_commands: - # Normalize command names (handle aliases) - cmd1 = self._normalize_command_name(command) - cmd2 = self._normalize_command_name(executing_cmd) - - # Check parallelism table - if cmd1 in self.PARALLELISM_TABLE: - if cmd2 in self.PARALLELISM_TABLE[cmd1]: - if self.PARALLELISM_TABLE[cmd1][cmd2] == "S": + # Normalize executing command name + exec_cmd = self._normalize_command_name(executing_cmd) + + # Check parallelism table from the perspective of the EXECUTING command + if exec_cmd in self.PARALLELISM_TABLE: + if new_cmd in self.PARALLELISM_TABLE[exec_cmd]: + if self.PARALLELISM_TABLE[exec_cmd][new_cmd] == "S": return False # Sequential required + # If "P" (parallel), continue checking other executing commands else: - # Command not in table - default to sequential for safety + # New command not in executing command's table - default to sequential for safety return False else: - # Command not in parallelism table - default to sequential + # Executing command not in parallelism table - default to sequential return False - return True # Can run in parallel + # All checks passed - can run in parallel with all executing commands + return True def _normalize_command_name(self, command: str) -> str: """Normalize command name for parallelism table lookup. @@ -369,9 +374,9 @@ def _update_state_from_status(self, state_str: str) -> None: if not state_str: self._logger.debug("_update_state_from_status: Empty state string, skipping update") return - + self._logger.debug(f"_update_state_from_status: Received state: {state_str!r} (type: {type(state_str).__name__})") - + # Match exactly against enum values (no normalization - we want to see what device actually returns) try: self._current_state = SiLAState(state_str) @@ -625,22 +630,25 @@ async def send_command( f"Command {command} not allowed in state {self._current_state.value} (return code 9)" ) - # Check parallelism (for commands in the table) - normalized_cmd = self._normalize_command_name(command) - if normalized_cmd in self.PARALLELISM_TABLE: - async with self._parallelism_lock: - if not self._check_parallelism(normalized_cmd): - raise RuntimeError( - f"Command {command} cannot run in parallel with currently executing commands (return code 4)" - ) - else: - # Command not in parallelism table - default to sequential (safe) - async with self._parallelism_lock: - if self._executing_commands: - # If any command is executing and this command isn't in table, reject - raise RuntimeError( - f"Command {command} not in parallelism table and device is busy (return code 4)" - ) + # Synchronous read-only commands (GetStatus, GetDeviceIdentification) should always be allowed + # They are non-interfering queries that can run at any time, even during method execution + if command not in self.SYNCHRONOUS_COMMANDS: + # Check parallelism (for commands in the table) + normalized_cmd = self._normalize_command_name(command) + if normalized_cmd in self.PARALLELISM_TABLE: + async with self._parallelism_lock: + if not self._check_parallelism(normalized_cmd): + raise RuntimeError( + f"Command {command} cannot run in parallel with currently executing commands (return code 4)" + ) + else: + # Command not in parallelism table - default to sequential (safe) + async with self._parallelism_lock: + if self._executing_commands: + # If any command is executing and this command isn't in table, reject + raise RuntimeError( + f"Command {command} not in parallelism table and device is busy (return code 4)" + ) # Generate request_id (reuse base class method) request_id = self._make_request_id() @@ -694,6 +702,11 @@ def _do_request() -> bytes: # Extract return code and message return_code, message = self._get_return_code_and_message(command, decoded) + # Debug logging for return code + self._logger.debug( + f"Command {command} returned code {return_code}: {message}" + ) + # Handle return codes if return_code == 1: # Synchronous success (GetStatus, GetDeviceIdentification) diff --git a/pylabrobot/thermocycling/inheco/odtc_xml.py b/pylabrobot/thermocycling/inheco/odtc_xml.py index 335b3b64a24..cc230197835 100644 --- a/pylabrobot/thermocycling/inheco/odtc_xml.py +++ b/pylabrobot/thermocycling/inheco/odtc_xml.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +from datetime import datetime import xml.etree.ElementTree as ET from dataclasses import dataclass, field, fields from enum import Enum @@ -21,6 +22,46 @@ T = TypeVar("T") +# ============================================================================= +# Scratch Method Names +# ============================================================================= + +SCRATCH_PROTOCOL_NAME = "plr_currentProtocol" + + +# ============================================================================= +# Timestamp Generation +# ============================================================================= + + +def generate_odtc_timestamp() -> str: + """Generate ISO 8601 timestamp in ODTC format. + + Returns: + Timestamp string in ISO 8601 format: YYYY-MM-DDTHH:MM:SS.ffffff (6 decimal places). + Example: "2026-01-26T14:30:45.123456" + + Note: + Uses Python's standard ISO 8601 formatting with microseconds (6 decimal places). + ODTC accepts timestamps with 0-7 decimal places, so this standard format is compatible. + """ + return datetime.now().isoformat(timespec="microseconds") + + +def resolve_protocol_name(name: Optional[str]) -> str: + """Resolve protocol name, using scratch name if not provided. + + Args: + name: Protocol name (may be None or empty string). + + Returns: + Resolved name. If name is None or empty, returns scratch name. + """ + if not name: # None or empty string + return SCRATCH_PROTOCOL_NAME + return name + + # ============================================================================= # Hardware Constraints # ============================================================================= @@ -278,7 +319,7 @@ class ODTCConfig: """ # Method identification/metadata - name: str = "converted_protocol" + name: Optional[str] = None creator: Optional[str] = None description: Optional[str] = None datetime: Optional[str] = None @@ -585,8 +626,9 @@ def _method_to_xml(method: ODTCMethod, parent: ET.Element) -> ET.Element: ET.SubElement(elem, "PlateType").text = str(method.plate_type) ET.SubElement(elem, "FluidQuantity").text = str(method.fluid_quantity) ET.SubElement(elem, "PostHeating").text = "true" if method.post_heating else "false" - ET.SubElement(elem, "StartBlockTemperature").text = str(method.start_block_temperature) - ET.SubElement(elem, "StartLidTemperature").text = str(method.start_lid_temperature) + # Use _format_value to ensure integers are formatted without .0 + ET.SubElement(elem, "StartBlockTemperature").text = _format_value(method.start_block_temperature) + ET.SubElement(elem, "StartLidTemperature").text = _format_value(method.start_lid_temperature) # Add steps for step in method.steps: @@ -682,9 +724,6 @@ def parse_sensor_values(xml_str: str) -> ODTCSensorValues: # ============================================================================= -def get_method_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCMethod]: - """Find a method by name.""" - return next((m for m in method_set.methods if m.name == name), None) def get_premethod_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCPreMethod]: @@ -692,8 +731,34 @@ def get_premethod_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTC return next((pm for pm in method_set.premethods if pm.name == name), None) -def list_method_names(method_set: ODTCMethodSet) -> List[str]: - """Get all method names.""" +# Keep the method-only version as internal helper +def _get_method_only_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCMethod]: + """Find a method by name (methods only, not premethods).""" + return next((m for m in method_set.methods if m.name == name), None) + + +def get_method_by_name(method_set: ODTCMethodSet, name: str) -> Optional[Union[ODTCMethod, ODTCPreMethod]]: + """Find a method by name, searching both methods and premethods. + + Args: + method_set: ODTCMethodSet to search. + name: Method name to find. + + Returns: + ODTCMethod or ODTCPreMethod if found, None otherwise. + """ + # Search methods first, then premethods + method = _get_method_only_by_name(method_set, name) + if method is not None: + return method + premethod = get_premethod_by_name(method_set, name) + if premethod is not None: + return premethod + return None + + +def _list_method_names_only(method_set: ODTCMethodSet) -> List[str]: + """Get all method names (methods only, not premethods).""" return [m.name for m in method_set.methods] @@ -702,6 +767,13 @@ def list_premethod_names(method_set: ODTCMethodSet) -> List[str]: return [pm.name for pm in method_set.premethods] +def list_method_names(method_set: ODTCMethodSet) -> List[str]: + """Get all method names (both methods and premethods).""" + method_names = [m.name for m in method_set.methods] + premethod_names = [pm.name for pm in method_set.premethods] + return method_names + premethod_names + + # ============================================================================= # Protocol Conversion Functions # ============================================================================= @@ -851,8 +923,14 @@ def protocol_to_odtc_method( else config.lid_temperature ) + # Resolve method name (use scratch name if not provided) + resolved_name = resolve_protocol_name(config.name) + + # Generate timestamp if not already set + resolved_datetime = config.datetime if config.datetime else generate_odtc_timestamp() + return ODTCMethod( - name=config.name, + name=resolved_name, variant=config.variant, plate_type=config.plate_type, fluid_quantity=config.fluid_quantity, @@ -863,7 +941,7 @@ def protocol_to_odtc_method( pid_set=list(config.pid_set), # Copy the list creator=config.creator, description=config.description, - datetime=config.datetime, + datetime=resolved_datetime, ) From 94b431586906c7abb459e2b79c60885f1f67344e Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:52:15 -0800 Subject: [PATCH 09/28] Formatting and fluid quantity checking --- pylabrobot/thermocycling/inheco/dev/README.md | 360 ++++++++++++------ .../inheco/dev/test_door_commands.ipynb | 49 +-- pylabrobot/thermocycling/inheco/odtc.py | 138 +++++-- .../thermocycling/inheco/odtc_backend.py | 17 +- 4 files changed, 394 insertions(+), 170 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/dev/README.md b/pylabrobot/thermocycling/inheco/dev/README.md index b79e88c018d..e260ed72993 100644 --- a/pylabrobot/thermocycling/inheco/dev/README.md +++ b/pylabrobot/thermocycling/inheco/dev/README.md @@ -36,6 +36,83 @@ The ODTC implementation provides a complete interface for controlling Inheco ODT - Conversion between `ODTCMethod` and generic `Protocol` - `ODTCConfig` class for preserving ODTC-specific parameters +## Protocol vs Method: Naming Conventions + +Understanding the distinction between **Protocol** and **Method** is crucial for using the ODTC API correctly: + +### Protocol (PyLabRobot) +- **`Protocol`**: PyLabRobot's generic protocol object (from `pylabrobot.thermocycling.standard`) + - Contains `Stage` objects with `Step` objects + - Defines temperatures, hold times, and cycle repeats + - Hardware-agnostic (works with any thermocycler) + - Example: `Protocol(stages=[Stage(steps=[Step(temperature=95.0, hold_seconds=30.0)])])` + +### Method (ODTC Device) +- **`ODTCMethod`** or **`ODTCPreMethod`**: ODTC-specific XML-defined method stored on the device + - Contains ODTC-specific parameters (overshoot, slopes, PID settings) + - Stored on the device with a unique **method name** (string identifier) + - Example: `"PCR_30cycles"` is a method name stored on the device + +### Method Name (String) +- **Method name**: A string identifier for a method stored on the device + - Examples: `"PCR_30cycles"`, `"my_pcr"`, `"plr_currentProtocol"` + - Used to reference methods when executing: `await tc.run_protocol(method_name="PCR_30cycles")` + - Can be a Method or PreMethod name (both are stored on the device) + +### Key API Methods + +**High-level methods (recommended):** +- `upload_protocol(protocol, name="method_name")` - Upload a `Protocol` to device as a method +- `run_protocol(protocol=..., method_name=...)` - Upload and run a `Protocol`, or run existing method by name +- `list_methods()` - List all method names (both Methods and PreMethods) on device +- `get_method(name)` - Get `ODTCMethod` or `ODTCPreMethod` by name from device +- `set_mount_temperature(temperature)` - Set mount temperature (creates PreMethod internally) + +**Lower-level methods (for advanced use):** +- `get_method_set()` - Get full `ODTCMethodSet` (all methods and premethods) +- `backend.execute_method(method_name)` - Execute method by name (backend-level) + +### Workflow Examples + +**Creating and running a new protocol:** +```python +# 1. Create Protocol (PyLabRobot object) +protocol = Protocol(stages=[Stage(steps=[Step(temperature=95.0, hold_seconds=30.0)])]) + +# 2. Upload Protocol to device as a method (with method name "my_pcr") +await tc.upload_protocol(protocol, name="my_pcr") + +# 3. Run the method by name +await tc.run_protocol(method_name="my_pcr") + +# OR: Upload and run in one call +await tc.run_protocol(protocol=protocol, method_name="my_pcr") +``` + +**Using existing methods on device:** +```python +# 1. List all method names on device +method_names = await tc.list_methods() # Returns: ["PCR_30cycles", "my_pcr", ...] + +# 2. Get method object by name +method = await tc.get_method("PCR_30cycles") # Returns ODTCMethod or ODTCPreMethod + +# 3. Run existing method by name +await tc.run_protocol(method_name="PCR_30cycles") +``` + +**Converting between Protocol and Method:** +```python +# Get method from device → Convert to Protocol +method = await tc.get_method("PCR_30cycles") +protocol, config = odtc_method_to_protocol(method) + +# Modify Protocol → Convert back to Method → Upload +protocol.stages[0].repeats = 35 +method_modified = protocol_to_odtc_method(protocol, config=config) +await tc.upload_protocol(protocol, name="PCR_35cycles") +``` + ## Connection and Setup ### Basic Connection @@ -133,26 +210,30 @@ await door_opening ### Asynchronous Method Execution -The ODTC supports both blocking and non-blocking method execution: +The ODTC supports both blocking and non-blocking method execution using `run_protocol()`: #### Blocking Execution (Default) ```python -# Block until method completes -await tc.execute_method("PCR_30cycles", wait=True) +# Run existing method - blocks until complete +await tc.run_protocol(method_name="PCR_30cycles") # Returns None when complete + +# Upload and run Protocol - blocks until complete +protocol = Protocol(stages=[...]) +await tc.run_protocol(protocol=protocol, method_name="my_pcr") ``` #### Non-Blocking Execution with Handle ```python # Start method and get execution handle -execution = await tc.execute_method("PCR_30cycles", wait=False) +execution = await tc.run_protocol(method_name="PCR_30cycles", wait=False) # Returns MethodExecution handle immediately # Do parallel operations while method runs temps = await tc.read_temperatures() # Allowed in parallel! -await tc.open_door() # Allowed in parallel! +door_opening = await tc.open_door(wait=False) # Allowed in parallel! # Wait for completion (multiple options) await execution # Await the handle directly @@ -172,14 +253,14 @@ The `MethodExecution` handle extends `CommandExecution` with method-specific fea - **`stop()`**: Stop the currently running method ```python -execution = await tc.execute_method("PCR_30cycles", wait=False) +execution = await tc.run_protocol(method_name="PCR_30cycles", wait=False) # Check status if await execution.is_running(): print(f"Method {execution.method_name} still running (ID: {execution.request_id})") # Get DataEvents for this execution -events = await tc.get_data_events(execution.request_id) +events = await execution.get_data_events() # Wait for completion await execution @@ -200,21 +281,22 @@ await tc.wait_for_method_completion( ### Temperature Control -The ODTC supports direct temperature control via `set_block_temperature()` and `set_lid_temperature()`: +The ODTC supports direct temperature control via `set_mount_temperature()`: ```python -# Set block temperature (uses PreMethod internally) -await tc.set_block_temperature([95.0]) # Sets block to 95°C +# Set mount (block) temperature - blocking +await tc.set_mount_temperature(95.0) # Sets mount to 95°C with default lid temp -# Set lid temperature (uses PreMethod internally) -await tc.set_lid_temperature([105.0]) # Sets lid to 105°C +# Set mount temperature with custom lid temperature +await tc.set_mount_temperature(95.0, lid_temperature=110.0) -# Both methods coordinate temperatures: -# - set_block_temperature() uses existing lid temp or defaults to 105°C -# - set_lid_temperature() uses existing block temp or defaults to 25°C +# Non-blocking temperature control +execution = await tc.set_mount_temperature(37.0, wait=False) +# Do other work... +await execution # Wait when ready ``` -**Note**: These methods use PreMethods internally to achieve constant temperature holds. They automatically stop any running method, wait for idle state, upload a PreMethod, and execute it. +**Note**: `set_mount_temperature()` uses PreMethods internally to achieve constant temperature holds. It automatically stops any running method, waits for idle state, uploads a PreMethod, and executes it. The default lid temperature is hardware-defined (110°C for 96-well, 115°C for 384-well). ### Parallel Operations @@ -229,7 +311,7 @@ Per ODTC SiLA spec, certain commands can run in parallel with `ExecuteMethod`: ```python # Start method -execution = await tc.execute_method("PCR_30cycles", wait=False) +execution = await tc.run_protocol(method_name="PCR_30cycles", wait=False) # These can run in parallel: temps = await tc.read_temperatures() @@ -239,7 +321,7 @@ door_opening = await tc.open_door(wait=False) await door_opening # These will queue/wait: -method2 = await tc.execute_method("PCR_40cycles", wait=False) # Waits for method1 +method2 = await tc.run_protocol(method_name="PCR_40cycles", wait=False) # Waits for method1 ``` ### CommandExecution vs MethodExecution @@ -256,15 +338,36 @@ door_opening = await tc.open_door(wait=False) await door_opening # Wait for door to open # MethodExecution example (has additional features) -method_exec = await tc.execute_method("PCR_30cycles", wait=False) +method_exec = await tc.run_protocol(method_name="PCR_30cycles", wait=False) if await method_exec.is_running(): print(f"Method {method_exec.method_name} is running") await method_exec.stop() # Stop the method ``` -## Getting Protocols from Device +## Getting Methods from Device -### Get Full MethodSet +### List All Method Names (Recommended) + +```python +# List all method names (both Methods and PreMethods) +method_names = await tc.list_methods() +# Returns: ["PCR_30cycles", "my_pcr", "PRE25", "plr_currentProtocol", ...] + +for name in method_names: + print(f"Method: {name}") +``` + +### Get Specific Method by Name + +```python +# Get a specific method (searches both Methods and PreMethods) +method = await tc.get_method("PCR_30cycles") +if method: + print(f"Found method: {method.name}") + # method is either ODTCMethod or ODTCPreMethod +``` + +### Get Full MethodSet (Advanced) ```python # Download all methods and premethods from device @@ -278,22 +381,15 @@ for premethod in method_set.premethods: print(f"PreMethod: {premethod.name}") ``` -### Get Specific Method by Name - -```python -# Get a specific method -method = await tc.get_method_by_name("PCR_30cycles") -if method: - print(f"Found method: {method.name}") -``` - -### Convert to Protocol + Config +### Convert Method to Protocol + Config ```python from pylabrobot.thermocycling.inheco.odtc_xml import odtc_method_to_protocol # Get method from device -method = await tc.get_method_by_name("PCR_30cycles") +method = await tc.get_method("PCR_30cycles") +if not method: + raise ValueError("Method not found") # Convert to Protocol + ODTCConfig (lossless) protocol, config = odtc_method_to_protocol(method) @@ -304,35 +400,13 @@ protocol, config = odtc_method_to_protocol(method) ## Running Protocols -### Upload and Execute from XML File - -```python -# Upload MethodSet XML file to device -await tc.upload_method_set_from_file("my_methods.xml") +### Recommended: High-Level API -# Execute a method -await tc.execute_method("PCR_30cycles") -``` +The recommended approach uses `upload_protocol()` and `run_protocol()` for a simpler workflow: -### Upload and Execute from ODTCMethodSet Object +#### Upload and Run a Protocol ```python -from pylabrobot.thermocycling.inheco.odtc_xml import parse_method_set_file - -# Parse XML file -method_set = parse_method_set_file("my_methods.xml") - -# Upload to device -await tc.upload_method_set(method_set) - -# Execute -await tc.execute_method("PCR_30cycles") -``` - -### Convert Protocol to ODTC and Execute - -```python -from pylabrobot.thermocycling.inheco.odtc_xml import protocol_to_odtc_method from pylabrobot.thermocycling.standard import Protocol, Stage, Step # Create a Protocol @@ -349,22 +423,73 @@ protocol = Protocol( ] ) -# Convert to ODTCMethod (with default config) -# Note: Overshoot parameters will use defaults (minimal overshoot) -# Future work will automatically derive optimal overshoot parameters -odtc_method = protocol_to_odtc_method(protocol) +# Upload Protocol to device with method name "my_pcr" +await tc.upload_protocol(protocol, name="my_pcr") -# Upload method to device (need to wrap in MethodSet) -from pylabrobot.thermocycling.inheco.odtc_xml import ODTCMethodSet -method_set = ODTCMethodSet(methods=[odtc_method], premethods=[]) -await tc.upload_method_set(method_set) +# Run the method by name +await tc.run_protocol(method_name="my_pcr") -# Execute -await tc.execute_method(odtc_method.name) +# OR: Upload and run in one call +await tc.run_protocol(protocol=protocol, method_name="my_pcr") +``` + +#### Run Existing Method by Name + +```python +# List all methods on device +method_names = await tc.list_methods() # Returns: ["PCR_30cycles", "my_pcr", ...] + +# Run existing method by name +await tc.run_protocol(method_name="PCR_30cycles") +``` + +#### Set Mount Temperature (Simple Temperature Control) + +```python +# Set mount temperature to 37°C (creates PreMethod internally) +await tc.set_mount_temperature(37.0) + +# Non-blocking with custom lid temperature +execution = await tc.set_mount_temperature( + 37.0, + lid_temperature=110.0, + wait=False +) +# Do other work... +await execution # Wait when ready ``` **Note on performance:** Protocols created directly in PyLabRobot (without an `ODTCConfig` from an existing XML protocol) will use default overshoot parameters, which may result in slower heating compared to manually-tuned ODTC protocols. Future enhancements will automatically derive optimal overshoot parameters for improved thermal performance. +### Advanced: Lower-Level API + +For advanced use cases, you can work directly with XML files and `ODTCMethodSet` objects: + +#### Upload and Execute from XML File + +```python +# Upload MethodSet XML file to device (backend-level) +await tc.backend.upload_method_set_from_file("my_methods.xml") + +# Execute a method by name +await tc.run_protocol(method_name="PCR_30cycles") +``` + +#### Upload and Execute from ODTCMethodSet Object + +```python +from pylabrobot.thermocycling.inheco.odtc_xml import parse_method_set_file + +# Parse XML file +method_set = parse_method_set_file("my_methods.xml") + +# Upload to device (backend-level) +await tc.backend.upload_method_set(method_set) + +# Execute +await tc.run_protocol(method_name="PCR_30cycles") +``` + ## XML to Protocol + Config Conversion ### Lossless Round-Trip Conversion @@ -479,9 +604,10 @@ from pylabrobot.thermocycling.inheco.odtc_xml import ( parse_method_set ) -# 1. Get method from device (or parse from XML) -method_set = await tc.get_method_set() -method = method_set.methods[0] +# 1. Get method from device +method = await tc.get_method("PCR_30cycles") +if not method: + raise ValueError("Method not found") # 2. Convert to Protocol + Config protocol, config = odtc_method_to_protocol(method) @@ -489,34 +615,32 @@ protocol, config = odtc_method_to_protocol(method) # 3. Modify protocol (generic changes) protocol.stages[0].repeats = 35 # Change cycle count -# 4. Convert back to ODTC (preserves all ODTC-specific params) -method_modified = protocol_to_odtc_method(protocol, config=config) +# 4. Upload modified protocol (preserves all ODTC-specific params via config) +await tc.upload_protocol(protocol, name="PCR_35cycles", config=config) -# 5. Upload and execute -method_set_modified = ODTCMethodSet(methods=[method_modified], premethods=[]) -await tc.upload_method_set(method_set_modified) -await tc.execute_method(method_modified.name) +# 5. Execute +await tc.run_protocol(method_name="PCR_35cycles") ``` ### Round-Trip from Device XML ```python -# Full round-trip: Device XML → Protocol+Config → Device XML +# Full round-trip: Device Method → Protocol+Config → Device Method # 1. Get from device -method_set = await tc.get_method_set() -method = method_set.methods[0] +method = await tc.get_method("PCR_30cycles") +if not method: + raise ValueError("Method not found") # 2. Convert to Protocol + Config protocol, config = odtc_method_to_protocol(method) -# 3. Convert back to XML -method_restored = protocol_to_odtc_method(protocol, config=config) -method_set_restored = ODTCMethodSet(methods=[method_restored], premethods=[]) -xml_restored = method_set_to_xml(method_set_restored) +# 3. Upload back to device (preserves all ODTC-specific params via config) +await tc.upload_protocol(protocol, name="PCR_30cycles_restored", config=config) -# 4. Verify round-trip (should be equivalent) -# (Note: XML formatting may differ, but content should match) +# 4. Verify round-trip by comparing methods +method_restored = await tc.get_method("PCR_30cycles_restored") +# Methods should be equivalent (XML formatting may differ, but content should match) ``` ## DataEvent Collection @@ -525,14 +649,14 @@ During method execution, the ODTC sends `DataEvent` messages containing experime ```python # Start method -execution = await tc.execute_method("PCR_30cycles", wait=False) +execution = await tc.run_protocol(method_name="PCR_30cycles", wait=False) # Get DataEvents for this execution -events = await tc.get_data_events(execution.request_id) -# Returns: {request_id: [event1, event2, ...]} +events = await execution.get_data_events() +# Returns: List of DataEvent objects -# Get all collected events -all_events = await tc.get_data_events() +# Get all collected events (backend-level) +all_events = await tc.backend.get_data_events() # Returns: {request_id1: [...], request_id2: [...]} ``` @@ -576,6 +700,7 @@ State transitions are tracked automatically: ```python from pylabrobot.thermocycling.inheco import InhecoODTC, ODTCBackend from pylabrobot.thermocycling.inheco.odtc_xml import odtc_method_to_protocol +from pylabrobot.thermocycling.standard import Protocol, Stage, Step # Setup tc = InhecoODTC( @@ -585,29 +710,42 @@ tc = InhecoODTC( ) await tc.setup() -# Get method from device -method = await tc.get_method_by_name("PCR_30cycles") -if not method: - raise ValueError("Method not found") - -# Convert to Protocol + Config -protocol, config = odtc_method_to_protocol(method) - -# Modify protocol -protocol.stages[0].repeats = 35 - -# Convert back (preserves all ODTC params including overtemp) -from pylabrobot.thermocycling.inheco.odtc_xml import protocol_to_odtc_method, ODTCMethodSet -method_modified = protocol_to_odtc_method(protocol, config=config) +# Example 1: Get existing method from device, modify, and run +method = await tc.get_method("PCR_30cycles") +if method: + # Convert to Protocol + Config + protocol, config = odtc_method_to_protocol(method) + + # Modify protocol + protocol.stages[0].repeats = 35 + + # Upload modified protocol with new name + await tc.upload_protocol(protocol, name="PCR_35cycles", config=config) + + # Run with parallel operations + execution = await tc.run_protocol(method_name="PCR_35cycles", wait=False) + temps = await tc.read_temperatures() # Parallel operation + await execution # Wait for completion + +# Example 2: Create new protocol and run +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 + ) + ] +) -# Upload and execute -method_set = ODTCMethodSet(methods=[method_modified], premethods=[]) -await tc.upload_method_set(method_set) +# Upload and run in one call +await tc.run_protocol(protocol=protocol, method_name="my_pcr") -# Execute with parallel operations -execution = await tc.execute_method(method_modified.name, wait=False) -temps = await tc.read_temperatures() # Parallel operation -await execution # Wait for completion +# Example 3: Set mount temperature +await tc.set_mount_temperature(37.0) # Cleanup await tc.stop() diff --git a/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb b/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb index 3acbba3cea9..080f42fa1ef 100644 --- a/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb +++ b/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb @@ -106,10 +106,10 @@ "name": "stderr", "output_type": "stream", "text": [ - "2026-01-26 22:08:28,661 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device reset (unlocked)\n", - "2026-01-26 22:08:28,859 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - GetStatus returned raw state: 'standby' (type: str)\n", - "2026-01-26 22:08:28,860 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device is in standby state, calling Initialize...\n", - "2026-01-26 22:08:30,396 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device successfully initialized and is in idle state\n" + "2026-01-26 22:42:19,718 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device reset (unlocked)\n", + "2026-01-26 22:42:20,270 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - GetStatus returned raw state: 'standby' (type: str)\n", + "2026-01-26 22:42:20,270 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device is in standby state, calling Initialize...\n", + "2026-01-26 22:42:29,348 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device successfully initialized and is in idle state\n" ] }, { @@ -141,6 +141,7 @@ "output_type": "stream", "text": [ "Methods and PreMethods (77):\n", + " - plr_currentProtocol\n", " - M18_Abnahmetest\n", " - M23_LEAK\n", " - M24_LEAKCYCLE\n", @@ -216,8 +217,7 @@ " - EVOPLUS_Init_4C\n", " - EVOPLUS_Init_110C\n", " - EVOPLUS_Init_Block20CLid85C\n", - " - EVOPLUS_Init_Block20CLid40C\n", - " - plr_currentProtocol\n" + " - EVOPLUS_Init_Block20CLid40C\n" ] } ], @@ -266,7 +266,7 @@ "output_type": "stream", "text": [ "Starting door close (non-blocking)...\n", - "✓ Door close command started! Request ID: 1351407823\n" + "✓ Door close command started! Request ID: 897717862\n" ] } ], @@ -286,7 +286,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "metadata": {}, "outputs": [ { @@ -300,7 +300,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "2026-01-26 22:08:43,684 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - MethodSet XML saved to: debug_set_mount_temp.xml\n" + "2026-01-26 22:42:43,082 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - MethodSet XML saved to: debug_set_mount_temp.xml\n" ] }, { @@ -309,9 +309,9 @@ "text": [ "✓ Door close completed!\n", "Setting mount temperature to 37°C...\n", - "✓ Method started! Request ID: 712229516\n", + "✓ Method started! Request ID: 1515759907\n", "Current temperatures:\n", - "ODTCSensorValues(timestamp='2026-01-26T23:27:44Z', mount=24.42, mount_monitor=24.27, lid=56.4, lid_monitor=56.81, ambient=22.95, pcb=28.84, heatsink=24.71, heatsink_tec=24.23)\n", + "ODTCSensorValues(timestamp='2026-01-27T00:01:44Z', mount=24.8, mount_monitor=24.71, lid=41.21, lid_monitor=41.300000000000004, ambient=23.32, pcb=27.52, heatsink=25.22, heatsink_tec=24.77)\n", "Check debug_set_mount_temp.xml for the generated XML\n" ] } @@ -340,16 +340,16 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 7, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "ODTCSensorValues(timestamp='2026-01-26T23:30:19Z', mount=37.0, mount_monitor=37.03, lid=111.71000000000001, lid_monitor=112.25, ambient=23.52, pcb=26.36, heatsink=23.54, heatsink_tec=23.16)" + "ODTCSensorValues(timestamp='2026-01-27T00:01:45Z', mount=25.23, mount_monitor=24.96, lid=41.45, lid_monitor=41.42, ambient=22.77, pcb=27.47, heatsink=25.2, heatsink_tec=24.63)" ] }, - "execution_count": 13, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -360,7 +360,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 8, "metadata": {}, "outputs": [ { @@ -374,7 +374,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "2026-01-26 22:16:38,830 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - MethodSet XML saved to: debug_cycling_protocol.xml\n" + "2026-01-26 22:50:38,661 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - MethodSet XML saved to: debug_cycling_protocol.xml\n" ] }, { @@ -382,10 +382,10 @@ "output_type": "stream", "text": [ "Running temperature cycling protocol...\n", - "✓ Protocol started! Request ID: 710292357\n", - "✓ Method started! Request ID: 710292357\n", + "✓ Protocol started! Request ID: 1179967139\n", + "✓ Method started! Request ID: 1179967139\n", "Current temperatures:\n", - "ODTCSensorValues(timestamp='2026-01-26T23:35:39Z', mount=36.85, mount_monitor=36.96, lid=110.16, lid_monitor=111.19, ambient=23.580000000000002, pcb=25.69, heatsink=23.42, heatsink_tec=23.09)\n", + "ODTCSensorValues(timestamp='2026-01-27T00:09:38Z', mount=36.85, mount_monitor=36.99, lid=109.87, lid_monitor=110.86, ambient=23.44, pcb=25.6, heatsink=23.28, heatsink_tec=22.94)\n", "Check debug_cycling_protocol.xml for the generated XML\n" ] } @@ -434,14 +434,14 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 9, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Protocol Completed, door opening with request ID: 159742277\n", + "Protocol Completed, door opening with request ID: 1844714615\n", "✓ Door open completed!\n", "✓ Connection closed.\n" ] @@ -460,13 +460,6 @@ "await tc.stop()\n", "print(\"✓ Connection closed.\")" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/pylabrobot/thermocycling/inheco/odtc.py b/pylabrobot/thermocycling/inheco/odtc.py index 71614e173a2..85bca18409b 100644 --- a/pylabrobot/thermocycling/inheco/odtc.py +++ b/pylabrobot/thermocycling/inheco/odtc.py @@ -1,5 +1,6 @@ """Inheco ODTC (On-Deck Thermocycler) resource class.""" +import logging from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union from pylabrobot.resources import Coordinate, ItemizedResource @@ -11,6 +12,8 @@ if TYPE_CHECKING: from pylabrobot.thermocycling.standard import Protocol +logger = logging.getLogger(__name__) + # Mapping from model string to variant integer (960000 for 96-well, 384000 for 384-well) _MODEL_TO_VARIANT: Dict[str, int] = { @@ -19,6 +22,72 @@ } +def _volume_to_fluid_quantity(volume_ul: float) -> int: + """Map volume in µL to ODTC fluid_quantity code. + + Args: + volume_ul: Volume in microliters. + + Returns: + fluid_quantity code: 0 (10-29ul), 1 (30-74ul), or 2 (75-100ul). + + Raises: + ValueError: If volume > 100 µL. + """ + if volume_ul > 100: + raise ValueError( + f"Volume {volume_ul} µL exceeds ODTC maximum of 100 µL. " + "Please use a volume between 0-100 µL." + ) + elif volume_ul <= 29: + return 0 # 10-29ul + elif volume_ul <= 74: + return 1 # 30-74ul + else: # 75 <= volume_ul <= 100 + return 2 # 75-100ul + + +def _validate_volume_fluid_quantity( + volume_ul: float, + fluid_quantity: int, + is_premethod: bool = False, +) -> None: + """Validate that volume matches fluid_quantity and warn if mismatch. + + Args: + volume_ul: Volume in microliters. + fluid_quantity: ODTC fluid_quantity code (0, 1, or 2). + is_premethod: If True, suppress warnings for volume=0 (premethods don't need volume). + """ + if volume_ul <= 0: + if not is_premethod: + logger.warning( + f"block_max_volume={volume_ul} µL is invalid. Using default fluid_quantity=1 (30-74ul). " + "Please provide a valid volume for accurate thermal calibration." + ) + return + + if volume_ul > 100: + raise ValueError( + f"Volume {volume_ul} µL exceeds ODTC maximum of 100 µL. " + "Please use a volume between 0-100 µL." + ) + + # Check if volume matches fluid_quantity + expected_fluid_quantity = _volume_to_fluid_quantity(volume_ul) + if fluid_quantity != expected_fluid_quantity: + volume_ranges = { + 0: "10-29 µL", + 1: "30-74 µL", + 2: "75-100 µL", + } + logger.warning( + f"Volume mismatch: block_max_volume={volume_ul} µL suggests fluid_quantity={expected_fluid_quantity} " + f"({volume_ranges[expected_fluid_quantity]}), but config has fluid_quantity={fluid_quantity} " + f"({volume_ranges.get(fluid_quantity, 'unknown')}). This may affect thermal calibration accuracy." + ) + + class InhecoODTC(Thermocycler): """Inheco ODTC (On-Deck Thermocycler). @@ -127,6 +196,7 @@ async def upload_protocol( allow_overwrite: bool = False, debug_xml: bool = False, xml_output_path: Optional[str] = None, + block_max_volume: Optional[float] = None, ) -> str: """Upload a Protocol to the device. @@ -140,18 +210,28 @@ async def upload_protocol( 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. + block_max_volume: Optional volume in µL. If provided and config is None, used to set + fluid_quantity. If config is provided, validates volume matches fluid_quantity. Returns: Method name (resolved name, may be scratch name if not provided). Raises: ValueError: If allow_overwrite=False and method name already exists. + ValueError: If block_max_volume > 100 µL. """ - from .odtc_xml import ODTCConfig, protocol_to_odtc_method + from .odtc_xml import protocol_to_odtc_method # Use model-aware defaults if config not provided if config is None: - config = self.get_default_config() + if block_max_volume is not None and block_max_volume > 0 and block_max_volume <= 100: + fluid_quantity = _volume_to_fluid_quantity(block_max_volume) + config = self.get_default_config(fluid_quantity=fluid_quantity) + else: + config = self.get_default_config() + elif block_max_volume is not None: + # Config provided - validate volume matches fluid_quantity + _validate_volume_fluid_quantity(block_max_volume, config.fluid_quantity, is_premethod=False) # Set name in config if provided if name is not None: @@ -174,11 +254,13 @@ async def upload_protocol( async def run_protocol( self, protocol: Optional["Protocol"] = None, + block_max_volume: float = 0.0, config: Optional["ODTCConfig"] = None, method_name: Optional[str] = None, wait: bool = True, debug_xml: bool = False, xml_output_path: Optional[str] = None, + **backend_kwargs: Any, ) -> Optional[MethodExecution]: """Run a protocol or method on the device. @@ -192,6 +274,9 @@ async def run_protocol( Args: protocol: Optional Protocol to convert and execute. If None, method_name must be provided. + block_max_volume: Maximum block volume (µL) for safety. Used to set fluid_quantity in + ODTCConfig when config is None. Must be between 0-100 µL. If 0, uses default + fluid_quantity=1 (30-74ul) with a warning. If >100, raises ValueError. config: Optional ODTCConfig for device-specific parameters. If None and protocol provided, uses model-aware defaults. method_name: Name of Method to execute. If protocol provided, this is the name for the @@ -202,6 +287,7 @@ async def run_protocol( 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. + **backend_kwargs: Additional backend-specific parameters (unused for ODTC). Returns: If wait=True: None (blocks until complete) @@ -213,17 +299,39 @@ async def run_protocol( if protocol is not None: # Convert, upload, and execute protocol if config is None: - config = self.get_default_config() + # Use block_max_volume to set fluid_quantity if volume is valid + if block_max_volume > 0 and block_max_volume <= 100: + fluid_quantity = _volume_to_fluid_quantity(block_max_volume) + config = self.get_default_config(fluid_quantity=fluid_quantity) + elif block_max_volume == 0: + # Use default but warn + logger.warning( + f"block_max_volume={block_max_volume} µL is invalid. Using default fluid_quantity=1 (30-74ul). " + "Please provide a valid volume for accurate thermal calibration." + ) + config = self.get_default_config() + else: # block_max_volume > 100 + raise ValueError( + f"Volume {block_max_volume} µL exceeds ODTC maximum of 100 µL. " + "Please use a volume between 0-100 µL." + ) + else: + # Config provided - validate volume matches fluid_quantity (only if volume > 0) + if block_max_volume > 0: + _validate_volume_fluid_quantity(block_max_volume, config.fluid_quantity, is_premethod=False) + # Upload with allow_overwrite=True since we're about to execute it - method_name = await self.upload_protocol( + # Pass block_max_volume only if > 0 to avoid triggering validation warnings in upload_protocol + resolved_method_name = await self.upload_protocol( protocol, config=config, name=method_name, allow_overwrite=True, debug_xml=debug_xml, xml_output_path=xml_output_path, + block_max_volume=block_max_volume if block_max_volume > 0 else None, ) - return await self.backend.execute_method(method_name, wait=wait) + return await self.backend.execute_method(resolved_method_name, wait=wait) elif method_name is not None: # Execute existing method by name return await self.backend.execute_method(method_name, wait=wait) @@ -332,6 +440,7 @@ async def set_mount_temperature( # Create PreMethod - much simpler than a full Method # PreMethods just set target temperatures and hold them + # Note: PreMethods don't need volume/fluid_quantity (they're for temperature conditioning) premethod = ODTCPreMethod( name=resolve_protocol_name(None), # Uses "plr_currentProtocol" target_block_temperature=temperature, @@ -366,25 +475,6 @@ async def read_temperatures(self): """ return await self.backend.read_temperatures() - async def monitor_temperatures( - self, - callback=None, - poll_interval: float = 2.0, - timeout=None, - stop_on_method_completion: bool = True, - show_updates: bool = True, - ): - """Monitor temperatures during method execution with controlled polling rate. - - See ODTCBackend.monitor_temperatures() for full documentation. - """ - return await self.backend.monitor_temperatures( - callback=callback, - poll_interval=poll_interval, - timeout=timeout, - stop_on_method_completion=stop_on_method_completion, - show_updates=show_updates, - ) # Device control methods diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index 3e603df7643..45d933a5f1c 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -5,21 +5,19 @@ import asyncio import logging from dataclasses import dataclass -from typing import Any, Callable, Dict, List, Optional, Union, cast +from typing import Any, Dict, List, Optional, Union, cast from pylabrobot.thermocycling.backend import ThermocyclerBackend from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol from .odtc_sila_interface import ODTCSiLAInterface, SiLAState from .odtc_xml import ( - ODTCConfig, ODTCMethod, ODTCMethodSet, ODTCPreMethod, ODTCSensorValues, generate_odtc_timestamp, get_method_by_name, - get_premethod_by_name, list_method_names, method_set_to_xml, parse_method_set, @@ -492,7 +490,7 @@ async def close_door(self, wait: bool = True) -> Optional[CommandExecution]: ) - # Sensor commands + # Sensor commands TODO: We cleaned this up at the xml extraction level, clean the method up for temperature reporting async def read_temperatures(self) -> ODTCSensorValues: """Read all temperature sensors. @@ -1103,10 +1101,15 @@ async def get_lid_open(self) -> bool: Returns: True if lid/door is open. + + Raises: + NotImplementedError: Door status query not available. Call open_door() or + close_door() explicitly to control and confirm door state. """ - # Would need GetDoorStatus command - for now, return False - # TODO: Implement GetDoorStatus if available - return False + raise NotImplementedError( + "Door status query not available. Call open_door() or close_door() " + "explicitly to control and confirm door state." + ) async def get_lid_status(self) -> LidStatus: """Get lid temperature status. From ade262de77783404a88b5293d752a2b247553671 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:04:19 -0800 Subject: [PATCH 10/28] Sila2 Style Command Lifecycle Management --- pylabrobot/thermocycling/inheco/odtc.py | 2 + .../thermocycling/inheco/odtc_backend.py | 17 +- .../inheco/odtc_backend_tests.py | 150 +++++++++- .../inheco/odtc_sila_interface.py | 282 +++++++++++++----- 4 files changed, 367 insertions(+), 84 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/odtc.py b/pylabrobot/thermocycling/inheco/odtc.py index 85bca18409b..8502934432d 100644 --- a/pylabrobot/thermocycling/inheco/odtc.py +++ b/pylabrobot/thermocycling/inheco/odtc.py @@ -109,6 +109,8 @@ class InhecoODTC(Thermocycler): - Depth (Y): 298 mm - Height (Z): 130 mm (with drawer closed) + To configure async command completion (polling fallback, timeout, behavior when ResponseEvent is lost), pass ``poll_interval``, ``lifetime_of_execution``, and ``on_response_event_missing`` to :class:`ODTCBackend`. + Example usage: ```python from pylabrobot.thermocycling.inheco import InhecoODTC, ODTCBackend diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index 45d933a5f1c..25ba1f8baea 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -5,7 +5,7 @@ import asyncio import logging from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Union, cast +from typing import Any, Dict, List, Literal, Optional, Union, cast from pylabrobot.thermocycling.backend import ThermocyclerBackend from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol @@ -103,6 +103,9 @@ def __init__( odtc_ip: str, client_ip: Optional[str] = None, logger: Optional[logging.Logger] = None, + poll_interval: float = 5.0, + lifetime_of_execution: Optional[float] = None, + on_response_event_missing: Literal["warn_and_continue", "error"] = "warn_and_continue", ): """Initialize ODTC backend. @@ -110,9 +113,19 @@ def __init__( odtc_ip: IP address of the ODTC device. client_ip: IP address of this client (auto-detected if None). logger: Logger instance (creates one if None). + poll_interval: Seconds between GetStatus calls in the async completion polling fallback (SiLA2 subscribe_by_polling style). Default 5.0. + lifetime_of_execution: Max seconds to wait for async command completion (SiLA2 deadline). If None, uses 3 hours. Protocol execution is always bounded. + on_response_event_missing: When completion is detected via polling but ResponseEvent was not received: "warn_and_continue" (resolve with None, log warning) or "error" (set exception). Default "warn_and_continue". """ super().__init__() - self._sila = ODTCSiLAInterface(machine_ip=odtc_ip, client_ip=client_ip, logger=logger) + self._sila = ODTCSiLAInterface( + machine_ip=odtc_ip, + client_ip=client_ip, + logger=logger, + poll_interval=poll_interval, + lifetime_of_execution=lifetime_of_execution, + on_response_event_missing=on_response_event_missing, + ) self.logger = logger or logging.getLogger(__name__) async def setup(self) -> None: diff --git a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py index 01d62450762..b42f6ade9f5 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py @@ -7,7 +7,11 @@ from unittest.mock import AsyncMock, MagicMock, patch from pylabrobot.thermocycling.inheco.odtc_backend import CommandExecution, MethodExecution, ODTCBackend -from pylabrobot.thermocycling.inheco.odtc_sila_interface import ODTCSiLAInterface, SiLAState +from pylabrobot.thermocycling.inheco.odtc_sila_interface import ( + ODTCSiLAInterface, + SiLAState, + _parse_iso8601_duration_seconds, +) class TestODTCSiLAInterface(unittest.IsolatedAsyncioTestCase): @@ -15,7 +19,7 @@ class TestODTCSiLAInterface(unittest.IsolatedAsyncioTestCase): def setUp(self): """Set up test fixtures.""" - self.interface = ODTCSiLAInterface(machine_ip="192.168.1.100") + self.interface = ODTCSiLAInterface(machine_ip="192.168.1.100", client_ip="127.0.0.1") def test_normalize_command_name(self): """Test command name normalization for aliases.""" @@ -36,6 +40,12 @@ def test_check_state_allowability(self): self.assertTrue(self.interface._check_state_allowability("Initialize")) self.assertFalse(self.interface._check_state_allowability("ExecuteMethod")) + # GetStatus allowed during initializing (needed for polling and post-Initialize verification) + self.interface._current_state = SiLAState.INITIALIZING + self.assertTrue(self.interface._check_state_allowability("GetStatus")) + self.assertTrue(self.interface._check_state_allowability("GetDeviceIdentification")) + self.assertFalse(self.interface._check_state_allowability("ExecuteMethod")) + self.interface._current_state = SiLAState.IDLE self.assertTrue(self.interface._check_state_allowability("ExecuteMethod")) self.assertFalse(self.interface._check_state_allowability("Initialize")) @@ -132,6 +142,142 @@ def test_handle_return_code(self): self.interface._handle_return_code(1000, "Device error", "ExecuteMethod", 123) self.assertEqual(self.interface._current_state, SiLAState.INERROR) + def test_get_terminal_state(self): + """Test terminal state map for polling fallback.""" + self.assertEqual(self.interface._get_terminal_state("Reset"), "standby") + self.assertEqual(self.interface._get_terminal_state("Initialize"), "idle") + self.assertEqual(self.interface._get_terminal_state("LockDevice"), "standby") + self.assertEqual(self.interface._get_terminal_state("UnlockDevice"), "standby") + self.assertEqual(self.interface._get_terminal_state("OpenDoor"), "idle") + self.assertEqual(self.interface._get_terminal_state("ExecuteMethod"), "idle") + + + def test_parse_iso8601_duration_seconds(self): + """Test ISO 8601 duration parsing (seconds, minutes, hours).""" + self.assertEqual(_parse_iso8601_duration_seconds("PT30.7S"), 30.7) + self.assertEqual(_parse_iso8601_duration_seconds("PT30M"), 30 * 60) + self.assertEqual(_parse_iso8601_duration_seconds("PT2H"), 2 * 3600) + self.assertEqual(_parse_iso8601_duration_seconds("PT1H30M10S"), 3600 + 30 * 60 + 10) + self.assertEqual(_parse_iso8601_duration_seconds("PT0.1S"), 0.1) + self.assertIsNone(_parse_iso8601_duration_seconds("")) + self.assertIsNone(_parse_iso8601_duration_seconds("invalid")) + self.assertEqual(_parse_iso8601_duration_seconds("P1D"), 86400) + + +# Minimal SOAP responses for dual-track tests (return_code 2 = async accepted; no duration = poll immediately). +_OPEN_DOOR_ASYNC_RESPONSE = b""" + + + + + 2 + Accepted + + + +""" + +_GET_STATUS_IDLE_RESPONSE = b""" + + + + idle + + 1 + Success + + + +""" + +_OPEN_DOOR_ASYNC_RESPONSE_WITH_DURATION = b""" + + + + + 2 + Accepted + PT0.1S + + + +""" + +_GET_STATUS_BUSY_RESPONSE = b""" + + + + busy + + 1 + Success + + + +""" + + +class TestODTCSiLADualTrack(unittest.IsolatedAsyncioTestCase): + """Tests for dual-track async completion (ResponseEvent + polling fallback).""" + + async def test_polling_fallback_completes_when_no_response_event(self): + """When ResponseEvent never arrives, polling sees idle and completes Future (warn_and_continue).""" + call_count = 0 + + def mock_urlopen(req): + nonlocal call_count + call_count += 1 + body = _OPEN_DOOR_ASYNC_RESPONSE if call_count == 1 else _GET_STATUS_IDLE_RESPONSE + resp = MagicMock() + resp.read.return_value = body + cm = MagicMock() + cm.__enter__.return_value = resp + cm.__exit__.return_value = None + return cm + + with patch("urllib.request.urlopen", side_effect=mock_urlopen): + interface = ODTCSiLAInterface( + machine_ip="192.168.1.100", + client_ip="127.0.0.1", + poll_interval=0.05, + lifetime_of_execution=5.0, + on_response_event_missing="warn_and_continue", + ) + interface._current_state = SiLAState.IDLE + # Do not call setup() so we avoid binding the HTTP server (sandbox/CI friendly). + result = await interface.send_command("OpenDoor", return_request_id=False) + self.assertIsNone(result) + self.assertGreaterEqual(call_count, 2) + + async def test_lifetime_of_execution_exceeded_raises(self): + """When lifetime_of_execution is exceeded before terminal state, Future gets timeout exception.""" + call_count = 0 + + def mock_urlopen(req): + nonlocal call_count + call_count += 1 + body = _OPEN_DOOR_ASYNC_RESPONSE if call_count == 1 else _GET_STATUS_BUSY_RESPONSE + resp = MagicMock() + resp.read.return_value = body + cm = MagicMock() + cm.__enter__.return_value = resp + cm.__exit__.return_value = None + return cm + + with patch("urllib.request.urlopen", side_effect=mock_urlopen): + interface = ODTCSiLAInterface( + machine_ip="192.168.1.100", + client_ip="127.0.0.1", + poll_interval=0.2, + lifetime_of_execution=0.5, + on_response_event_missing="warn_and_continue", + ) + interface._current_state = SiLAState.IDLE + # Do not call setup() so we avoid binding the HTTP server (sandbox/CI friendly). + with self.assertRaises(RuntimeError) as cm: + await interface.send_command("OpenDoor", return_request_id=False) + self.assertIn("lifetime_of_execution", str(cm.exception)) + class TestODTCBackend(unittest.IsolatedAsyncioTestCase): """Tests for ODTCBackend.""" diff --git a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py index 998209453ce..cc8cc2009dd 100644 --- a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py +++ b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py @@ -12,11 +12,12 @@ import asyncio import logging +import re import time import urllib.request from dataclasses import dataclass from enum import Enum -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Literal, Optional, Set import xml.etree.ElementTree as ET @@ -99,6 +100,48 @@ class SiLAState(str, Enum): INERROR = "inError" # Device returns "inError" (camelCase per SCILABackend comment) +# Default max wait for async command completion (3 hours). SiLA2-aligned: protocol execution always bounded. +DEFAULT_LIFETIME_OF_EXECUTION: float = 10800.0 + +# Buffer (seconds) added to estimated_remaining_time before starting polling loop. +POLLING_START_BUFFER: float = 10.0 + + +def _parse_iso8601_duration_seconds(duration_str: str) -> Optional[float]: + """Parse ISO 8601 duration string to total seconds (supports D, H, M, S). + + Examples: PT30.7S, PT30M, PT2H, PT1H30M10S, P1DT2H30M10.5S. + H, M, S are only parsed in the time part (after T) so P1M (month) is not treated as minutes. + Returns None if parsing fails. + """ + if not isinstance(duration_str, str) or not duration_str.strip(): + return None + total = 0.0 + # Days: P1D + d_match = re.search(r"(\d+(?:\.\d+)?)D", duration_str, re.IGNORECASE) + if d_match: + total += float(d_match.group(1)) * 86400 + # Time part (after T): H, M, S + time_part = re.search(r"T(.+)$", duration_str) + if time_part: + t = time_part.group(1) + h_match = re.search(r"(\d+(?:\.\d+)?)H", t, re.IGNORECASE) + if h_match: + total += float(h_match.group(1)) * 3600 + m_match = re.search(r"(\d+(?:\.\d+)?)M", t, re.IGNORECASE) + if m_match: + total += float(m_match.group(1)) * 60 + s_match = re.search(r"(\d+(?:\.\d+)?)S", t, re.IGNORECASE) + if s_match: + total += float(s_match.group(1)) + else: + # No T: e.g. PT30.7S might be written as P30.7S in some variants; treat S only + s_match = re.search(r"(\d+(?:\.\d+)?)S", duration_str, re.IGNORECASE) + if s_match: + total += float(s_match.group(1)) + return total if total > 0 else None + + @dataclass(frozen=True) class PendingCommand: """Tracks a pending async command.""" @@ -107,7 +150,7 @@ class PendingCommand: request_id: int fut: asyncio.Future[Any] started_at: float - timeout: Optional[float] = None # Estimated duration from device response + estimated_remaining_time: Optional[float] = None # Seconds from device duration (ISO 8601) lock_id: Optional[str] = None # LockId sent with LockDevice command (for tracking) @@ -218,9 +261,9 @@ class ODTCSiLAInterface(InhecoSiLAInterface): "ExecuteMethod": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, "GetConfiguration": {SiLAState.STARTUP: False, SiLAState.STANDBY: True, SiLAState.IDLE: False, SiLAState.BUSY: False}, "GetParameters": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "GetDeviceIdentification": {SiLAState.STARTUP: True, SiLAState.STANDBY: True, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "GetDeviceIdentification": {SiLAState.STARTUP: True, SiLAState.STANDBY: True, SiLAState.INITIALIZING: True, SiLAState.IDLE: True, SiLAState.BUSY: True}, "GetLastData": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "GetStatus": {SiLAState.STARTUP: True, SiLAState.STANDBY: True, SiLAState.IDLE: True, SiLAState.BUSY: True}, + "GetStatus": {SiLAState.STARTUP: True, SiLAState.STANDBY: True, SiLAState.INITIALIZING: True, SiLAState.IDLE: True, SiLAState.BUSY: True}, "Initialize": {SiLAState.STARTUP: False, SiLAState.STANDBY: True, SiLAState.IDLE: False, SiLAState.BUSY: False}, "LockDevice": {SiLAState.STARTUP: False, SiLAState.STANDBY: True, SiLAState.IDLE: False, SiLAState.BUSY: False}, "OpenDoor": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, @@ -241,11 +284,24 @@ class ODTCSiLAInterface(InhecoSiLAInterface): # Device-specific return codes that indicate DeviceError (InError state) DEVICE_ERROR_CODES: Set[int] = {1000, 2000, 2001, 2007} + # Terminal state for polling fallback: command name -> expected state when command is done (per STATE_ALLOWABILITY). + ASYNC_COMMAND_TERMINAL_STATE: Dict[str, str] = { + "Reset": "standby", + "Initialize": "idle", + "LockDevice": "standby", + "UnlockDevice": "standby", + } + # Default terminal state for other async commands (OpenDoor, CloseDoor, ExecuteMethod, StopMethod, etc.) + _DEFAULT_TERMINAL_STATE: str = "idle" + def __init__( self, machine_ip: str, client_ip: Optional[str] = None, logger: Optional[logging.Logger] = None, + poll_interval: float = 5.0, + lifetime_of_execution: Optional[float] = None, + on_response_event_missing: Literal["warn_and_continue", "error"] = "warn_and_continue", ) -> None: """Initialize ODTC SiLA interface. @@ -253,9 +309,17 @@ def __init__( machine_ip: IP address of the ODTC device. client_ip: IP address of this client (auto-detected if None). logger: Logger instance (creates one if None). + poll_interval: Seconds between GetStatus calls in the polling fallback (SiLA2 subscribe_by_polling style). + lifetime_of_execution: Max seconds to wait for async completion (SiLA2 deadline). If None, use 3 hours. + on_response_event_missing: When polling sees terminal state but ResponseEvent was not received: + "warn_and_continue" -> resolve with None and log warning; "error" -> set exception. """ super().__init__(machine_ip=machine_ip, client_ip=client_ip, logger=logger) + self._poll_interval = poll_interval + self._lifetime_of_execution = lifetime_of_execution + self._on_response_event_missing = on_response_event_missing + # Multi-request tracking (replaces single _pending) self._pending_by_id: Dict[int, PendingCommand] = {} self._active_request_ids: Set[int] = set() # For duplicate detection @@ -345,6 +409,53 @@ def _normalize_command_name(self, command: str) -> str: return "CloseDoor" return command + def _get_terminal_state(self, command: str) -> str: + """Return the device state that indicates this async command has finished (for polling fallback).""" + return self.ASYNC_COMMAND_TERMINAL_STATE.get(command, self._DEFAULT_TERMINAL_STATE) + + def _complete_pending( + self, + request_id: int, + result: Any = None, + exception: Optional[BaseException] = None, + update_lock_state: bool = True, + ) -> None: + """Complete a pending command: cleanup and resolve its Future (single place for ResponseEvent and polling). + + Args: + request_id: Pending command request_id. + result: Value to set on Future (ignored if exception is set). + exception: If set, set_exception on Future instead of set_result(result). + update_lock_state: If True (ResponseEvent path), apply LockDevice/UnlockDevice/Reset lock updates. + """ + pending = self._pending_by_id.get(request_id) + if pending is None or pending.fut.done(): + return + + if update_lock_state: + if pending.name == "LockDevice" and pending.lock_id is not None: + self._lock_id = pending.lock_id + self._logger.info(f"Device locked with lockId: {self._lock_id}") + elif pending.name == "UnlockDevice": + self._lock_id = None + self._logger.info("Device unlocked") + elif pending.name == "Reset": + self._lock_id = None + self._logger.info("Device reset (unlocked)") + + self._pending_by_id.pop(request_id, None) + self._active_request_ids.discard(request_id) + normalized_cmd = self._normalize_command_name(pending.name) + self._executing_commands.discard(normalized_cmd) + + if not self._executing_commands and self._current_state == SiLAState.BUSY: + self._current_state = SiLAState.IDLE + + if exception is not None: + pending.fut.set_exception(exception) + else: + pending.fut.set_result(result) + def _validate_lock_id(self, lock_id: Optional[str]) -> None: """Validate lockId parameter. @@ -478,7 +589,6 @@ async def _on_http(self, req: InhecoSiLAInterface._HTTPRequest) -> bytes: self._logger.warning("ResponseEvent missing requestId") return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") - # Find matching pending command pending = self._pending_by_id.get(request_id) if pending is None: self._logger.warning(f"ResponseEvent for unknown requestId: {request_id}") @@ -488,47 +598,29 @@ async def _on_http(self, req: InhecoSiLAInterface._HTTPRequest) -> bytes: self._logger.warning(f"ResponseEvent for already-completed requestId: {request_id}") return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") - # Fix: Code 3 means "async finished" (SUCCESS), not error + # Code 3 = async finished (SUCCESS) if return_code == 3: - # Success - extract response data response_data = response_event.get("responseData", "") if response_data and response_data.strip(): try: root = ET.fromstring(response_data) - pending.fut.set_result(root) + self._complete_pending(request_id, result=root, update_lock_state=True) except ET.ParseError as e: self._logger.error(f"Failed to parse ResponseEvent responseData: {e}") - pending.fut.set_exception(RuntimeError(f"Failed to parse response data: {e}")) + self._complete_pending( + request_id, + exception=RuntimeError(f"Failed to parse response data: {e}"), + update_lock_state=False, + ) else: - # No response data - still success (e.g., OpenDoor, CloseDoor) - pending.fut.set_result(None) - - # Handle LockDevice/UnlockDevice/Reset to update lock state (only on success) - if pending.name == "LockDevice" and pending.lock_id is not None: - self._lock_id = pending.lock_id - self._logger.info(f"Device locked with lockId: {self._lock_id}") - elif pending.name == "UnlockDevice": - self._lock_id = None - self._logger.info("Device unlocked") - elif pending.name == "Reset": - # Reset unlocks device implicitly - self._lock_id = None - self._logger.info("Device reset (unlocked)") + self._complete_pending(request_id, result=None, update_lock_state=True) else: - # Error or other code err_msg = message.replace("\n", " ") if message else f"Unknown error (code {return_code})" - pending.fut.set_exception(RuntimeError(f"Command {pending.name} failed with code {return_code}: '{err_msg}'")) - - # Clean up - self._pending_by_id.pop(request_id, None) - self._active_request_ids.discard(request_id) - # Use normalized command name for cleanup - normalized_cmd = self._normalize_command_name(pending.name) - self._executing_commands.discard(normalized_cmd) - - # Update state: if no more commands executing, transition busy -> idle - if not self._executing_commands and self._current_state == SiLAState.BUSY: - self._current_state = SiLAState.IDLE + self._complete_pending( + request_id, + exception=RuntimeError(f"Command {pending.name} failed with code {return_code}: '{err_msg}'"), + update_lock_state=False, + ) return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") @@ -562,23 +654,24 @@ async def _on_http(self, req: InhecoSiLAInterface._HTTPRequest) -> bytes: # Handle ErrorEvent (recoverable errors with continuation tasks) if "ErrorEvent" in decoded: error_event = decoded["ErrorEvent"] - request_id = error_event.get("requestId") + 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 {request_id}: code {return_code}, message: {message}") + self._logger.error(f"ErrorEvent for requestId {req_id}: code {return_code}, message: {message}") - # Transition to ErrorHandling state self._current_state = SiLAState.ERRORHANDLING - # Find matching pending command and set exception - pending = self._pending_by_id.get(request_id) - if pending and not pending.fut.done(): - err_msg = message.replace("\n", " ") if message else f"Error (code {return_code})" - pending.fut.set_exception(RuntimeError(f"Command {pending.name} error: '{err_msg}'")) + err_msg = message.replace("\n", " ") if message else f"Error (code {return_code})" + pending_err = self._pending_by_id.get(req_id) + if pending_err and not pending_err.fut.done(): + self._complete_pending( + req_id, + exception=RuntimeError(f"Command {pending_err.name} error: '{err_msg}'"), + update_lock_state=False, + ) - # TODO: Continuation task selection/response not implemented (out of scope) return SOAP_RESPONSE_ErrorEventResponse.encode("utf-8") # Unknown event type @@ -600,6 +693,9 @@ async def send_command( - LockId validation - Multi-request tracking - Proper return code handling + - Dual-track async completion: primary = ResponseEvent (push); fallback = GetStatus polling + after estimated_remaining_time (from device duration), bounded by lifetime_of_execution + (default 3 h). SiLA2-aligned: poll_interval (subscribe_by_polling style), lifetime_of_execution. Args: command: Command name. @@ -721,40 +817,26 @@ def _do_request() -> bytes: return decoded elif return_code == 2: - # Asynchronous command accepted - set up pending tracking + # Asynchronous command accepted - set up pending tracking and polling fallback fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future() - # Extract duration for timeout (if provided) result = decoded.get(f"{command}Response", {}).get(f"{command}Result", {}) duration_str = result.get("duration") - timeout = None + estimated_remaining_time: Optional[float] = None if duration_str: - # Parse ISO 8601 duration (simplified - just extract seconds) - # Format: PT30.7S or P5DT4H12M17S - try: - # For now, just use a multiplier - proper parsing would use datetime.timedelta - # This is a simplified approach - if isinstance(duration_str, str) and "S" in duration_str: - # Extract seconds part - import re - match = re.search(r"(\d+(?:\.\d+)?)S", duration_str) - if match: - seconds = float(match.group(1)) - timeout = seconds + 10.0 # Add 10s buffer - except Exception: - pass # Ignore parsing errors, use None timeout - - # Store lock_id for LockDevice commands so we can set it after success + estimated_remaining_time = _parse_iso8601_duration_seconds(str(duration_str)) + pending_lock_id = None if command == "LockDevice" and "lockId" in params: pending_lock_id = params["lockId"] + started_at = time.time() pending = PendingCommand( name=command, request_id=request_id, fut=fut, - started_at=time.time(), - timeout=timeout, + started_at=started_at, + estimated_remaining_time=estimated_remaining_time, lock_id=pending_lock_id, ) @@ -762,25 +844,65 @@ def _do_request() -> bytes: self._active_request_ids.add(request_id) self._executing_commands.add(normalized_cmd) - # Update state: idle -> busy if self._current_state == SiLAState.IDLE: self._current_state = SiLAState.BUSY - # Handle return_request_id parameter + effective_lifetime = ( + self._lifetime_of_execution + if self._lifetime_of_execution is not None + else DEFAULT_LIFETIME_OF_EXECUTION + ) + + async def _poll_until_complete() -> None: + if estimated_remaining_time is not None and estimated_remaining_time > 0: + await asyncio.sleep(estimated_remaining_time + POLLING_START_BUFFER) + while True: + pending_ref = self._pending_by_id.get(request_id) + if pending_ref is None: + break + if pending_ref.fut.done(): + break + elapsed = time.time() - pending_ref.started_at + if elapsed >= effective_lifetime: + self._complete_pending( + request_id, + exception=RuntimeError( + f"Command {pending_ref.name} timed out (lifetime_of_execution exceeded: {effective_lifetime}s)" + ), + update_lock_state=False, + ) + break + try: + decoded_status = await self.send_command("GetStatus") + except Exception: + await asyncio.sleep(self._poll_interval) + continue + state = decoded_status.get("GetStatusResponse", {}).get("state") + if state: + self._update_state_from_status(state) + terminal_state = self._get_terminal_state(pending_ref.name) + if state == terminal_state: + if self._on_response_event_missing == "warn_and_continue": + self._logger.warning( + "ResponseEvent not received; completed via GetStatus polling (possible sleep/network loss)" + ) + self._complete_pending(request_id, result=None, update_lock_state=False) + else: + self._complete_pending( + request_id, + exception=RuntimeError( + "ResponseEvent not received; device reported idle. Possible callback loss (e.g. sleep/network)." + ), + update_lock_state=False, + ) + break + await asyncio.sleep(self._poll_interval) + + asyncio.create_task(_poll_until_complete()) + if return_request_id: - # Return Future and request_id immediately (caller awaits) return (fut, request_id) - else: - # Existing behavior: await Future - try: - result = await fut - return result - except asyncio.TimeoutError: - # Clean up on timeout - self._pending_by_id.pop(request_id, None) - self._active_request_ids.discard(request_id) - self._executing_commands.discard(normalized_cmd) - raise RuntimeError(f"Command {command} timed out waiting for ResponseEvent") + return await fut else: # Error return code From cb86ea31b9d306a68fb8f883175df1d43d55251b Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:03:30 -0800 Subject: [PATCH 11/28] ODTC: send_command vs start_command, get_method/list_methods, doc consistency Split interface into send_command (result) and start_command (handle); rename backend get_method_by_name/list_method_names to get_method/list_methods; standardize docstrings and update README/notebook. --- pylabrobot/thermocycling/inheco/__init__.py | 9 +- pylabrobot/thermocycling/inheco/dev/README.md | 18 +- pylabrobot/thermocycling/inheco/odtc.py | 10 +- .../thermocycling/inheco/odtc_backend.py | 426 ++++++++++-------- .../inheco/odtc_backend_tests.py | 87 ++-- .../inheco/odtc_sila_interface.py | 239 ++++++---- 6 files changed, 470 insertions(+), 319 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/__init__.py b/pylabrobot/thermocycling/inheco/__init__.py index 69832bb7b34..11e536538dc 100644 --- a/pylabrobot/thermocycling/inheco/__init__.py +++ b/pylabrobot/thermocycling/inheco/__init__.py @@ -1,6 +1,11 @@ """Inheco ODTC thermocycler implementation.""" from .odtc import InhecoODTC -from .odtc_backend import ODTCBackend +from .odtc_backend import CommandExecution, MethodExecution, ODTCBackend -__all__ = ["InhecoODTC", "ODTCBackend"] +__all__ = [ + "CommandExecution", + "InhecoODTC", + "MethodExecution", + "ODTCBackend", +] diff --git a/pylabrobot/thermocycling/inheco/dev/README.md b/pylabrobot/thermocycling/inheco/dev/README.md index e260ed72993..76bf5bac9f3 100644 --- a/pylabrobot/thermocycling/inheco/dev/README.md +++ b/pylabrobot/thermocycling/inheco/dev/README.md @@ -16,6 +16,7 @@ The ODTC implementation provides a complete interface for controlling Inheco ODT 1. **`ODTCSiLAInterface`** (`odtc_sila_interface.py`) - Low-level SiLA communication (SOAP over HTTP) + - Exposes **`send_command`** (run and return result) and **`start_command`** (start and return handle) for consistency with automation patterns; backend uses these for `wait=True` vs `wait=False` - Handles parallelism rules, state management, and lockId validation - Tracks pending async commands and collects DataEvents - Manages state transitions (Startup → Standby → Idle → Busy) @@ -48,9 +49,9 @@ Understanding the distinction between **Protocol** and **Method** is crucial for - Example: `Protocol(stages=[Stage(steps=[Step(temperature=95.0, hold_seconds=30.0)])])` ### Method (ODTC Device) -- **`ODTCMethod`** or **`ODTCPreMethod`**: ODTC-specific XML-defined method stored on the device +- **`ODTCMethod`** or **`ODTCPreMethod`**: ODTC-specific XML-defined method stored on the device. In ODTC/SiLA, a **method** is the device's runnable protocol (thermocycling program). - Contains ODTC-specific parameters (overshoot, slopes, PID settings) - - Stored on the device with a unique **method name** (string identifier) + - Stored on the device with a unique **method name** (string identifier; SiLA: `methodName`) - Example: `"PCR_30cycles"` is a method name stored on the device ### Method Name (String) @@ -64,12 +65,13 @@ Understanding the distinction between **Protocol** and **Method** is crucial for **High-level methods (recommended):** - `upload_protocol(protocol, name="method_name")` - Upload a `Protocol` to device as a method - `run_protocol(protocol=..., method_name=...)` - Upload and run a `Protocol`, or run existing method by name -- `list_methods()` - List all method names (both Methods and PreMethods) on device +- `list_methods()` - List all method names (both Methods and PreMethods) on device; returns list of method name strings - `get_method(name)` - Get `ODTCMethod` or `ODTCPreMethod` by name from device - `set_mount_temperature(temperature)` - Set mount temperature (creates PreMethod internally) **Lower-level methods (for advanced use):** - `get_method_set()` - Get full `ODTCMethodSet` (all methods and premethods) +- `backend.get_method(name)` / `backend.list_methods()` - Same as resource; backend uses `get_method` and `list_methods` (normalized with resource) - `backend.execute_method(method_name)` - Execute method by name (backend-level) ### Workflow Examples @@ -132,10 +134,12 @@ await tc.setup() # Device transitions: Startup → Standby → Idle ``` -The `setup()` method: +The `setup()` method prepares the connection and brings the device to idle: 1. Starts HTTP server for receiving SiLA events (ResponseEvent, StatusEvent, DataEvent) -2. Calls `Reset()` to register event receiver URI and move to `standby` -3. Calls `Initialize()` to move to `idle` (ready for commands) +2. Calls SiLA `Reset()` to register event receiver URI and move to `standby` +3. Calls SiLA `Initialize()` to move to `idle` (ready for commands) + +Use `setup()` for lifecycle/connection; `initialize()` is the SiLA command (standby→idle) and is called by `setup()` when needed. ### Cleanup @@ -189,7 +193,7 @@ await door_opening.wait() # Explicit wait method #### CommandExecution Handle -The `CommandExecution` handle provides: +The `CommandExecution` handle (sometimes called a job or task handle in other automation systems) provides: - **`request_id`**: SiLA request ID for tracking DataEvents - **`command_name`**: Name of the executing command diff --git a/pylabrobot/thermocycling/inheco/odtc.py b/pylabrobot/thermocycling/inheco/odtc.py index 8502934432d..ea61a8eac90 100644 --- a/pylabrobot/thermocycling/inheco/odtc.py +++ b/pylabrobot/thermocycling/inheco/odtc.py @@ -351,21 +351,25 @@ async def get_method_set(self): async def get_method(self, name: str) -> Optional[Union[ODTCMethod, ODTCPreMethod]]: """Get a method by name from the device (searches both methods and premethods). + In ODTC/SiLA, a method is a runnable protocol (thermocycling program). + Args: name: Method name to retrieve. Returns: ODTCMethod or ODTCPreMethod if found, None otherwise. """ - return await self.backend.get_method_by_name(name) + return await self.backend.get_method(name) async def list_methods(self) -> List[str]: """List all method names (both methods and premethods) on the device. + In ODTC/SiLA, a method is a runnable protocol (thermocycling program). + Returns: - List of method names. + List of method names (strings). """ - return await self.backend.list_method_names() + return await self.backend.list_methods() async def stop_method(self, wait: bool = True) -> Optional[CommandExecution]: """Stop any currently running method. diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index 25ba1f8baea..0040578c042 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -10,7 +10,12 @@ from pylabrobot.thermocycling.backend import ThermocyclerBackend from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol -from .odtc_sila_interface import ODTCSiLAInterface, SiLAState +from .odtc_sila_interface import ( + DEFAULT_LIFETIME_OF_EXECUTION, + ODTCSiLAInterface, + POLLING_START_BUFFER, + SiLAState, +) from .odtc_xml import ( ODTCMethod, ODTCMethodSet, @@ -29,27 +34,75 @@ @dataclass class CommandExecution: - """Base handle for an executing async command that can be awaited or checked. + """Handle for an executing async command (SiLA return_code 2). - This handle is returned from async commands when wait=False and provides: + Sometimes called a job or task handle in other automation systems. + Returned from async commands when wait=False. Provides: - Awaitable interface (can be awaited like a Task) - Request ID access for DataEvent tracking - Command completion waiting + - done, status, estimated_remaining_time, started_at, lifetime for ETA and resumable wait """ request_id: int command_name: str _future: asyncio.Future[Any] backend: "ODTCBackend" + estimated_remaining_time: Optional[float] = None # seconds from device duration + started_at: Optional[float] = None # time.time() when command was sent + lifetime: Optional[float] = None # max wait seconds (for resumable wait) def __await__(self): """Make this awaitable like a Task.""" return self._future.__await__() + @property + def done(self) -> bool: + """True if the command has finished (success or error).""" + return self._future.done() + + @property + def status(self) -> str: + """'running', 'success', or 'error'.""" + if not self._future.done(): + return "running" + try: + self._future.result() + return "success" + except Exception: + return "error" + async def wait(self) -> None: """Wait for command completion.""" await self._future + async def wait_resumable(self, poll_interval: float = 5.0) -> None: + """Wait for completion using only GetStatus and handle timing (resumable after restart). + + Use when the in-memory Future is not available (e.g. after process restart). + Persist the handle (request_id, started_at, estimated_remaining_time, lifetime), + reconnect the backend, then call this. Uses backend.wait_for_completion_by_time. + Terminal state is 'idle' for most commands. + + Args: + poll_interval: Seconds between GetStatus calls. + + Raises: + TimeoutError: If lifetime exceeded before device reached terminal state. + """ + import time + + started_at = self.started_at if self.started_at is not None else time.time() + lifetime = self.lifetime if self.lifetime is not None else self.backend._get_effective_lifetime() + await self.backend.wait_for_completion_by_time( + request_id=self.request_id, + started_at=started_at, + estimated_remaining_time=self.estimated_remaining_time, + lifetime=lifetime, + poll_interval=poll_interval, + terminal_state="idle", + ) + async def get_data_events(self) -> List[Dict[str, Any]]: """Get DataEvents for this command execution. @@ -62,15 +115,15 @@ async def get_data_events(self) -> List[Dict[str, Any]]: @dataclass class MethodExecution(CommandExecution): - """Handle for an executing method (protocol) with method-specific features. + """Handle for an executing method (SiLA ExecuteMethod; method = runnable protocol). - This handle is returned from execute_method(wait=False) and provides: + Returned from execute_method(wait=False). Provides: - All features from CommandExecution (awaitable, request_id, DataEvents) - Method-specific status checking - - Method stopping capability + - Method stopping capability (SiLA: StopMethod) """ - method_name: str + method_name: str = "" # default required after parent's optional fields def __post_init__(self): """Set command_name to ExecuteMethod for parent class.""" @@ -129,14 +182,17 @@ def __init__( self.logger = logger or logging.getLogger(__name__) async def setup(self) -> None: - """Initialize the ODTC device connection. + """Prepare the ODTC connection and bring the device to idle. Performs the full SiLA connection lifecycle: 1. Sets up the HTTP event receiver server 2. Calls Reset to move from startup -> standby and register event receiver 3. Waits for Reset to complete and checks state - 4. Calls Initialize to move from standby -> idle + 4. Calls Initialize (SiLA command) to move from standby -> idle 5. Verifies device is in idle state after Initialize + + This is lifecycle/connection setup; initialize() is the SiLA command that + moves standby -> idle (called by setup() when needed). """ # Step 1: Set up the HTTP event receiver server await self._sila.setup() @@ -189,6 +245,49 @@ def serialize(self) -> dict: "port": self._sila.bound_port, } + def _get_effective_lifetime(self) -> float: + """Effective max wait for async command completion (seconds).""" + if self._sila._lifetime_of_execution is not None: + return self._sila._lifetime_of_execution + return DEFAULT_LIFETIME_OF_EXECUTION + + async def _run_async_command( + self, + command_name: str, + wait: bool, + execution_class: type, + method_name: Optional[str] = None, + **send_kwargs: Any, + ) -> Optional[Union[CommandExecution, MethodExecution]]: + """Run an async SiLA command; return None if wait else execution handle.""" + if wait: + await self._sila.send_command(command_name, **send_kwargs) + return None + fut, request_id, eta, started_at = await self._sila.start_command( + command_name, **send_kwargs + ) + lifetime = self._get_effective_lifetime() + if execution_class is MethodExecution: + return MethodExecution( + request_id=request_id, + command_name="ExecuteMethod", + _future=fut, + backend=self, + estimated_remaining_time=eta, + started_at=started_at, + lifetime=lifetime, + method_name=method_name or "", + ) + return CommandExecution( + request_id=request_id, + command_name=command_name, + _future=fut, + backend=self, + estimated_remaining_time=eta, + started_at=started_at, + lifetime=lifetime, + ) + # ============================================================================ # Response Parsing Utilities # ============================================================================ @@ -304,29 +403,19 @@ async def get_status(self) -> str: return str(state) async def initialize(self, wait: bool = True) -> Optional[CommandExecution]: - """Initialize the device (must be in standby state). + """Initialize the device (SiLA command: standby -> idle). + + Call when device is in standby; setup() performs the full lifecycle + including Reset and Initialize. SiLA command: Initialize. Args: - wait: If True, block until completion. If False, return CommandExecution handle. + wait: If True, block until completion. If False, return an execution + handle (CommandExecution). Returns: - If wait=True: None (blocks until complete) - If wait=False: CommandExecution handle (awaitable, has request_id) + If wait=True: None. If wait=False: execution handle (awaitable). """ - if wait: - await self._sila.send_command("Initialize", return_request_id=False) - return None - else: - fut, request_id = await self._sila.send_command( - "Initialize", - return_request_id=True - ) - return CommandExecution( - request_id=request_id, - command_name="Initialize", - _future=fut, - backend=self - ) + return await self._run_async_command("Initialize", wait, CommandExecution) async def reset( self, @@ -335,43 +424,28 @@ async def reset( simulation_mode: bool = False, wait: bool = True, ) -> Optional[CommandExecution]: - """Reset the device. + """Reset the device (SiLA command: startup -> standby, register event receiver). Args: - device_id: Device identifier. - event_receiver_uri: Event receiver URI (auto-detected if None). - simulation_mode: Enable simulation mode. - wait: If True, block until completion. If False, return CommandExecution handle. + device_id: Device identifier (SiLA: deviceId). + event_receiver_uri: Event receiver URI (SiLA: eventReceiverURI; auto-detected if None). + simulation_mode: Enable simulation mode (SiLA: simulationMode). + wait: If True, block until completion. If False, return an execution + handle (CommandExecution). Returns: - If wait=True: None (blocks until complete) - If wait=False: CommandExecution handle (awaitable, has request_id) + If wait=True: None. If wait=False: execution handle (awaitable). """ if event_receiver_uri is None: event_receiver_uri = f"http://{self._sila._client_ip}:{self._sila.bound_port}/" - if wait: - await self._sila.send_command( - "Reset", - return_request_id=False, - deviceId=device_id, - eventReceiverURI=event_receiver_uri, - simulationMode=simulation_mode, - ) - return None - else: - fut, request_id = await self._sila.send_command( - "Reset", - return_request_id=True, - deviceId=device_id, - eventReceiverURI=event_receiver_uri, - simulationMode=simulation_mode, - ) - return CommandExecution( - request_id=request_id, - command_name="Reset", - _future=fut, - backend=self - ) + return await self._run_async_command( + "Reset", + wait, + CommandExecution, + deviceId=device_id, + eventReceiverURI=event_receiver_uri, + simulationMode=simulation_mode, + ) async def get_device_identification(self) -> dict: """Get device identification information. @@ -391,116 +465,65 @@ async def get_device_identification(self) -> dict: return result if isinstance(result, dict) else {} async def lock_device(self, lock_id: str, lock_timeout: Optional[float] = None, wait: bool = True) -> Optional[CommandExecution]: - """Lock the device for exclusive access. + """Lock the device for exclusive access (SiLA: LockDevice). Args: - lock_id: Unique lock identifier. - lock_timeout: Lock timeout in seconds (optional). - wait: If True, block until completion. If False, return CommandExecution handle. + lock_id: Unique lock identifier (SiLA: lockId). + lock_timeout: Lock timeout in seconds (optional; SiLA: lockTimeout). + wait: If True, block until completion. If False, return an execution + handle (CommandExecution). Returns: - If wait=True: None (blocks until complete) - If wait=False: CommandExecution handle (awaitable, has request_id) + If wait=True: None. If wait=False: execution handle (awaitable). """ params: dict = {"lockId": lock_id, "PMSId": "PyLabRobot"} if lock_timeout is not None: params["lockTimeout"] = lock_timeout - if wait: - await self._sila.send_command("LockDevice", return_request_id=False, lock_id=lock_id, **params) - return None - else: - fut, request_id = await self._sila.send_command( - "LockDevice", - return_request_id=True, - lock_id=lock_id, - **params - ) - return CommandExecution( - request_id=request_id, - command_name="LockDevice", - _future=fut, - backend=self - ) + return await self._run_async_command( + "LockDevice", wait, CommandExecution, lock_id=lock_id, **params + ) async def unlock_device(self, wait: bool = True) -> Optional[CommandExecution]: - """Unlock the device. + """Unlock the device (SiLA: UnlockDevice). Args: - wait: If True, block until completion. If False, return CommandExecution handle. + wait: If True, block until completion. If False, return an execution + handle (CommandExecution). Returns: - If wait=True: None (blocks until complete) - If wait=False: CommandExecution handle (awaitable, has request_id) + If wait=True: None. If wait=False: execution handle (awaitable). """ # Must provide the lockId that was used to lock it if self._sila._lock_id is None: raise RuntimeError("Device is not locked") - if wait: - await self._sila.send_command("UnlockDevice", return_request_id=False, lock_id=self._sila._lock_id) - return None - else: - fut, request_id = await self._sila.send_command( - "UnlockDevice", - return_request_id=True, - lock_id=self._sila._lock_id - ) - return CommandExecution( - request_id=request_id, - command_name="UnlockDevice", - _future=fut, - backend=self - ) + return await self._run_async_command( + "UnlockDevice", wait, CommandExecution, lock_id=self._sila._lock_id + ) - # Door control commands + # Door control commands (SiLA: OpenDoor, CloseDoor; thermocycler: lid) async def open_door(self, wait: bool = True) -> Optional[CommandExecution]: - """Open the drawer door. + """Open the door (thermocycler lid). SiLA: OpenDoor. Args: - wait: If True, block until completion. If False, return CommandExecution handle. + wait: If True, block until completion. If False, return an execution + handle (CommandExecution). Returns: - If wait=True: None (blocks until complete) - If wait=False: CommandExecution handle (awaitable, has request_id) + If wait=True: None. If wait=False: execution handle (awaitable). """ - if wait: - await self._sila.send_command("OpenDoor", return_request_id=False) - return None - else: - fut, request_id = await self._sila.send_command( - "OpenDoor", - return_request_id=True - ) - return CommandExecution( - request_id=request_id, - command_name="OpenDoor", - _future=fut, - backend=self - ) + return await self._run_async_command("OpenDoor", wait, CommandExecution) async def close_door(self, wait: bool = True) -> Optional[CommandExecution]: - """Close the drawer door. + """Close the door (thermocycler lid). SiLA: CloseDoor. Args: - wait: If True, block until completion. If False, return CommandExecution handle. + wait: If True, block until completion. If False, return an execution + handle (CommandExecution). Returns: - If wait=True: None (blocks until complete) - If wait=False: CommandExecution handle (awaitable, has request_id) + If wait=True: None. If wait=False: execution handle (awaitable). """ - if wait: - await self._sila.send_command("CloseDoor", return_request_id=False) - return None - else: - fut, request_id = await self._sila.send_command( - "CloseDoor", - return_request_id=True - ) - return CommandExecution( - request_id=request_id, - command_name="CloseDoor", - _future=fut, - backend=self - ) + return await self._run_async_command("CloseDoor", wait, CommandExecution) # Sensor commands TODO: We cleaned this up at the xml extraction level, clean the method up for temperature reporting @@ -573,73 +596,44 @@ async def get_last_data(self) -> str: # For now, return the raw response - parsing can be added later return str(resp) # type: ignore - # Method control commands + # Method control commands (SiLA: ExecuteMethod; method = runnable protocol) async def execute_method( self, method_name: str, priority: Optional[int] = None, wait: bool = True, ) -> Optional[MethodExecution]: - """Execute a method or premethod by name. + """Execute a method or premethod by name (SiLA: ExecuteMethod; methodName). + + In ODTC/SiLA, a method is a runnable protocol (thermocycling program). Args: - method_name: Name of the method or premethod to execute. - priority: Priority (not used by ODTC, but part of SiLA spec). - wait: If True, block until completion and return None. - If False, return MethodExecution handle immediately. + method_name: Name of the method or premethod to execute (SiLA: methodName). + priority: Priority (SiLA spec; not used by ODTC). + wait: If True, block until completion. If False, return an execution + handle (MethodExecution). Returns: - If wait=True: None (blocks until complete) - If wait=False: MethodExecution handle (awaitable, has request_id) + If wait=True: None. If wait=False: execution handle (awaitable). """ params: dict = {"methodName": method_name} if priority is not None: params["priority"] = priority - - if wait: - # Blocking: await send_command normally - await self._sila.send_command("ExecuteMethod", return_request_id=False, **params) - return None - else: - # Use send_command with return_request_id=True to get Future and request_id - fut, request_id = await self._sila.send_command( - "ExecuteMethod", - return_request_id=True, - **params - ) - - return MethodExecution( - request_id=request_id, - command_name="ExecuteMethod", # Will be set correctly by __post_init__ - method_name=method_name, - _future=fut, - backend=self - ) + return await self._run_async_command( + "ExecuteMethod", wait, MethodExecution, method_name=method_name, **params + ) async def stop_method(self, wait: bool = True) -> Optional[CommandExecution]: - """Stop currently running method. + """Stop the currently running method (SiLA: StopMethod). Args: - wait: If True, block until completion. If False, return CommandExecution handle. + wait: If True, block until completion. If False, return an execution + handle (CommandExecution). Returns: - If wait=True: None (blocks until complete) - If wait=False: CommandExecution handle (awaitable, has request_id) + If wait=True: None. If wait=False: execution handle (awaitable). """ - if wait: - await self._sila.send_command("StopMethod", return_request_id=False) - return None - else: - fut, request_id = await self._sila.send_command( - "StopMethod", - return_request_id=True - ) - return CommandExecution( - request_id=request_id, - command_name="StopMethod", - _future=fut, - backend=self - ) + return await self._run_async_command("StopMethod", wait, CommandExecution) async def is_method_running(self) -> bool: """Check if a method is currently running. @@ -682,6 +676,57 @@ async def wait_for_method_completion( ) await asyncio.sleep(poll_interval) + async def wait_for_completion_by_time( + self, + request_id: int, + started_at: float, + estimated_remaining_time: Optional[float], + lifetime: float, + poll_interval: float = 5.0, + terminal_state: str = "idle", + ) -> None: + """Wait for async command completion using only wall-clock and GetStatus (resumable). + + Does not require the in-memory Future. Use after restart: persist request_id, + started_at, estimated_remaining_time, lifetime from the handle, then call this + with a reconnected backend. + + (a) Waits until time.time() >= started_at + estimated_remaining_time + buffer. + (b) Then polls GetStatus every poll_interval until state == terminal_state or + time.time() - started_at >= lifetime (then raises TimeoutError). + + Args: + request_id: SiLA request ID (for logging; not used for correlation). + started_at: time.time() when the command was sent. + estimated_remaining_time: Device-estimated duration in seconds (or None). + lifetime: Max seconds to wait (e.g. from handle.lifetime). + poll_interval: Seconds between GetStatus calls. + terminal_state: Device state that indicates command finished (default "idle"). + + Raises: + TimeoutError: If lifetime exceeded before terminal state. + """ + import time + + buffer = POLLING_START_BUFFER + eta = estimated_remaining_time or 0.0 + while True: + now = time.time() + elapsed = now - started_at + if elapsed >= lifetime: + raise TimeoutError( + f"Command (request_id={request_id}) did not complete within {lifetime}s" + ) + # Don't start polling until estimated time + buffer has passed + remaining_wait = started_at + eta + buffer - now + if remaining_wait > 0: + await asyncio.sleep(min(remaining_wait, poll_interval)) + continue + status = await self.get_status() + if status == terminal_state: + return + await asyncio.sleep(poll_interval) + async def get_data_events(self, request_id: Optional[int] = None) -> Dict[int, List[Dict[str, Any]]]: """Get collected DataEvents. @@ -714,23 +759,28 @@ async def get_method_set(self) -> ODTCMethodSet: # Parse MethodSet XML (it's escaped in the response) return parse_method_set(method_set_xml) - async def get_method_by_name(self, method_name: str) -> Optional[Union[ODTCMethod, ODTCPreMethod]]: + async def get_method(self, name: str) -> Optional[Union[ODTCMethod, ODTCPreMethod]]: """Get a method by name from the device (searches both methods and premethods). + In ODTC/SiLA, a method is a runnable protocol (thermocycling program). + SiLA command: ExecuteMethod; parameter: methodName. + Args: - method_name: Name of the method to retrieve. + name: Method name to retrieve. Returns: ODTCMethod or ODTCPreMethod if found, None otherwise. """ method_set = await self.get_method_set() - return get_method_by_name(method_set, method_name) + return get_method_by_name(method_set, name) - async def list_method_names(self) -> List[str]: + async def list_methods(self) -> List[str]: """List all method names (both methods and premethods) on the device. + In ODTC/SiLA, a method is a runnable protocol (thermocycling program). + Returns: - List of method names. + List of method names (strings). """ method_set = await self.get_method_set() return list_method_names(method_set) @@ -1006,11 +1056,11 @@ async def save_method_set_to_file(self, filepath: str) -> None: # ============================================================================ async def open_lid(self) -> None: - """Open thermocycler lid (maps to OpenDoor).""" + """Open the thermocycler lid (ODTC SiLA: OpenDoor).""" await self.open_door() async def close_lid(self) -> None: - """Close thermocycler lid (maps to CloseDoor).""" + """Close the thermocycler lid (ODTC SiLA: CloseDoor).""" await self.close_door() async def set_block_temperature(self, temperature: List[float]) -> None: diff --git a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py index b42f6ade9f5..73268874ed0 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py @@ -8,6 +8,7 @@ from pylabrobot.thermocycling.inheco.odtc_backend import CommandExecution, MethodExecution, ODTCBackend from pylabrobot.thermocycling.inheco.odtc_sila_interface import ( + SiLATimeoutError, ODTCSiLAInterface, SiLAState, _parse_iso8601_duration_seconds, @@ -236,16 +237,17 @@ def mock_urlopen(req): return cm with patch("urllib.request.urlopen", side_effect=mock_urlopen): + # Lifetime must exceed POLLING_START_BUFFER (10s) so polling can run before timeout. interface = ODTCSiLAInterface( machine_ip="192.168.1.100", client_ip="127.0.0.1", poll_interval=0.05, - lifetime_of_execution=5.0, + lifetime_of_execution=15.0, on_response_event_missing="warn_and_continue", ) interface._current_state = SiLAState.IDLE # Do not call setup() so we avoid binding the HTTP server (sandbox/CI friendly). - result = await interface.send_command("OpenDoor", return_request_id=False) + result = await interface.send_command("OpenDoor") self.assertIsNone(result) self.assertGreaterEqual(call_count, 2) @@ -274,8 +276,8 @@ def mock_urlopen(req): ) interface._current_state = SiLAState.IDLE # Do not call setup() so we avoid binding the HTTP server (sandbox/CI friendly). - with self.assertRaises(RuntimeError) as cm: - await interface.send_command("OpenDoor", return_request_id=False) + with self.assertRaises(SiLATimeoutError) as cm: + await interface.send_command("OpenDoor") self.assertIn("lifetime_of_execution", str(cm.exception)) @@ -290,6 +292,8 @@ def setUp(self): self.backend._sila.bound_port = 8080 self.backend._sila._machine_ip = "192.168.1.100" self.backend._sila._lock_id = None + self.backend._sila._lifetime_of_execution = None + self.backend._sila._client_ip = "127.0.0.1" async def test_setup(self): """Test backend setup.""" @@ -365,7 +369,9 @@ async def test_execute_method(self): """Test execute_method.""" self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign] await self.backend.execute_method("MyMethod") - self.backend._sila.send_command.assert_called_once_with("ExecuteMethod", methodName="MyMethod") + self.backend._sila.send_command.assert_called_once_with( + "ExecuteMethod", methodName="MyMethod" + ) async def test_stop_method(self): """Test stop_method.""" @@ -387,7 +393,9 @@ async def test_unlock_device(self): self.backend._sila._lock_id = "my_lock_id" self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign] await self.backend.unlock_device() - self.backend._sila.send_command.assert_called_once_with("UnlockDevice", lock_id="my_lock_id") + self.backend._sila.send_command.assert_called_once_with( + "UnlockDevice", lock_id="my_lock_id" + ) async def test_unlock_device_not_locked(self): """Test unlock_device when device is not locked.""" @@ -428,21 +436,21 @@ async def test_execute_method_wait_true(self): result = await self.backend.execute_method("PCR_30cycles", wait=True) self.assertIsNone(result) self.backend._sila.send_command.assert_called_once_with( - "ExecuteMethod", return_request_id=False, methodName="PCR_30cycles" + "ExecuteMethod", methodName="PCR_30cycles" ) async def test_execute_method_wait_false(self): """Test execute_method with wait=False (returns handle).""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] + self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] execution = await self.backend.execute_method("PCR_30cycles", wait=False) assert execution is not None # Type narrowing self.assertIsInstance(execution, MethodExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.method_name, "PCR_30cycles") - self.backend._sila.send_command.assert_called_once_with( - "ExecuteMethod", return_request_id=True, methodName="PCR_30cycles" + self.backend._sila.start_command.assert_called_once_with( + "ExecuteMethod", methodName="PCR_30cycles" ) async def test_method_execution_awaitable(self): @@ -498,7 +506,7 @@ async def test_method_execution_stop(self): ) self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign] await execution.stop() - self.backend._sila.send_command.assert_called_once_with("StopMethod", return_request_id=False) + self.backend._sila.send_command.assert_called_once_with("StopMethod") async def test_method_execution_inheritance(self): """Test that MethodExecution is a subclass of CommandExecution.""" @@ -562,102 +570,117 @@ async def test_open_door_wait_false(self): """Test open_door with wait=False (returns handle).""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] + self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] execution = await self.backend.open_door(wait=False) assert execution is not None # Type narrowing self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "OpenDoor") - self.backend._sila.send_command.assert_called_once_with( - "OpenDoor", return_request_id=True - ) + self.backend._sila.start_command.assert_called_once_with("OpenDoor") async def test_open_door_wait_true(self): """Test open_door with wait=True (blocking).""" self.backend._sila.send_command = AsyncMock(return_value=None) # type: ignore[method-assign] result = await self.backend.open_door(wait=True) self.assertIsNone(result) - self.backend._sila.send_command.assert_called_once_with( - "OpenDoor", return_request_id=False - ) + self.backend._sila.send_command.assert_called_once_with("OpenDoor") async def test_close_door_wait_false(self): """Test close_door with wait=False (returns handle).""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] + self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] execution = await self.backend.close_door(wait=False) assert execution is not None # Type narrowing self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "CloseDoor") + self.backend._sila.start_command.assert_called_once_with("CloseDoor") async def test_initialize_wait_false(self): """Test initialize with wait=False (returns handle).""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] + self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] execution = await self.backend.initialize(wait=False) assert execution is not None # Type narrowing self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "Initialize") + self.backend._sila.start_command.assert_called_once_with("Initialize") async def test_reset_wait_false(self): """Test reset with wait=False (returns handle).""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] + self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] execution = await self.backend.reset(wait=False) assert execution is not None # Type narrowing self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "Reset") + self.backend._sila.start_command.assert_called_once() + call_kwargs = self.backend._sila.start_command.call_args[1] + self.assertEqual(call_kwargs["deviceId"], "ODTC") + self.assertEqual(call_kwargs["eventReceiverURI"], "http://127.0.0.1:8080/") + self.assertFalse(call_kwargs["simulationMode"]) async def test_lock_device_wait_false(self): """Test lock_device with wait=False (returns handle).""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] + self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] execution = await self.backend.lock_device("my_lock", wait=False) assert execution is not None # Type narrowing self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "LockDevice") + self.backend._sila.start_command.assert_called_once_with( + "LockDevice", lock_id="my_lock", lockId="my_lock", PMSId="PyLabRobot" + ) async def test_unlock_device_wait_false(self): """Test unlock_device with wait=False (returns handle).""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) self.backend._sila._lock_id = "my_lock" - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] + self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] execution = await self.backend.unlock_device(wait=False) assert execution is not None # Type narrowing self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "UnlockDevice") + self.backend._sila.start_command.assert_called_once_with("UnlockDevice", lock_id="my_lock") async def test_stop_method_wait_false(self): """Test stop_method with wait=False (returns handle).""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.send_command = AsyncMock(return_value=(fut, 12345)) # type: ignore[method-assign] + self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] execution = await self.backend.stop_method(wait=False) assert execution is not None # Type narrowing self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "StopMethod") + self.backend._sila.start_command.assert_called_once_with("StopMethod") async def test_is_method_running(self): """Test is_method_running().""" - self.backend.get_status = AsyncMock(return_value="busy") # type: ignore[method-assign] - self.assertTrue(await self.backend.is_method_running()) - - self.backend.get_status = AsyncMock(return_value="idle") # type: ignore[method-assign] - self.assertFalse(await self.backend.is_method_running()) - - self.backend.get_status = AsyncMock(return_value="BUSY") # type: ignore[method-assign] - self.assertTrue(await self.backend.is_method_running()) + with patch.object( + ODTCBackend, "get_status", new_callable=AsyncMock, return_value="busy" + ): + self.assertTrue(await self.backend.is_method_running()) + + with patch.object( + ODTCBackend, "get_status", new_callable=AsyncMock, return_value="idle" + ): + self.assertFalse(await self.backend.is_method_running()) + + with patch.object( + ODTCBackend, "get_status", new_callable=AsyncMock, return_value="BUSY" + ): + # Backend compares to SiLAState.BUSY.value ("busy"), so uppercase is False + self.assertFalse(await self.backend.is_method_running()) async def test_wait_for_method_completion(self): """Test wait_for_method_completion().""" diff --git a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py index cc8cc2009dd..ae6030edae3 100644 --- a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py +++ b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py @@ -24,7 +24,58 @@ from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface from pylabrobot.storage.inheco.scila.soap import soap_decode, soap_encode, XSI + +# ----------------------------------------------------------------------------- +# SiLA/ODTC exceptions (typed command and device errors) +# ----------------------------------------------------------------------------- + + +class SiLAError(RuntimeError): + """Base exception for SiLA command and device errors.""" + + pass + + +class SiLACommandRejected(SiLAError): + """Command rejected: device busy (return code 4) or not allowed in state (return code 9).""" + + pass + + +class SiLALockIdError(SiLAError): + """LockId mismatch (return code 5).""" + + pass + + +class SiLARequestIdError(SiLAError): + """Invalid or duplicate requestId (return code 6).""" + + pass + + +class SiLAParameterError(SiLAError): + """Invalid command parameter (return code 11).""" + + pass + + +class SiLADeviceError(SiLAError): + """Device-specific error (return codes 1000, 2000, 2001, 2007, etc.).""" + + pass + + +class SiLATimeoutError(SiLAError): + """Command timed out: lifetime_of_execution exceeded or ResponseEvent not received.""" + + pass + + +# ----------------------------------------------------------------------------- # SOAP responses for events +# ----------------------------------------------------------------------------- + SOAP_RESPONSE_ResponseEventResponse = """ @@ -471,7 +522,7 @@ def _validate_lock_id(self, lock_id: Optional[str]) -> None: # Device is locked - must provide matching lockId if lock_id != self._lock_id: - raise RuntimeError( + raise SiLALockIdError( f"Device is locked with lockId '{self._lock_id}', " f"but command provided lockId '{lock_id}'. Return code: 5" ) @@ -523,21 +574,21 @@ def _handle_return_code( return elif return_code == 4: # Device busy - raise RuntimeError(f"Command {command_name} rejected: Device is busy (return code 4)") + raise SiLACommandRejected(f"Command {command_name} rejected: Device is busy (return code 4)") elif return_code == 5: # LockId error - raise RuntimeError(f"Command {command_name} rejected: LockId mismatch (return code 5)") + raise SiLALockIdError(f"Command {command_name} rejected: LockId mismatch (return code 5)") elif return_code == 6: # RequestId error - raise RuntimeError(f"Command {command_name} rejected: Invalid or duplicate requestId (return code 6)") + raise SiLARequestIdError(f"Command {command_name} rejected: Invalid or duplicate requestId (return code 6)") elif return_code == 9: # Command not allowed in this state - raise RuntimeError( + raise SiLACommandRejected( f"Command {command_name} not allowed in state {self._current_state.value} (return code 9)" ) elif return_code == 11: # Invalid parameter - raise RuntimeError(f"Command {command_name} rejected: Invalid parameter (return code 11): {message}") + raise SiLAParameterError(f"Command {command_name} rejected: Invalid parameter (return code 11): {message}") elif return_code == 12: # Finished with warning self._logger.warning(f"Command {command_name} finished with warning (return code 12): {message}") @@ -547,7 +598,7 @@ def _handle_return_code( if return_code in self.DEVICE_ERROR_CODES: # DeviceError - transition to InError self._current_state = SiLAState.INERROR - raise RuntimeError( + raise SiLADeviceError( f"Command {command_name} failed with device error (return code {return_code}): {message}" ) else: @@ -561,7 +612,7 @@ def _handle_return_code( self._current_state = SiLAState.ERRORHANDLING else: # Unknown return code - raise RuntimeError(f"Command {command_name} returned unknown code {return_code}: {message}") + raise SiLAError(f"Command {command_name} returned unknown code {return_code}: {message}") async def _on_http(self, req: InhecoSiLAInterface._HTTPRequest) -> bytes: """Handle incoming HTTP requests from device (events). @@ -678,93 +729,56 @@ async def _on_http(self, req: InhecoSiLAInterface._HTTPRequest) -> bytes: self._logger.warning("Unknown event type received") return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") - async def send_command( + async def _execute_command( self, command: str, lock_id: Optional[str] = None, - return_request_id: bool = False, - **kwargs, - ) -> Any | tuple[asyncio.Future[Any], int]: - """Send a SiLA command with parallelism, state, and lockId validation. - - Overrides base class to add: - - Parallelism checking - - State allowability checking - - LockId validation - - Multi-request tracking - - Proper return code handling - - Dual-track async completion: primary = ResponseEvent (push); fallback = GetStatus polling - after estimated_remaining_time (from device duration), bounded by lifetime_of_execution - (default 3 h). SiLA2-aligned: poll_interval (subscribe_by_polling style), lifetime_of_execution. + **kwargs: Any, + ) -> Any | tuple[asyncio.Future[Any], int, Optional[float], float]: + """Execute a SiLA command; return decoded dict (sync) or (fut, request_id, eta, started_at) (async). - Args: - command: Command name. - lock_id: LockId (defaults to None, validated if device is locked). - return_request_id: If True and command is async (return_code==2), - return (Future, request_id) tuple instead of awaiting Future. - Caller must await the Future themselves. - **kwargs: Additional command parameters. - - Returns: - - For sync commands (return_code==1): decoded response dict - - For async commands with return_request_id=False: result after awaiting Future - - For async commands with return_request_id=True: (Future, request_id) tuple - - Raises: - RuntimeError: For validation failures, return code errors, or state violations. + Internal helper used by send_command and start_command. Callers should use + send_command (run and return result) or start_command (start and return handle). """ if self._closed: raise RuntimeError("Interface is closed") - # GetStatus doesn't require lockId per ODTC doc section 2 if command != "GetStatus": self._validate_lock_id(lock_id) - # Check state allowability if not self._check_state_allowability(command): - raise RuntimeError( + raise SiLACommandRejected( f"Command {command} not allowed in state {self._current_state.value} (return code 9)" ) - # Synchronous read-only commands (GetStatus, GetDeviceIdentification) should always be allowed - # They are non-interfering queries that can run at any time, even during method execution if command not in self.SYNCHRONOUS_COMMANDS: - # Check parallelism (for commands in the table) normalized_cmd = self._normalize_command_name(command) if normalized_cmd in self.PARALLELISM_TABLE: async with self._parallelism_lock: if not self._check_parallelism(normalized_cmd): - raise RuntimeError( + raise SiLACommandRejected( f"Command {command} cannot run in parallel with currently executing commands (return code 4)" ) else: - # Command not in parallelism table - default to sequential (safe) async with self._parallelism_lock: if self._executing_commands: - # If any command is executing and this command isn't in table, reject - raise RuntimeError( + raise SiLACommandRejected( f"Command {command} not in parallelism table and device is busy (return code 4)" ) + else: + normalized_cmd = self._normalize_command_name(command) - # Generate request_id (reuse base class method) request_id = self._make_request_id() - - # Check for duplicate request_id (unlikely but guard against it) if request_id in self._active_request_ids: - raise RuntimeError(f"Duplicate requestId generated: {request_id} (return code 6)") + raise SiLARequestIdError(f"Duplicate requestId generated: {request_id} (return code 6)") - # Build command parameters params: Dict[str, Any] = {"requestId": request_id, **kwargs} - # Add lockId if provided (or if device is locked, it's required) - if command != "GetStatus": # GetStatus exception + if command != "GetStatus": if self._lock_id is not None: - # Device is locked - must provide lockId params["lockId"] = lock_id if lock_id is not None else self._lock_id elif lock_id is not None: - # Device not locked but lockId provided - include it params["lockId"] = lock_id - # Encode SOAP request cmd_xml = soap_encode( command, params, @@ -772,7 +786,6 @@ async def send_command( extra_method_xmlns={"i": XSI}, ) - # Make POST request url = f"http://{self._machine_ip}:8080/" req = urllib.request.Request( url=url, @@ -787,39 +800,25 @@ async def send_command( }, ) - # Execute request def _do_request() -> bytes: with urllib.request.urlopen(req) as resp: return resp.read() # type: ignore body = await asyncio.to_thread(_do_request) decoded = soap_decode(body.decode("utf-8")) - - # Extract return code and message return_code, message = self._get_return_code_and_message(command, decoded) + self._logger.debug(f"Command {command} returned code {return_code}: {message}") - # Debug logging for return code - self._logger.debug( - f"Command {command} returned code {return_code}: {message}" - ) - - # Handle return codes if return_code == 1: - # Synchronous success (GetStatus, GetDeviceIdentification) - # Update state from GetStatus response if applicable if command == "GetStatus": - # ODTC standard: GetStatusResponse -> state - # GetStatusResult contains return code info, but state is a direct child of GetStatusResponse get_status_response = decoded.get("GetStatusResponse", {}) state = get_status_response.get("state") if state: self._update_state_from_status(state) return decoded - elif return_code == 2: - # Asynchronous command accepted - set up pending tracking and polling fallback + if return_code == 2: fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future() - result = decoded.get(f"{command}Response", {}).get(f"{command}Result", {}) duration_str = result.get("duration") estimated_remaining_time: Optional[float] = None @@ -854,8 +853,17 @@ def _do_request() -> bytes: ) async def _poll_until_complete() -> None: - if estimated_remaining_time is not None and estimated_remaining_time > 0: - await asyncio.sleep(estimated_remaining_time + POLLING_START_BUFFER) + while True: + pending_ref = self._pending_by_id.get(request_id) + if pending_ref is None or pending_ref.fut.done(): + break + remaining_wait = ( + started_at + (estimated_remaining_time or 0) + POLLING_START_BUFFER - time.time() + ) + if remaining_wait > 0: + await asyncio.sleep(min(remaining_wait, self._poll_interval)) + continue + break while True: pending_ref = self._pending_by_id.get(request_id) if pending_ref is None: @@ -866,7 +874,7 @@ async def _poll_until_complete() -> None: if elapsed >= effective_lifetime: self._complete_pending( request_id, - exception=RuntimeError( + exception=SiLATimeoutError( f"Command {pending_ref.name} timed out (lifetime_of_execution exceeded: {effective_lifetime}s)" ), update_lock_state=False, @@ -890,7 +898,7 @@ async def _poll_until_complete() -> None: else: self._complete_pending( request_id, - exception=RuntimeError( + exception=SiLATimeoutError( "ResponseEvent not received; device reported idle. Possible callback loss (e.g. sleep/network)." ), update_lock_state=False, @@ -899,13 +907,70 @@ async def _poll_until_complete() -> None: await asyncio.sleep(self._poll_interval) asyncio.create_task(_poll_until_complete()) + return (fut, request_id, estimated_remaining_time, started_at) - if return_request_id: - return (fut, request_id) - return await fut + self._handle_return_code(return_code, message, command, request_id) + raise SiLAError(f"Command {command} failed: {return_code} {message}") - else: - # Error return code - self._handle_return_code(return_code, message, command, request_id) - # Should not reach here (handle_return_code raises), but just in case: - raise RuntimeError(f"Command {command} failed: {return_code} {message}") + async def send_command( + self, + command: str, + lock_id: Optional[str] = None, + **kwargs: Any, + ) -> Any: + """Run a SiLA command and return the result (blocking until done). + + Use for any command when you want the decoded result. For async device + commands (e.g. OpenDoor, ExecuteMethod), this awaits completion and + returns the result (or raises). For sync commands (GetStatus, + GetDeviceIdentification), returns the decoded dict immediately. + + Args: + command: Command name. + lock_id: LockId (defaults to None, validated if device is locked). + **kwargs: Additional command parameters. + + Returns: + Decoded response dict (sync) or result after awaiting (async). + + Raises: + SiLAError and subclasses: For validation, return code, or state violations. + """ + result = await self._execute_command(command, lock_id=lock_id, **kwargs) + if isinstance(result, tuple): + return await result[0] + return result + + async def start_command( + self, + command: str, + lock_id: Optional[str] = None, + **kwargs: Any, + ) -> tuple[asyncio.Future[Any], int, Optional[float], float]: + """Start a SiLA command and return a handle (future + request_id, eta, started_at). + + Use for async device commands (OpenDoor, CloseDoor, ExecuteMethod, + Initialize, Reset, LockDevice, UnlockDevice, StopMethod) when you want + a handle to await, poll, or compose with. Do not use for sync-only + commands (GetStatus, GetDeviceIdentification); use send_command instead. + + Args: + command: Command name (must be an async command). + lock_id: LockId (defaults to None, validated if device is locked). + **kwargs: Additional command parameters. + + Returns: + (future, request_id, estimated_remaining_time, started_at) tuple. + Await the future for the result or to propagate exceptions. + + Raises: + ValueError: If the device returned sync (return_code 1); start_command + is for async commands only. + SiLAError and subclasses: For validation, return code, or state violations. + """ + result = await self._execute_command(command, lock_id=lock_id, **kwargs) + if isinstance(result, tuple): + return result + raise ValueError( + "start_command is for async commands only; device returned sync response (return_code 1)" + ) From 3b46ece75ccf8bd62428132cab13903fb374698e Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:13:56 -0800 Subject: [PATCH 12/28] Align ODTC with the standard Thermocycler resource pattern (single entry point, backend implements ThermocyclerBackend) instead of the original custom layout; improves consistency with PyLabRobot and gives a clear recommended API. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename odtc_xml → odtc_model; remove odtc.py; add ODTCThermocycler as preferred resource. Use computed estimated duration (PreMethod 10 min, Method from steps); stop parsing duration from device. - Backend/thermocycler interface updates; pass estimated duration through all execution paths. - README: Recommended Workflows (run by name, round-trip for thermal performance, set_block_temperature); clarify preserve temps when reusing ODTC config (only durations/cycle counts safe to change); trim redundancy. --- pylabrobot/thermocycling/backend.py | 38 +- pylabrobot/thermocycling/chatterbox.py | 4 +- pylabrobot/thermocycling/inheco/README.md | 573 +++++++++++++ pylabrobot/thermocycling/inheco/__init__.py | 36 +- pylabrobot/thermocycling/inheco/dev/README.md | 756 ------------------ pylabrobot/thermocycling/inheco/odtc.py | 639 --------------- .../thermocycling/inheco/odtc_backend.py | 567 +++++++++++-- .../inheco/odtc_backend_tests.py | 476 +++++++---- .../inheco/{odtc_xml.py => odtc_model.py} | 125 ++- .../inheco/odtc_sila_interface.py | 51 +- .../thermocycling/inheco/odtc_thermocycler.py | 117 +++ pylabrobot/thermocycling/opentrons_backend.py | 4 +- .../thermocycling/opentrons_backend_usb.py | 4 +- .../thermo_fisher_thermocycler.py | 1 + pylabrobot/thermocycling/thermocycler.py | 68 +- 15 files changed, 1778 insertions(+), 1681 deletions(-) create mode 100644 pylabrobot/thermocycling/inheco/README.md delete mode 100644 pylabrobot/thermocycling/inheco/dev/README.md delete mode 100644 pylabrobot/thermocycling/inheco/odtc.py rename pylabrobot/thermocycling/inheco/{odtc_xml.py => odtc_model.py} (91%) create mode 100644 pylabrobot/thermocycling/inheco/odtc_thermocycler.py diff --git a/pylabrobot/thermocycling/backend.py b/pylabrobot/thermocycling/backend.py index cf0955364ae..76a36e37ba3 100644 --- a/pylabrobot/thermocycling/backend.py +++ b/pylabrobot/thermocycling/backend.py @@ -41,13 +41,47 @@ async def deactivate_lid(self): """Deactivate thermocycler lid.""" @abstractmethod - async def run_protocol(self, protocol: Protocol, block_max_volume: float): - """Execute thermocycler protocol run. + async def run_protocol( + self, + protocol: Protocol, + block_max_volume: float, + **kwargs, + ): + """Execute thermocycler protocol run (always non-blocking). + + Starts the protocol and returns an execution handle immediately. To block + until completion, await the handle (e.g. await handle.wait()) or use + wait_for_profile_completion() on the thermocycler. Args: protocol: Protocol object containing stages with steps and repeats. block_max_volume: Maximum block volume (µL) for safety. + **kwargs: Backend-specific options (e.g. ODTC accepts config=ODTCConfig). + + Returns: + Execution handle (backend-specific; e.g. MethodExecution for ODTC), or + None for backends that do not return a handle. Caller can await + handle.wait() or use wait_for_profile_completion() to block until done. + """ + + async def run_stored_protocol(self, name: str, wait: bool = False, **kwargs): + """Execute a stored protocol by name (optional; backends that support it override). + + Args: + name: Name of the stored protocol to run. + wait: If False (default), start and return an execution handle. If True, + block until done then return the (completed) handle. + **kwargs: Backend-specific options. + + Returns: + Execution handle (backend-specific). Same as run_protocol. + + Raises: + NotImplementedError: This backend does not support running stored protocols by name. """ + raise NotImplementedError( + "This backend does not support running stored protocols by name." + ) @abstractmethod async def get_block_current_temperature(self) -> List[float]: diff --git a/pylabrobot/thermocycling/chatterbox.py b/pylabrobot/thermocycling/chatterbox.py index 1c45e40752d..2bd070246b0 100644 --- a/pylabrobot/thermocycling/chatterbox.py +++ b/pylabrobot/thermocycling/chatterbox.py @@ -97,7 +97,9 @@ 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..8d01ca68135 --- /dev/null +++ b/pylabrobot/thermocycling/inheco/README.md @@ -0,0 +1,573 @@ +# ODTC (On-Deck Thermocycler) Implementation Guide + +## Overview + +Interface for Inheco ODTC thermocyclers via SiLA (SOAP over HTTP). Supports asynchronous method execution (blocking and non-blocking), round-trip protocol conversion (ODTC XML ↔ PyLabRobot `Protocol` with lossless ODTC parameters), parallel commands (e.g. read temperatures during run), and DataEvent collection. + +**New users:** Start with **Connection and Setup**, then **Recommended Workflows** (run by name, round-trip for thermal performance, set block/lid temp). Use **Running Commands** and **Getting Protocols** for async handles and device introspection; **XML to Protocol + Config** for conversion detail. + +## Architecture + +- **`ODTCSiLAInterface`** (`odtc_sila_interface.py`) — SiLA SOAP layer: `send_command` / `start_command`, parallelism rules, state machine (Startup → Standby → Idle → Busy), lockId, DataEvents. +- **`ODTCBackend`** (`odtc_backend.py`) — Implements `ThermocyclerBackend`: method execution, protocol conversion, upload/download, status. +- **`ODTCThermocycler`** (`odtc_thermocycler.py`) — Preferred resource: takes `odtc_ip`, `variant` (96/384 or 960000/384000), uses ODTC dimensions (147×298×130 mm). Alternative: generic `Thermocycler` with `ODTCBackend` for custom sizing. +- **`odtc_model.py`** — MethodSet XML (de)serialization, `ODTCMethod` ↔ `Protocol` conversion, `ODTCConfig` for ODTC-specific parameters. + +## Protocol vs Method: Naming Conventions + +Understanding the distinction between **Protocol** and **Method** is crucial for using the ODTC API correctly: + +### Protocol (PyLabRobot) +- **`Protocol`**: PyLabRobot's generic protocol object (from `pylabrobot.thermocycling.standard`) + - Contains `Stage` objects with `Step` objects + - Defines temperatures, hold times, and cycle repeats + - Hardware-agnostic (works with any thermocycler) + - Example: `Protocol(stages=[Stage(steps=[Step(temperature=95.0, hold_seconds=30.0)])])` + +### Method (ODTC Device) +- **`ODTCMethod`** or **`ODTCPreMethod`**: ODTC-specific XML-defined method stored on the device. In ODTC/SiLA, a **method** is the device's runnable protocol (thermocycling program). + - Contains ODTC-specific parameters (overshoot, slopes, PID settings) + - Stored on the device with a unique **method name** (string identifier; SiLA: `methodName`) + - Example: `"PCR_30cycles"` is a method name stored on the device + +### Method Name (String) +- **Method name**: A string identifier for a method stored on the device + - Examples: `"PCR_30cycles"`, `"my_pcr"`, `"plr_currentProtocol"` + - Used to reference methods when executing: `await tc.run_protocol(method_name="PCR_30cycles")` + - Can be a Method or PreMethod name (both are stored on the device) + +### Key API + +- **Resource:** `tc.run_protocol(protocol, block_max_volume)` — in-memory (upload + execute); `tc.run_stored_protocol(name)` — by name (ODTC only). +- **Backend:** `tc.backend.list_protocols()`, `get_protocol(name)` (runnable methods only; premethods → `None`), `upload_protocol(...)`, `set_block_temperature(...)` (PreMethod), `get_default_config()`, `get_constraints()`, `execute_method(method_name)` (low-level). + +## Connection and Setup + +**Preferred: ODTCThermocycler** (owns dimensions and backend): + +```python +from pylabrobot.resources import Coordinate +from pylabrobot.thermocycling.inheco import ODTCThermocycler + +tc = ODTCThermocycler( + name="odtc", + odtc_ip="192.168.1.100", + variant=96, # or 384; or 960000 / 384000 + child_location=Coordinate(0, 0, 0), +) +await tc.setup() # HTTP event receiver + Reset + Initialize → idle +``` + +**Alternative:** Generic `Thermocycler` with `ODTCBackend` (e.g. custom dimensions): + +```python +from pylabrobot.thermocycling.inheco import ODTCBackend +from pylabrobot.thermocycling.thermocycler import Thermocycler + +backend = ODTCBackend(odtc_ip="192.168.1.100", variant=960000) +tc = Thermocycler(name="odtc", size_x=147, size_y=298, size_z=130, backend=backend, child_location=Coordinate(0, 0, 0)) +await tc.setup() +``` + +**Estimated duration:** The device does not return duration in the async response. We compute it: PreMethod = 10 min; Method = from steps (ramp + plateau + overshoot, with loops). This estimate is used for `handle.estimated_remaining_time`, when to start polling, and a tighter timeout cap. + +### Cleanup + +```python +await tc.stop() # Closes HTTP server and connections +``` + +## Recommended Workflows + +Use these patterns for the best balance of simplicity and thermal performance. + +### 1. Run stored protocol by name + +**Use when:** The protocol (method) is already on the device. Single instrument call; no upload. + +```python +# List names: methods and premethods +names = await tc.backend.list_protocols() # e.g. ["PCR_30cycles", "my_pcr", ...] + +# Run by name (blocking or non-blocking) +await tc.run_stored_protocol("PCR_30cycles") +# Or with handle: execution = await tc.run_stored_protocol("PCR_30cycles", wait=False); await execution +``` + +### 2. Get → modify → upload with config → run (round-trip for thermal performance) + +**Use when:** You want to change an existing device protocol (e.g. cycle count) while keeping equivalent thermal performance. Preserving `ODTCConfig` keeps overshoot and other ODTC parameters from the original. + +```python +# Get runnable protocol from device (returns None for premethods) +stored = await tc.backend.get_protocol("PCR_30cycles") +if not stored: + raise ValueError("Protocol not found") +protocol, config = stored.protocol, stored.config + +# Modify only durations or cycle counts; keep temperatures unchanged +# ODTCConfig is tuned for the original temperature setpoints—change temps and tuning may be wrong +protocol.stages[0].repeats = 35 # Safe: cycle count +# protocol.stages[0].steps[0].duration = 120 # Safe: hold duration +# Do NOT change plateau_temperature / setpoints when reusing config + +# Upload with same config so overshoot/ODTC params are preserved +await tc.backend.upload_protocol(protocol, name="PCR_35cycles", config=config) + +# Run by name +await tc.run_stored_protocol("PCR_35cycles") +``` + +**Why:** New protocols created without a config use default overshoot parameters and can heat more slowly. Using `get_protocol` + `upload_protocol(..., config=config)` preserves the device’s thermal tuning. + +**Important:** When reusing an ODTC-specific config, **preserve temperature setpoints** (plateau temperatures, lid, etc.). The config's overshoot and ramp parameters are calibrated for those temperatures. Only **durations** (hold times) and **cycle/repeat counts** are safe to change—they don't affect thermal tuning. Changing target temperatures while keeping the same config can give suboptimal or inconsistent thermal performance. + +### 3. Set block and lid temperature (PreMethod equivalent) + +**Use when:** You want to hold the block (and lid) at a set temperature without running a full cycling method. ODTC implements this by uploading and running a PreMethod. + +```python +# Block to 95°C with default lid (110°C for 96-well, 115°C for 384-well) +await tc.set_block_temperature([95.0]) + +# Custom lid temperature +await tc.set_block_temperature([37.0], lid_temperature=110.0) + +# Non-blocking +execution = await tc.set_block_temperature([95.0], lid_temperature=110.0, wait=False) +await execution +``` + +ODTC has no direct SetBlockTemperature command; `set_block_temperature()` creates and runs a PreMethod internally. Estimated duration for this path is 10 minutes (see Connection and Setup). + +## Running Commands + +### Synchronous Commands + +Some commands are synchronous and return immediately: + +```python +# Get device status +status = await tc.get_status() # Returns "idle", "busy", "standby", etc. + +# Get device identification +device_info = await tc.get_device_identification() +``` + +### Asynchronous Commands + +Most ODTC commands are asynchronous and support both blocking and non-blocking execution: + +#### Blocking Execution (Default) + +```python +# Block until command completes +await tc.open_door() # Returns None when complete +await tc.close_door() +await tc.initialize() +await tc.reset() +``` + +#### Non-Blocking Execution with Handle + +```python +# Start command and get execution handle +door_opening = await tc.open_door(wait=False) +# Returns CommandExecution handle immediately + +# Do other work while command runs +temps = await tc.read_temperatures() # Can run in parallel if allowed + +# Wait for completion +await door_opening # Await the handle directly +# OR +await door_opening.wait() # Explicit wait method +``` + +#### CommandExecution Handle + +- **`request_id`**, **`command_name`**, **`estimated_remaining_time`** (seconds; from our computed estimate when available) +- **Awaitable** (`await handle`) and **`wait()`** +- **`get_data_events()`** — DataEvents for this execution + +```python +# Non-blocking door operation +door_opening = await tc.open_door(wait=False) + +# Get DataEvents for this execution +events = await door_opening.get_data_events() + +# Wait for completion +await door_opening +``` + +### Method Execution + +- **Blocking:** `await tc.run_stored_protocol("PCR_30cycles")` or `await tc.run_protocol(protocol, block_max_volume=50.0)` (upload + execute). +- **Non-blocking:** `execution = await tc.run_stored_protocol("PCR_30cycles", wait=False)`; then `await execution` or `await execution.wait()` or `await tc.wait_for_method_completion()`. +- While a method runs you can call `read_temperatures()`, `open_door(wait=False)`, etc. (parallel where allowed). + +#### MethodExecution Handle + +Extends `CommandExecution` with **`method_name`**, **`is_running()`** (device busy state), **`stop()`** (StopMethod). + +```python +execution = await tc.run_stored_protocol("PCR_30cycles", wait=False) + +# Check status +if await execution.is_running(): + print(f"Method {execution.method_name} still running (ID: {execution.request_id})") + +# Get DataEvents for this execution +events = await execution.get_data_events() + +# Wait for completion +await execution +``` + +### State Checking + +```python +# Check if method is running +is_running = await tc.is_method_running() # Returns True if state is "busy" + +# Wait for method completion with polling +await tc.wait_for_method_completion( + poll_interval=5.0, # Check every 5 seconds + timeout=3600.0 # Timeout after 1 hour +) +``` + +### Temperature Control + +See **Recommended Workflows → Set block and lid temperature** for the main usage. Summary: `await tc.set_block_temperature([temp])` or with `lid_temperature=..., wait=False`. ODTC implements this via a PreMethod (no direct SetBlockTemperature command); default lid is 110°C (96-well) or 115°C (384-well). + +### Parallel Operations + +Per ODTC SiLA spec, certain commands can run in parallel with `ExecuteMethod`: + +- ✅ `ReadActualTemperature` - Read temperatures during execution +- ✅ `OpenDoor` / `CloseDoor` - Door operations +- ✅ `StopMethod` - Stop current method +- ❌ `SetParameters` / `GetParameters` - Sequential +- ❌ `GetLastData` - Sequential +- ❌ Another `ExecuteMethod` - Only one method at a time + +```python +# Start method +execution = await tc.run_stored_protocol("PCR_30cycles", wait=False) + +# These can run in parallel: +temps = await tc.read_temperatures() +door_opening = await tc.open_door(wait=False) + +# Wait for door to complete +await door_opening + +# These will queue/wait: +method2 = await tc.run_protocol(method_name="PCR_40cycles", wait=False) # Waits for method1 +``` + +### CommandExecution vs MethodExecution + +- **`CommandExecution`**: Base class for all async commands (door operations, initialize, reset, etc.) +- **`MethodExecution`**: Subclass of `CommandExecution` for method execution with additional features: + - `is_running()`: Checks if device is in "busy" state + - `stop()`: Stops the currently running method + - `method_name`: More semantic than `command_name` for methods + +```python +# CommandExecution example +door_opening = await tc.open_door(wait=False) +await door_opening # Wait for door to open + +# MethodExecution example (has additional features) +method_exec = await tc.run_stored_protocol("PCR_30cycles", wait=False) +if await method_exec.is_running(): + print(f"Method {method_exec.method_name} is running") + await method_exec.stop() # Stop the method +``` + +## Getting Protocols from Device + +### List All Protocol Names (Recommended) + +```python +# List all protocol names (both Methods and PreMethods) +protocol_names = await tc.backend.list_protocols() +# Returns: ["PCR_30cycles", "my_pcr", "PRE25", "plr_currentProtocol", ...] + +for name in protocol_names: + print(f"Protocol: {name}") +``` + +### Get Runnable Protocol by Name + +```python +# Get a runnable protocol by name (returns StoredProtocol or None for premethods) +stored = await tc.backend.get_protocol("PCR_30cycles") +if stored: + print(f"Protocol: {stored.name}") + # stored.protocol: Protocol; stored.config: ODTCConfig +``` + +### Get Full MethodSet (Advanced) + +```python +# Download all methods and premethods from device +method_set = await tc.backend.get_method_set() # Returns ODTCMethodSet + +# Access methods +for method in method_set.methods: + print(f"Method: {method.name}, Steps: {len(method.steps)}") + +for premethod in method_set.premethods: + print(f"PreMethod: {premethod.name}") +``` + +### Inspect Stored Protocol + +```python +# Get runnable protocol from device (StoredProtocol has protocol + config) +stored = await tc.backend.get_protocol("PCR_30cycles") +if stored: + # stored.protocol: Generic PyLabRobot Protocol (stages, steps, temperatures, times) + # stored.config: ODTCConfig preserving all ODTC-specific parameters + await tc.run_protocol(stored.protocol, block_max_volume=50.0, config=stored.config) +``` + +## Running Protocols (reference) + +- **By name:** See **Recommended Workflows → Run stored protocol by name**. `await tc.run_stored_protocol(name)` or `wait=False` for a handle. +- **Round-trip (modify with thermal performance):** See **Recommended Workflows → Get → modify → upload with config → run**. +- **Block + lid temp:** See **Recommended Workflows → Set block and lid temperature**. +- **In-memory (new protocol):** `await tc.run_protocol(protocol, block_max_volume=50.0)` (upload + execute). New protocols use default overshoot; for best thermal performance, prefer round-trip from an existing device protocol. +- **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")`. + +## XML to Protocol + Config Conversion + +### Lossless Round-Trip Conversion + +The conversion system ensures **lossless round-trip** conversion between ODTC XML format and PyLabRobot's generic `Protocol` format. This is achieved through the `ODTCConfig` companion object that preserves all ODTC-specific parameters. + +### How It Works + +#### 1. ODTC → Protocol + Config + +```python +from pylabrobot.thermocycling.inheco.odtc_model import odtc_method_to_protocol + +# Convert ODTC method to Protocol + Config +protocol, config = odtc_method_to_protocol(odtc_method) +``` + +**What gets preserved in `ODTCConfig`:** + +- **Method-level parameters:** + - `name`, `creator`, `description`, `datetime` + - `fluid_quantity`, `variant`, `plate_type` + - `lid_temperature`, `start_lid_temperature` + - `post_heating` + - `pid_set` (PID controller parameters) + +- **Per-step parameters** (stored in `config.step_settings[step_index]`): + - `slope` - Temperature ramp rate (°C/s) + - `overshoot_slope1` - First overshoot ramp rate + - `overshoot_temperature` - Overshoot target temperature + - `overshoot_time` - Overshoot hold time + - `overshoot_slope2` - Second overshoot ramp rate + - `lid_temp` - Lid temperature for this step + - `pid_number` - PID controller to use + +**What goes into `Protocol`:** + +- Temperature targets (from `plateau_temperature`) +- Hold times (from `plateau_time`) +- Stage structure (from loop analysis) +- Repeat counts (from `loop_number`) + +#### 2. Protocol + Config → ODTC + +```python +from pylabrobot.thermocycling.inheco.odtc_model import protocol_to_odtc_method + +# Convert back to ODTC method (lossless if config preserved) +odtc_method = protocol_to_odtc_method(protocol, config=config) +``` + +The conversion uses: +- `Protocol` for temperature/time structure +- `ODTCConfig.step_settings` for per-step overtemp parameters +- `ODTCConfig` defaults for method-level parameters + +### Overtemp/Overshoot Parameter Preservation + +**Overtemp parameters** (overshoot settings) are ODTC-specific features that allow temperature overshooting for faster heating and improved thermal performance: + +- **`overshoot_temperature`**: Target temperature to overshoot to +- **`overshoot_time`**: How long to hold at overshoot temperature +- **`overshoot_slope1`**: Ramp rate to overshoot temperature +- **`overshoot_slope2`**: Ramp rate back to target temperature + +These parameters are **not part of the generic Protocol** (which only has target temperature and hold time), so they are preserved in `ODTCConfig.step_settings`. + +**Why preservation matters:** + +When converting existing ODTC XML protocols to PyLabRobot `Protocol` format, **preserving overshoot parameters is critical for maintaining equivalent thermal performance**. Without these parameters, the converted protocol may have different heating characteristics, potentially affecting PCR efficiency or other temperature-sensitive reactions. + +**Current behavior:** +- ✅ **Preserved from XML**: When converting ODTC XML → Protocol+Config, all overshoot parameters are captured in `ODTCConfig.step_settings` +- ✅ **Restored to XML**: When converting Protocol+Config → ODTC XML, overshoot parameters are restored from `ODTCConfig.step_settings` +- ⚠️ **Not generated**: When creating new protocols in PyLabRobot, overshoot parameters default to minimal values (0.0 for temperature/time, 0.1 for slopes) + +**Future work:** +- 🔮 **Automatic derivation**: Future enhancements will automatically derive optimal overshoot parameters for PyLabRobot-created protocols based on: + - Temperature transitions (large jumps benefit more from overshoot) + - Hardware constraints (variant-specific limits) + - Thermal characteristics (fluid quantity, plate type) +- 🔮 **Performance optimization**: This will enable PyLabRobot-created protocols to achieve equivalent or improved thermal performance compared to manually-tuned ODTC protocols + +**Example of preservation:** + +```python +# When converting ODTC → Protocol + Config +protocol, config = odtc_method_to_protocol(odtc_method) + +# Overtemp params stored per step (preserved from original XML) +step_0_overtemp = config.step_settings[0] +print(step_0_overtemp.overshoot_temperature) # e.g., 100.0 (from original XML) +print(step_0_overtemp.overshoot_time) # e.g., 5.0 (from original XML) + +# When converting back Protocol + Config → ODTC +odtc_method_restored = protocol_to_odtc_method(protocol, config=config) + +# Overtemp params restored from config.step_settings +# This ensures equivalent thermal performance to original +assert odtc_method_restored.steps[0].overshoot_temperature == 100.0 +``` + +**Important:** Always preserve the `ODTCConfig` when modifying protocols converted from ODTC XML to maintain equivalent thermal performance. If you create a new protocol without a config, overshoot parameters will use defaults which may result in slower heating. + +### Example: Round-Trip Conversion + +```python +from pylabrobot.thermocycling.inheco.odtc_model import ( + odtc_method_to_protocol, + protocol_to_odtc_method, + method_set_to_xml, + parse_method_set +) + +# 1. Get runnable protocol from device +stored = await tc.backend.get_protocol("PCR_30cycles") +if not stored: + raise ValueError("Protocol not found") +protocol, config = stored.protocol, stored.config + +# 2. Modify protocol (generic changes) +protocol.stages[0].repeats = 35 # Change cycle count + +# 3. Upload modified protocol (preserves all ODTC-specific params via config) +await tc.backend.upload_protocol(protocol, name="PCR_35cycles", config=config) + +# 4. Execute +await tc.run_stored_protocol("PCR_35cycles") +``` + +### Round-Trip from Device XML + +```python +# Full round-trip: Device Method → Protocol+Config → Device Method + +# 1. Get from device +stored = await tc.backend.get_protocol("PCR_30cycles") +if not stored: + raise ValueError("Protocol not found") +protocol, config = stored.protocol, stored.config + +# 2. Upload back to device (preserves all ODTC-specific params via config) +await tc.backend.upload_protocol(protocol, name="PCR_30cycles_restored", config=config) + +# 3. Verify round-trip by comparing protocols +stored_restored = await tc.backend.get_protocol("PCR_30cycles_restored") +# Protocols should be equivalent (XML formatting may differ, but content should match) +``` + +## DataEvent Collection + +During method execution, the ODTC sends `DataEvent` messages containing experimental data. These are automatically collected: + +```python +# Start method +execution = await tc.run_stored_protocol("PCR_30cycles", wait=False) + +# Get DataEvents for this execution +events = await execution.get_data_events() +# Returns: List of DataEvent objects + +# Get all collected events (backend-level) +all_events = await tc.backend.get_data_events() +# Returns: {request_id1: [...], request_id2: [...]} +``` + +**Note:** DataEvent parsing and progress tracking are planned for future implementation. Currently, raw event payloads are stored for later analysis. + +## Error Handling + +The implementation handles SiLA return codes and state transitions: + +- **Return code 1**: Synchronous success (GetStatus, GetDeviceIdentification) +- **Return code 2**: Asynchronous command accepted (ExecuteMethod, OpenDoor, etc.) +- **Return code 3**: Asynchronous command completed successfully (in ResponseEvent) +- **Return code 4**: Device busy (command rejected due to parallelism) +- **Return code 5**: LockId mismatch +- **Return code 6**: Invalid/duplicate requestId +- **Return code 9**: Command not allowed in current state + +State transitions are tracked automatically: +- `startup` → `standby` (via Reset) +- `standby` → `idle` (via Initialize) +- `idle` → `busy` (when async command starts) +- `busy` → `idle` (when all commands complete) + +## Best Practices + +1. **Always call `setup()`** before using the device +2. **Use `wait=False`** for long-running methods to enable parallel operations +3. **Check state** with `is_method_running()` before starting new methods +4. **Preserve `ODTCConfig`** when converting protocols to maintain ODTC-specific parameters (especially overshoot parameters for equivalent thermal performance) +5. **Handle timeouts** when waiting for method completion +6. **Clean up** with `stop()` when done + +### Protocol Conversion Best Practices + +- **When converting from ODTC XML**: Always preserve the returned `ODTCConfig` alongside the `Protocol` to maintain overshoot parameters and ensure equivalent thermal performance +- **When modifying converted protocols**: Keep the original `ODTCConfig` and only modify the `Protocol` structure (temperatures, times, repeats) +- **When creating new protocols**: Be aware that overshoot parameters will use defaults until automatic derivation is implemented (future work) + +## Complete Example + +```python +from pylabrobot.resources import Coordinate +from pylabrobot.thermocycling.inheco import ODTCThermocycler +from pylabrobot.thermocycling.standard import Protocol, Stage, Step + +tc = ODTCThermocycler(name="odtc", odtc_ip="192.168.1.100", variant=96, child_location=Coordinate(0, 0, 0)) +await tc.setup() + +# Get protocol from device, modify, run +stored = await tc.backend.get_protocol("PCR_30cycles") +if stored: + protocol, config = stored.protocol, stored.config + protocol.stages[0].repeats = 35 + await tc.backend.upload_protocol(protocol, name="PCR_35cycles", config=config) + execution = await tc.run_stored_protocol("PCR_35cycles", wait=False) + await execution + +# New protocol and run +protocol = Protocol(stages=[Stage(steps=[Step(95.0, 30.0), Step(60.0, 30.0), Step(72.0, 60.0)], repeats=30)]) +await tc.run_protocol(protocol, 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 11e536538dc..386a3a54827 100644 --- a/pylabrobot/thermocycling/inheco/__init__.py +++ b/pylabrobot/thermocycling/inheco/__init__.py @@ -1,11 +1,41 @@ -"""Inheco ODTC thermocycler implementation.""" +"""Inheco ODTC thermocycler implementation. + +Preferred: use ODTCThermocycler (owns connection params and dimensions): + + tc = ODTCThermocycler( + name="odtc1", + odtc_ip="192.168.1.100", + variant=384, + child_location=Coordinate.zero(), + ) + +Alternative: use generic Thermocycler with ODTCBackend (e.g. for custom backend): + + backend = ODTCBackend(odtc_ip="192.168.1.100", variant=384) + tc = Thermocycler( + name="odtc1", + size_x=147, + size_y=298, + size_z=130, + backend=backend, + child_location=..., + ) + +Variant accepts 96, 384 or device codes (960000, 384000). Use tc.run_protocol(protocol, +block_max_volume) for in-memory protocols; tc.run_stored_protocol("my_pcr") for +stored-by-name (ODTC only). +""" -from .odtc import InhecoODTC from .odtc_backend import CommandExecution, MethodExecution, ODTCBackend +from .odtc_model import ODTC_DIMENSIONS, StoredProtocol, normalize_variant +from .odtc_thermocycler import ODTCThermocycler __all__ = [ "CommandExecution", - "InhecoODTC", "MethodExecution", "ODTCBackend", + "ODTC_DIMENSIONS", + "ODTCThermocycler", + "StoredProtocol", + "normalize_variant", ] diff --git a/pylabrobot/thermocycling/inheco/dev/README.md b/pylabrobot/thermocycling/inheco/dev/README.md deleted file mode 100644 index 76bf5bac9f3..00000000000 --- a/pylabrobot/thermocycling/inheco/dev/README.md +++ /dev/null @@ -1,756 +0,0 @@ -# ODTC (On-Deck Thermocycler) Implementation Guide - -## Overview - -The ODTC implementation provides a complete interface for controlling Inheco ODTC thermocyclers via the SiLA (Standard for Laboratory Automation) protocol. It supports: - -- **Asynchronous method execution** with blocking and non-blocking modes -- **Round-trip protocol conversion** between ODTC XML format and PyLabRobot's generic `Protocol` format -- **Lossless parameter preservation** including ODTC-specific overtemp/overshoot parameters -- **Parallel command execution** (e.g., reading temperatures during method execution) -- **State tracking and DataEvent collection** for monitoring method progress - -## Architecture - -### Components - -1. **`ODTCSiLAInterface`** (`odtc_sila_interface.py`) - - Low-level SiLA communication (SOAP over HTTP) - - Exposes **`send_command`** (run and return result) and **`start_command`** (start and return handle) for consistency with automation patterns; backend uses these for `wait=True` vs `wait=False` - - Handles parallelism rules, state management, and lockId validation - - Tracks pending async commands and collects DataEvents - - Manages state transitions (Startup → Standby → Idle → Busy) - -2. **`ODTCBackend`** (`odtc_backend.py`) - - High-level device control interface - - Implements `ThermocyclerBackend` interface - - Provides method execution, status checking, and data retrieval - - Handles protocol conversion and method upload/download - -3. **`InhecoODTC`** (`odtc.py`) - - Public-facing resource class - - Supports both 96-well and 384-well formats via `model` parameter - - Exposes backend methods with PyLabRobot resource interface - -4. **`odtc_xml.py`** - - XML serialization/deserialization for ODTC MethodSet format - - Conversion between `ODTCMethod` and generic `Protocol` - - `ODTCConfig` class for preserving ODTC-specific parameters - -## Protocol vs Method: Naming Conventions - -Understanding the distinction between **Protocol** and **Method** is crucial for using the ODTC API correctly: - -### Protocol (PyLabRobot) -- **`Protocol`**: PyLabRobot's generic protocol object (from `pylabrobot.thermocycling.standard`) - - Contains `Stage` objects with `Step` objects - - Defines temperatures, hold times, and cycle repeats - - Hardware-agnostic (works with any thermocycler) - - Example: `Protocol(stages=[Stage(steps=[Step(temperature=95.0, hold_seconds=30.0)])])` - -### Method (ODTC Device) -- **`ODTCMethod`** or **`ODTCPreMethod`**: ODTC-specific XML-defined method stored on the device. In ODTC/SiLA, a **method** is the device's runnable protocol (thermocycling program). - - Contains ODTC-specific parameters (overshoot, slopes, PID settings) - - Stored on the device with a unique **method name** (string identifier; SiLA: `methodName`) - - Example: `"PCR_30cycles"` is a method name stored on the device - -### Method Name (String) -- **Method name**: A string identifier for a method stored on the device - - Examples: `"PCR_30cycles"`, `"my_pcr"`, `"plr_currentProtocol"` - - Used to reference methods when executing: `await tc.run_protocol(method_name="PCR_30cycles")` - - Can be a Method or PreMethod name (both are stored on the device) - -### Key API Methods - -**High-level methods (recommended):** -- `upload_protocol(protocol, name="method_name")` - Upload a `Protocol` to device as a method -- `run_protocol(protocol=..., method_name=...)` - Upload and run a `Protocol`, or run existing method by name -- `list_methods()` - List all method names (both Methods and PreMethods) on device; returns list of method name strings -- `get_method(name)` - Get `ODTCMethod` or `ODTCPreMethod` by name from device -- `set_mount_temperature(temperature)` - Set mount temperature (creates PreMethod internally) - -**Lower-level methods (for advanced use):** -- `get_method_set()` - Get full `ODTCMethodSet` (all methods and premethods) -- `backend.get_method(name)` / `backend.list_methods()` - Same as resource; backend uses `get_method` and `list_methods` (normalized with resource) -- `backend.execute_method(method_name)` - Execute method by name (backend-level) - -### Workflow Examples - -**Creating and running a new protocol:** -```python -# 1. Create Protocol (PyLabRobot object) -protocol = Protocol(stages=[Stage(steps=[Step(temperature=95.0, hold_seconds=30.0)])]) - -# 2. Upload Protocol to device as a method (with method name "my_pcr") -await tc.upload_protocol(protocol, name="my_pcr") - -# 3. Run the method by name -await tc.run_protocol(method_name="my_pcr") - -# OR: Upload and run in one call -await tc.run_protocol(protocol=protocol, method_name="my_pcr") -``` - -**Using existing methods on device:** -```python -# 1. List all method names on device -method_names = await tc.list_methods() # Returns: ["PCR_30cycles", "my_pcr", ...] - -# 2. Get method object by name -method = await tc.get_method("PCR_30cycles") # Returns ODTCMethod or ODTCPreMethod - -# 3. Run existing method by name -await tc.run_protocol(method_name="PCR_30cycles") -``` - -**Converting between Protocol and Method:** -```python -# Get method from device → Convert to Protocol -method = await tc.get_method("PCR_30cycles") -protocol, config = odtc_method_to_protocol(method) - -# Modify Protocol → Convert back to Method → Upload -protocol.stages[0].repeats = 35 -method_modified = protocol_to_odtc_method(protocol, config=config) -await tc.upload_protocol(protocol, name="PCR_35cycles") -``` - -## Connection and Setup - -### Basic Connection - -```python -from pylabrobot.thermocycling.inheco import InhecoODTC, ODTCBackend - -# Create thermocycler instance (96-well format) -tc = InhecoODTC( - name="odtc", - backend=ODTCBackend(odtc_ip="192.168.1.100"), - model="96" # Use "384" for 384-well format -) - -# Setup establishes HTTP event receiver and initializes device -await tc.setup() -# Device transitions: Startup → Standby → Idle -``` - -The `setup()` method prepares the connection and brings the device to idle: -1. Starts HTTP server for receiving SiLA events (ResponseEvent, StatusEvent, DataEvent) -2. Calls SiLA `Reset()` to register event receiver URI and move to `standby` -3. Calls SiLA `Initialize()` to move to `idle` (ready for commands) - -Use `setup()` for lifecycle/connection; `initialize()` is the SiLA command (standby→idle) and is called by `setup()` when needed. - -### Cleanup - -```python -await tc.stop() # Closes HTTP server and connections -``` - -## Running Commands - -### Synchronous Commands - -Some commands are synchronous and return immediately: - -```python -# Get device status -status = await tc.get_status() # Returns "idle", "busy", "standby", etc. - -# Get device identification -device_info = await tc.get_device_identification() -``` - -### Asynchronous Commands - -Most ODTC commands are asynchronous and support both blocking and non-blocking execution: - -#### Blocking Execution (Default) - -```python -# Block until command completes -await tc.open_door() # Returns None when complete -await tc.close_door() -await tc.initialize() -await tc.reset() -``` - -#### Non-Blocking Execution with Handle - -```python -# Start command and get execution handle -door_opening = await tc.open_door(wait=False) -# Returns CommandExecution handle immediately - -# Do other work while command runs -temps = await tc.read_temperatures() # Can run in parallel if allowed - -# Wait for completion -await door_opening # Await the handle directly -# OR -await door_opening.wait() # Explicit wait method -``` - -#### CommandExecution Handle - -The `CommandExecution` handle (sometimes called a job or task handle in other automation systems) provides: - -- **`request_id`**: SiLA request ID for tracking DataEvents -- **`command_name`**: Name of the executing command -- **Awaitable interface**: Can be awaited like `asyncio.Task` -- **`wait()`**: Explicit wait for completion -- **`get_data_events()`**: Get DataEvents for this command execution - -```python -# Non-blocking door operation -door_opening = await tc.open_door(wait=False) - -# Get DataEvents for this execution -events = await door_opening.get_data_events() - -# Wait for completion -await door_opening -``` - -### Asynchronous Method Execution - -The ODTC supports both blocking and non-blocking method execution using `run_protocol()`: - -#### Blocking Execution (Default) - -```python -# Run existing method - blocks until complete -await tc.run_protocol(method_name="PCR_30cycles") -# Returns None when complete - -# Upload and run Protocol - blocks until complete -protocol = Protocol(stages=[...]) -await tc.run_protocol(protocol=protocol, method_name="my_pcr") -``` - -#### Non-Blocking Execution with Handle - -```python -# Start method and get execution handle -execution = await tc.run_protocol(method_name="PCR_30cycles", wait=False) -# Returns MethodExecution handle immediately - -# Do parallel operations while method runs -temps = await tc.read_temperatures() # Allowed in parallel! -door_opening = await tc.open_door(wait=False) # Allowed in parallel! - -# Wait for completion (multiple options) -await execution # Await the handle directly -# OR -await execution.wait() # Explicit wait method -# OR -await tc.wait_for_method_completion() # Poll-based wait -``` - -#### MethodExecution Handle - -The `MethodExecution` handle extends `CommandExecution` with method-specific features: - -- **All `CommandExecution` features**: `request_id`, `command_name`, awaitable interface, `wait()`, `get_data_events()` -- **`method_name`**: Name of executing method (more semantic than `command_name`) -- **`is_running()`**: Check if method is still running (checks device busy state) -- **`stop()`**: Stop the currently running method - -```python -execution = await tc.run_protocol(method_name="PCR_30cycles", wait=False) - -# Check status -if await execution.is_running(): - print(f"Method {execution.method_name} still running (ID: {execution.request_id})") - -# Get DataEvents for this execution -events = await execution.get_data_events() - -# Wait for completion -await execution -``` - -### State Checking - -```python -# Check if method is running -is_running = await tc.is_method_running() # Returns True if state is "busy" - -# Wait for method completion with polling -await tc.wait_for_method_completion( - poll_interval=5.0, # Check every 5 seconds - timeout=3600.0 # Timeout after 1 hour -) -``` - -### Temperature Control - -The ODTC supports direct temperature control via `set_mount_temperature()`: - -```python -# Set mount (block) temperature - blocking -await tc.set_mount_temperature(95.0) # Sets mount to 95°C with default lid temp - -# Set mount temperature with custom lid temperature -await tc.set_mount_temperature(95.0, lid_temperature=110.0) - -# Non-blocking temperature control -execution = await tc.set_mount_temperature(37.0, wait=False) -# Do other work... -await execution # Wait when ready -``` - -**Note**: `set_mount_temperature()` uses PreMethods internally to achieve constant temperature holds. It automatically stops any running method, waits for idle state, uploads a PreMethod, and executes it. The default lid temperature is hardware-defined (110°C for 96-well, 115°C for 384-well). - -### Parallel Operations - -Per ODTC SiLA spec, certain commands can run in parallel with `ExecuteMethod`: - -- ✅ `ReadActualTemperature` - Read temperatures during execution -- ✅ `OpenDoor` / `CloseDoor` - Door operations -- ✅ `StopMethod` - Stop current method -- ❌ `SetParameters` / `GetParameters` - Sequential -- ❌ `GetLastData` - Sequential -- ❌ Another `ExecuteMethod` - Only one method at a time - -```python -# Start method -execution = await tc.run_protocol(method_name="PCR_30cycles", wait=False) - -# These can run in parallel: -temps = await tc.read_temperatures() -door_opening = await tc.open_door(wait=False) - -# Wait for door to complete -await door_opening - -# These will queue/wait: -method2 = await tc.run_protocol(method_name="PCR_40cycles", wait=False) # Waits for method1 -``` - -### CommandExecution vs MethodExecution - -- **`CommandExecution`**: Base class for all async commands (door operations, initialize, reset, etc.) -- **`MethodExecution`**: Subclass of `CommandExecution` for method execution with additional features: - - `is_running()`: Checks if device is in "busy" state - - `stop()`: Stops the currently running method - - `method_name`: More semantic than `command_name` for methods - -```python -# CommandExecution example -door_opening = await tc.open_door(wait=False) -await door_opening # Wait for door to open - -# MethodExecution example (has additional features) -method_exec = await tc.run_protocol(method_name="PCR_30cycles", wait=False) -if await method_exec.is_running(): - print(f"Method {method_exec.method_name} is running") - await method_exec.stop() # Stop the method -``` - -## Getting Methods from Device - -### List All Method Names (Recommended) - -```python -# List all method names (both Methods and PreMethods) -method_names = await tc.list_methods() -# Returns: ["PCR_30cycles", "my_pcr", "PRE25", "plr_currentProtocol", ...] - -for name in method_names: - print(f"Method: {name}") -``` - -### Get Specific Method by Name - -```python -# Get a specific method (searches both Methods and PreMethods) -method = await tc.get_method("PCR_30cycles") -if method: - print(f"Found method: {method.name}") - # method is either ODTCMethod or ODTCPreMethod -``` - -### Get Full MethodSet (Advanced) - -```python -# Download all methods and premethods from device -method_set = await tc.get_method_set() # Returns ODTCMethodSet - -# Access methods -for method in method_set.methods: - print(f"Method: {method.name}, Steps: {len(method.steps)}") - -for premethod in method_set.premethods: - print(f"PreMethod: {premethod.name}") -``` - -### Convert Method to Protocol + Config - -```python -from pylabrobot.thermocycling.inheco.odtc_xml import odtc_method_to_protocol - -# Get method from device -method = await tc.get_method("PCR_30cycles") -if not method: - raise ValueError("Method not found") - -# Convert to Protocol + ODTCConfig (lossless) -protocol, config = odtc_method_to_protocol(method) - -# protocol: Generic PyLabRobot Protocol (stages, steps, temperatures, times) -# config: ODTCConfig preserving all ODTC-specific parameters -``` - -## Running Protocols - -### Recommended: High-Level API - -The recommended approach uses `upload_protocol()` and `run_protocol()` for a simpler workflow: - -#### Upload and Run a Protocol - -```python -from pylabrobot.thermocycling.standard import Protocol, Stage, Step - -# Create a Protocol -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 - ) - ] -) - -# Upload Protocol to device with method name "my_pcr" -await tc.upload_protocol(protocol, name="my_pcr") - -# Run the method by name -await tc.run_protocol(method_name="my_pcr") - -# OR: Upload and run in one call -await tc.run_protocol(protocol=protocol, method_name="my_pcr") -``` - -#### Run Existing Method by Name - -```python -# List all methods on device -method_names = await tc.list_methods() # Returns: ["PCR_30cycles", "my_pcr", ...] - -# Run existing method by name -await tc.run_protocol(method_name="PCR_30cycles") -``` - -#### Set Mount Temperature (Simple Temperature Control) - -```python -# Set mount temperature to 37°C (creates PreMethod internally) -await tc.set_mount_temperature(37.0) - -# Non-blocking with custom lid temperature -execution = await tc.set_mount_temperature( - 37.0, - lid_temperature=110.0, - wait=False -) -# Do other work... -await execution # Wait when ready -``` - -**Note on performance:** Protocols created directly in PyLabRobot (without an `ODTCConfig` from an existing XML protocol) will use default overshoot parameters, which may result in slower heating compared to manually-tuned ODTC protocols. Future enhancements will automatically derive optimal overshoot parameters for improved thermal performance. - -### Advanced: Lower-Level API - -For advanced use cases, you can work directly with XML files and `ODTCMethodSet` objects: - -#### Upload and Execute from XML File - -```python -# Upload MethodSet XML file to device (backend-level) -await tc.backend.upload_method_set_from_file("my_methods.xml") - -# Execute a method by name -await tc.run_protocol(method_name="PCR_30cycles") -``` - -#### Upload and Execute from ODTCMethodSet Object - -```python -from pylabrobot.thermocycling.inheco.odtc_xml import parse_method_set_file - -# Parse XML file -method_set = parse_method_set_file("my_methods.xml") - -# Upload to device (backend-level) -await tc.backend.upload_method_set(method_set) - -# Execute -await tc.run_protocol(method_name="PCR_30cycles") -``` - -## XML to Protocol + Config Conversion - -### Lossless Round-Trip Conversion - -The conversion system ensures **lossless round-trip** conversion between ODTC XML format and PyLabRobot's generic `Protocol` format. This is achieved through the `ODTCConfig` companion object that preserves all ODTC-specific parameters. - -### How It Works - -#### 1. ODTC → Protocol + Config - -```python -from pylabrobot.thermocycling.inheco.odtc_xml import odtc_method_to_protocol - -# Convert ODTC method to Protocol + Config -protocol, config = odtc_method_to_protocol(odtc_method) -``` - -**What gets preserved in `ODTCConfig`:** - -- **Method-level parameters:** - - `name`, `creator`, `description`, `datetime` - - `fluid_quantity`, `variant`, `plate_type` - - `lid_temperature`, `start_lid_temperature` - - `post_heating` - - `pid_set` (PID controller parameters) - -- **Per-step parameters** (stored in `config.step_settings[step_index]`): - - `slope` - Temperature ramp rate (°C/s) - - `overshoot_slope1` - First overshoot ramp rate - - `overshoot_temperature` - Overshoot target temperature - - `overshoot_time` - Overshoot hold time - - `overshoot_slope2` - Second overshoot ramp rate - - `lid_temp` - Lid temperature for this step - - `pid_number` - PID controller to use - -**What goes into `Protocol`:** - -- Temperature targets (from `plateau_temperature`) -- Hold times (from `plateau_time`) -- Stage structure (from loop analysis) -- Repeat counts (from `loop_number`) - -#### 2. Protocol + Config → ODTC - -```python -from pylabrobot.thermocycling.inheco.odtc_xml import protocol_to_odtc_method - -# Convert back to ODTC method (lossless if config preserved) -odtc_method = protocol_to_odtc_method(protocol, config=config) -``` - -The conversion uses: -- `Protocol` for temperature/time structure -- `ODTCConfig.step_settings` for per-step overtemp parameters -- `ODTCConfig` defaults for method-level parameters - -### Overtemp/Overshoot Parameter Preservation - -**Overtemp parameters** (overshoot settings) are ODTC-specific features that allow temperature overshooting for faster heating and improved thermal performance: - -- **`overshoot_temperature`**: Target temperature to overshoot to -- **`overshoot_time`**: How long to hold at overshoot temperature -- **`overshoot_slope1`**: Ramp rate to overshoot temperature -- **`overshoot_slope2`**: Ramp rate back to target temperature - -These parameters are **not part of the generic Protocol** (which only has target temperature and hold time), so they are preserved in `ODTCConfig.step_settings`. - -**Why preservation matters:** - -When converting existing ODTC XML protocols to PyLabRobot `Protocol` format, **preserving overshoot parameters is critical for maintaining equivalent thermal performance**. Without these parameters, the converted protocol may have different heating characteristics, potentially affecting PCR efficiency or other temperature-sensitive reactions. - -**Current behavior:** -- ✅ **Preserved from XML**: When converting ODTC XML → Protocol+Config, all overshoot parameters are captured in `ODTCConfig.step_settings` -- ✅ **Restored to XML**: When converting Protocol+Config → ODTC XML, overshoot parameters are restored from `ODTCConfig.step_settings` -- ⚠️ **Not generated**: When creating new protocols in PyLabRobot, overshoot parameters default to minimal values (0.0 for temperature/time, 0.1 for slopes) - -**Future work:** -- 🔮 **Automatic derivation**: Future enhancements will automatically derive optimal overshoot parameters for PyLabRobot-created protocols based on: - - Temperature transitions (large jumps benefit more from overshoot) - - Hardware constraints (variant-specific limits) - - Thermal characteristics (fluid quantity, plate type) -- 🔮 **Performance optimization**: This will enable PyLabRobot-created protocols to achieve equivalent or improved thermal performance compared to manually-tuned ODTC protocols - -**Example of preservation:** - -```python -# When converting ODTC → Protocol + Config -protocol, config = odtc_method_to_protocol(odtc_method) - -# Overtemp params stored per step (preserved from original XML) -step_0_overtemp = config.step_settings[0] -print(step_0_overtemp.overshoot_temperature) # e.g., 100.0 (from original XML) -print(step_0_overtemp.overshoot_time) # e.g., 5.0 (from original XML) - -# When converting back Protocol + Config → ODTC -odtc_method_restored = protocol_to_odtc_method(protocol, config=config) - -# Overtemp params restored from config.step_settings -# This ensures equivalent thermal performance to original -assert odtc_method_restored.steps[0].overshoot_temperature == 100.0 -``` - -**Important:** Always preserve the `ODTCConfig` when modifying protocols converted from ODTC XML to maintain equivalent thermal performance. If you create a new protocol without a config, overshoot parameters will use defaults which may result in slower heating. - -### Example: Round-Trip Conversion - -```python -from pylabrobot.thermocycling.inheco.odtc_xml import ( - odtc_method_to_protocol, - protocol_to_odtc_method, - method_set_to_xml, - parse_method_set -) - -# 1. Get method from device -method = await tc.get_method("PCR_30cycles") -if not method: - raise ValueError("Method not found") - -# 2. Convert to Protocol + Config -protocol, config = odtc_method_to_protocol(method) - -# 3. Modify protocol (generic changes) -protocol.stages[0].repeats = 35 # Change cycle count - -# 4. Upload modified protocol (preserves all ODTC-specific params via config) -await tc.upload_protocol(protocol, name="PCR_35cycles", config=config) - -# 5. Execute -await tc.run_protocol(method_name="PCR_35cycles") -``` - -### Round-Trip from Device XML - -```python -# Full round-trip: Device Method → Protocol+Config → Device Method - -# 1. Get from device -method = await tc.get_method("PCR_30cycles") -if not method: - raise ValueError("Method not found") - -# 2. Convert to Protocol + Config -protocol, config = odtc_method_to_protocol(method) - -# 3. Upload back to device (preserves all ODTC-specific params via config) -await tc.upload_protocol(protocol, name="PCR_30cycles_restored", config=config) - -# 4. Verify round-trip by comparing methods -method_restored = await tc.get_method("PCR_30cycles_restored") -# Methods should be equivalent (XML formatting may differ, but content should match) -``` - -## DataEvent Collection - -During method execution, the ODTC sends `DataEvent` messages containing experimental data. These are automatically collected: - -```python -# Start method -execution = await tc.run_protocol(method_name="PCR_30cycles", wait=False) - -# Get DataEvents for this execution -events = await execution.get_data_events() -# Returns: List of DataEvent objects - -# Get all collected events (backend-level) -all_events = await tc.backend.get_data_events() -# Returns: {request_id1: [...], request_id2: [...]} -``` - -**Note:** DataEvent parsing and progress tracking are planned for future implementation. Currently, raw event payloads are stored for later analysis. - -## Error Handling - -The implementation handles SiLA return codes and state transitions: - -- **Return code 1**: Synchronous success (GetStatus, GetDeviceIdentification) -- **Return code 2**: Asynchronous command accepted (ExecuteMethod, OpenDoor, etc.) -- **Return code 3**: Asynchronous command completed successfully (in ResponseEvent) -- **Return code 4**: Device busy (command rejected due to parallelism) -- **Return code 5**: LockId mismatch -- **Return code 6**: Invalid/duplicate requestId -- **Return code 9**: Command not allowed in current state - -State transitions are tracked automatically: -- `startup` → `standby` (via Reset) -- `standby` → `idle` (via Initialize) -- `idle` → `busy` (when async command starts) -- `busy` → `idle` (when all commands complete) - -## Best Practices - -1. **Always call `setup()`** before using the device -2. **Use `wait=False`** for long-running methods to enable parallel operations -3. **Check state** with `is_method_running()` before starting new methods -4. **Preserve `ODTCConfig`** when converting protocols to maintain ODTC-specific parameters (especially overshoot parameters for equivalent thermal performance) -5. **Handle timeouts** when waiting for method completion -6. **Clean up** with `stop()` when done - -### Protocol Conversion Best Practices - -- **When converting from ODTC XML**: Always preserve the returned `ODTCConfig` alongside the `Protocol` to maintain overshoot parameters and ensure equivalent thermal performance -- **When modifying converted protocols**: Keep the original `ODTCConfig` and only modify the `Protocol` structure (temperatures, times, repeats) -- **When creating new protocols**: Be aware that overshoot parameters will use defaults until automatic derivation is implemented (future work) - -## Complete Example - -```python -from pylabrobot.thermocycling.inheco import InhecoODTC, ODTCBackend -from pylabrobot.thermocycling.inheco.odtc_xml import odtc_method_to_protocol -from pylabrobot.thermocycling.standard import Protocol, Stage, Step - -# Setup -tc = InhecoODTC( - name="odtc", - backend=ODTCBackend(odtc_ip="192.168.1.100"), - model="96" # Use "384" for 384-well format -) -await tc.setup() - -# Example 1: Get existing method from device, modify, and run -method = await tc.get_method("PCR_30cycles") -if method: - # Convert to Protocol + Config - protocol, config = odtc_method_to_protocol(method) - - # Modify protocol - protocol.stages[0].repeats = 35 - - # Upload modified protocol with new name - await tc.upload_protocol(protocol, name="PCR_35cycles", config=config) - - # Run with parallel operations - execution = await tc.run_protocol(method_name="PCR_35cycles", wait=False) - temps = await tc.read_temperatures() # Parallel operation - await execution # Wait for completion - -# Example 2: Create new protocol and run -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 - ) - ] -) - -# Upload and run in one call -await tc.run_protocol(protocol=protocol, method_name="my_pcr") - -# Example 3: Set mount temperature -await tc.set_mount_temperature(37.0) - -# Cleanup -await tc.stop() -``` diff --git a/pylabrobot/thermocycling/inheco/odtc.py b/pylabrobot/thermocycling/inheco/odtc.py deleted file mode 100644 index ea61a8eac90..00000000000 --- a/pylabrobot/thermocycling/inheco/odtc.py +++ /dev/null @@ -1,639 +0,0 @@ -"""Inheco ODTC (On-Deck Thermocycler) resource class.""" - -import logging -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union - -from pylabrobot.resources import Coordinate, ItemizedResource -from pylabrobot.thermocycling.thermocycler import Thermocycler - -from .odtc_backend import CommandExecution, MethodExecution, ODTCBackend -from .odtc_xml import ODTCMethod, ODTCPreMethod, ODTCConfig - -if TYPE_CHECKING: - from pylabrobot.thermocycling.standard import Protocol - -logger = logging.getLogger(__name__) - - -# Mapping from model string to variant integer (960000 for 96-well, 384000 for 384-well) -_MODEL_TO_VARIANT: Dict[str, int] = { - "96": 960000, - "384": 384000, -} - - -def _volume_to_fluid_quantity(volume_ul: float) -> int: - """Map volume in µL to ODTC fluid_quantity code. - - Args: - volume_ul: Volume in microliters. - - Returns: - fluid_quantity code: 0 (10-29ul), 1 (30-74ul), or 2 (75-100ul). - - Raises: - ValueError: If volume > 100 µL. - """ - if volume_ul > 100: - raise ValueError( - f"Volume {volume_ul} µL exceeds ODTC maximum of 100 µL. " - "Please use a volume between 0-100 µL." - ) - elif volume_ul <= 29: - return 0 # 10-29ul - elif volume_ul <= 74: - return 1 # 30-74ul - else: # 75 <= volume_ul <= 100 - return 2 # 75-100ul - - -def _validate_volume_fluid_quantity( - volume_ul: float, - fluid_quantity: int, - is_premethod: bool = False, -) -> None: - """Validate that volume matches fluid_quantity and warn if mismatch. - - Args: - volume_ul: Volume in microliters. - fluid_quantity: ODTC fluid_quantity code (0, 1, or 2). - is_premethod: If True, suppress warnings for volume=0 (premethods don't need volume). - """ - if volume_ul <= 0: - if not is_premethod: - logger.warning( - f"block_max_volume={volume_ul} µL is invalid. Using default fluid_quantity=1 (30-74ul). " - "Please provide a valid volume for accurate thermal calibration." - ) - return - - if volume_ul > 100: - raise ValueError( - f"Volume {volume_ul} µL exceeds ODTC maximum of 100 µL. " - "Please use a volume between 0-100 µL." - ) - - # Check if volume matches fluid_quantity - expected_fluid_quantity = _volume_to_fluid_quantity(volume_ul) - if fluid_quantity != expected_fluid_quantity: - volume_ranges = { - 0: "10-29 µL", - 1: "30-74 µL", - 2: "75-100 µL", - } - logger.warning( - f"Volume mismatch: block_max_volume={volume_ul} µL suggests fluid_quantity={expected_fluid_quantity} " - f"({volume_ranges[expected_fluid_quantity]}), but config has fluid_quantity={fluid_quantity} " - f"({volume_ranges.get(fluid_quantity, 'unknown')}). This may affect thermal calibration accuracy." - ) - - -class InhecoODTC(Thermocycler): - """Inheco ODTC (On-Deck Thermocycler). - - The ODTC is a compact thermocycler designed for integration into liquid handling - systems. It features a motorized drawer for plate access and supports PCR protocols - via XML-defined methods. - - Available models: - - "96": 96-well plate format (variant=960000) - - "384": 384-well plate format (variant=384000) - - The model parameter affects: - - Default hardware constraints (max heating slope, max lid temp) - - Default variant used in protocol conversion - - Resource identification in PyLabRobot - - Approximate dimensions: - - Width (X): 147 mm - - Depth (Y): 298 mm - - Height (Z): 130 mm (with drawer closed) - - To configure async command completion (polling fallback, timeout, behavior when ResponseEvent is lost), pass ``poll_interval``, ``lifetime_of_execution``, and ``on_response_event_missing`` to :class:`ODTCBackend`. - - Example usage: - ```python - from pylabrobot.thermocycling.inheco import InhecoODTC, ODTCBackend - from pylabrobot.thermocycling.standard import Protocol, Stage, Step - - # Create backend and thermocycler (384-well) - backend = ODTCBackend(odtc_ip="192.168.1.100") - tc = InhecoODTC(name="odtc1", backend=backend, model="384") - - # Initialize - await tc.setup() - - # Create a protocol - protocol = Protocol(stages=[ - Stage(steps=[Step(temperature=[95.0], hold_seconds=30.0)], repeats=1) - ]) - - # Upload protocol (uses model's variant 384000 automatically) - await tc.upload_protocol(protocol, name="my_method") - - # Run method by name - await tc.run_protocol(method_name="my_method") - - # Or upload and run in one call - await tc.run_protocol(protocol=protocol, method_name="my_pcr") - - # Clean up - await tc.stop() - ``` - """ - - def __init__( - self, - name: str, - backend: ODTCBackend, - model: Literal["96", "384"] = "96", - child_location: Coordinate = Coordinate(x=10.0, y=10.0, z=50.0), - child: Optional[ItemizedResource] = None, - ): - """Initialize the ODTC thermocycler. - - Args: - name: Human-readable name for this resource. - backend: ODTCBackend instance configured with device IP. - model: ODTC model variant - "96" for 96-well or "384" for 384-well format. - child_location: Position where a plate sits on the block. - Defaults to approximate center of the block area. - child: Optional plate/rack already loaded on the module. - """ - model_name = f"InhecoODTC{model}" - super().__init__( - name=name, - size_x=147.0, # mm - approximate width - size_y=298.0, # mm - approximate depth (includes drawer travel) - size_z=130.0, # mm - approximate height with drawer closed - backend=backend, - child_location=child_location, - category="thermocycler", - model=model_name, - ) - - self.backend: ODTCBackend = backend - self.model: Literal["96", "384"] = model - # Get variant integer from model string via lookup - self._variant: int = _MODEL_TO_VARIANT[model] - self.child = child - if child is not None: - self.assign_child_resource(child, location=child_location) - - def serialize(self) -> dict: - """Return a serialized representation of the thermocycler.""" - return { - **super().serialize(), - "odtc_ip": self.backend._sila._machine_ip, - "port": self.backend._sila.bound_port, - } - - # Protocol management methods - - async def upload_protocol( - self, - protocol: "Protocol", - config: Optional["ODTCConfig"] = None, - name: Optional[str] = None, - allow_overwrite: bool = False, - debug_xml: bool = False, - xml_output_path: Optional[str] = None, - block_max_volume: Optional[float] = None, - ) -> str: - """Upload a Protocol to the device. - - Args: - protocol: PyLabRobot Protocol to upload. - config: Optional ODTCConfig for device-specific parameters. If None, uses - model-aware defaults (variant matches thermocycler model). - name: Method name. If None, uses "plr_currentProtocol". - allow_overwrite: If False, raise ValueError if method name already exists. - 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. - block_max_volume: Optional volume in µL. If provided and config is None, used to set - fluid_quantity. If config is provided, validates volume matches fluid_quantity. - - Returns: - Method name (resolved name, may be scratch name if not provided). - - Raises: - ValueError: If allow_overwrite=False and method name already exists. - ValueError: If block_max_volume > 100 µL. - """ - from .odtc_xml import protocol_to_odtc_method - - # Use model-aware defaults if config not provided - if config is None: - if block_max_volume is not None and block_max_volume > 0 and block_max_volume <= 100: - fluid_quantity = _volume_to_fluid_quantity(block_max_volume) - config = self.get_default_config(fluid_quantity=fluid_quantity) - else: - config = self.get_default_config() - elif block_max_volume is not None: - # Config provided - validate volume matches fluid_quantity - _validate_volume_fluid_quantity(block_max_volume, config.fluid_quantity, is_premethod=False) - - # Set name in config if provided - if name is not None: - config.name = name - - # Convert Protocol to ODTCMethod in resource layer - method = protocol_to_odtc_method(protocol, config=config) - - # Upload method to backend - await self.backend.upload_method( - method, - allow_overwrite=allow_overwrite, - execute=False, - debug_xml=debug_xml, - xml_output_path=xml_output_path, - ) - - return method.name - - async def run_protocol( - self, - protocol: Optional["Protocol"] = None, - block_max_volume: float = 0.0, - config: Optional["ODTCConfig"] = None, - method_name: Optional[str] = None, - wait: bool = True, - debug_xml: bool = False, - xml_output_path: Optional[str] = None, - **backend_kwargs: Any, - ) -> Optional[MethodExecution]: - """Run a protocol or method on the device. - - If protocol is provided: - - Converts Protocol to ODTCMethod - - Uploads it with name method_name (or uses scratch name "plr_currentProtocol" if method_name=None) - - Executes the method by the resolved name - - If only method_name is provided: - - Executes existing Method on device by that name - - Args: - protocol: Optional Protocol to convert and execute. If None, method_name must be provided. - block_max_volume: Maximum block volume (µL) for safety. Used to set fluid_quantity in - ODTCConfig when config is None. Must be between 0-100 µL. If 0, uses default - fluid_quantity=1 (30-74ul) with a warning. If >100, raises ValueError. - config: Optional ODTCConfig for device-specific parameters. If None and protocol provided, - uses model-aware defaults. - method_name: Name of Method to execute. If protocol provided, this is the name for the - uploaded method. If only method_name provided, this is the existing method to run. - If None and protocol provided, uses "plr_currentProtocol". - wait: If True, block until completion. If False, return MethodExecution handle. - 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. - **backend_kwargs: Additional backend-specific parameters (unused for ODTC). - - Returns: - If wait=True: None (blocks until complete) - If wait=False: MethodExecution handle (awaitable, has request_id) - - Raises: - ValueError: If neither protocol nor method_name is provided. - """ - if protocol is not None: - # Convert, upload, and execute protocol - if config is None: - # Use block_max_volume to set fluid_quantity if volume is valid - if block_max_volume > 0 and block_max_volume <= 100: - fluid_quantity = _volume_to_fluid_quantity(block_max_volume) - config = self.get_default_config(fluid_quantity=fluid_quantity) - elif block_max_volume == 0: - # Use default but warn - logger.warning( - f"block_max_volume={block_max_volume} µL is invalid. Using default fluid_quantity=1 (30-74ul). " - "Please provide a valid volume for accurate thermal calibration." - ) - config = self.get_default_config() - else: # block_max_volume > 100 - raise ValueError( - f"Volume {block_max_volume} µL exceeds ODTC maximum of 100 µL. " - "Please use a volume between 0-100 µL." - ) - else: - # Config provided - validate volume matches fluid_quantity (only if volume > 0) - if block_max_volume > 0: - _validate_volume_fluid_quantity(block_max_volume, config.fluid_quantity, is_premethod=False) - - # Upload with allow_overwrite=True since we're about to execute it - # Pass block_max_volume only if > 0 to avoid triggering validation warnings in upload_protocol - resolved_method_name = await self.upload_protocol( - protocol, - config=config, - name=method_name, - allow_overwrite=True, - debug_xml=debug_xml, - xml_output_path=xml_output_path, - block_max_volume=block_max_volume if block_max_volume > 0 else None, - ) - return await self.backend.execute_method(resolved_method_name, wait=wait) - elif method_name is not None: - # Execute existing method by name - return await self.backend.execute_method(method_name, wait=wait) - else: - raise ValueError("Either protocol or method_name must be provided") - - async def get_method_set(self): - """Get the full MethodSet from the device. - - Returns: - ODTCMethodSet containing all methods and premethods. - """ - return await self.backend.get_method_set() - - async def get_method(self, name: str) -> Optional[Union[ODTCMethod, ODTCPreMethod]]: - """Get a method by name from the device (searches both methods and premethods). - - In ODTC/SiLA, a method is a runnable protocol (thermocycling program). - - Args: - name: Method name to retrieve. - - Returns: - ODTCMethod or ODTCPreMethod if found, None otherwise. - """ - return await self.backend.get_method(name) - - async def list_methods(self) -> List[str]: - """List all method names (both methods and premethods) on the device. - - In ODTC/SiLA, a method is a runnable protocol (thermocycling program). - - Returns: - List of method names (strings). - """ - return await self.backend.list_methods() - - async def stop_method(self, wait: bool = True) -> Optional[CommandExecution]: - """Stop any currently running method. - - Args: - wait: If True, block until completion. If False, return CommandExecution handle. - - Returns: - If wait=True: None (blocks until complete) - If wait=False: CommandExecution handle (awaitable, has request_id) - """ - return await self.backend.stop_method(wait=wait) - - async def is_method_running(self) -> bool: - """Check if a method is currently running.""" - return await self.backend.is_method_running() - - async def wait_for_method_completion( - self, - poll_interval: float = 5.0, - timeout: Optional[float] = None, - ) -> None: - """Wait until method execution completes.""" - await self.backend.wait_for_method_completion(poll_interval, timeout) - - async def set_mount_temperature( - self, - temperature: float, - lid_temperature: Optional[float] = None, - wait: bool = True, - debug_xml: bool = False, - xml_output_path: Optional[str] = None, - ) -> Optional[MethodExecution]: - """Set mount (block) temperature and hold it. - - Creates and executes a PreMethod to set the mount and lid temperatures. - PreMethods are simpler than full Methods and are designed for temperature conditioning. - - Args: - temperature: Target mount (block) temperature in °C. - lid_temperature: Optional lid temperature in °C. If None, uses hardware-defined - default (max_lid_temp: 110°C for 96-well, 115°C for 384-well). - wait: If True, block until temperatures are set. If False, return MethodExecution handle. - debug_xml: If True, log the generated XML to the logger at DEBUG level. - xml_output_path: Optional file path to save the generated MethodSet XML. - - Returns: - If wait=True: None (blocks until complete) - If wait=False: MethodExecution handle (awaitable, has request_id) - - Example: - ```python - # Set mount to 95°C with default lid temperature (110°C) - blocking - await tc.set_mount_temperature(95.0) - - # Set mount to 95°C with custom lid temperature - blocking - await tc.set_mount_temperature(95.0, lid_temperature=115.0) - - # Non-blocking - returns MethodExecution handle - execution = await tc.set_mount_temperature(95.0, wait=False) - # Do other work... - await execution # Wait when ready - ``` - """ - from .odtc_xml import ODTCPreMethod, generate_odtc_timestamp, resolve_protocol_name - - # Use default lid temperature if not specified - if lid_temperature is not None: - target_lid_temp = lid_temperature - else: - # Use hardware-defined max as default (110°C for 96-well, 115°C for 384-well) - constraints = self.get_constraints() - target_lid_temp = constraints.max_lid_temp - - # Create PreMethod - much simpler than a full Method - # PreMethods just set target temperatures and hold them - # Note: PreMethods don't need volume/fluid_quantity (they're for temperature conditioning) - premethod = ODTCPreMethod( - name=resolve_protocol_name(None), # Uses "plr_currentProtocol" - target_block_temperature=temperature, - target_lid_temperature=target_lid_temp, - datetime=generate_odtc_timestamp(), - ) - - # Upload PreMethod to backend - await self.backend.upload_premethod( - premethod, - allow_overwrite=True, # Always overwrite scratch name - debug_xml=debug_xml, - xml_output_path=xml_output_path, - ) - - # Execute the PreMethod (same command as Methods) - return await self.backend.execute_method(premethod.name, wait=wait) - - async def get_status(self) -> str: - """Get device status state. - - Returns: - Device state string (e.g., "idle", "busy", "standby"). - """ - return await self.backend.get_status() - - async def read_temperatures(self): - """Read all temperature sensors. - - Returns: - ODTCSensorValues with temperatures in °C. - """ - return await self.backend.read_temperatures() - - - # Device control methods - - async def initialize(self, wait: bool = True) -> Optional[CommandExecution]: - """Initialize the device (must be in standby state). - - Args: - wait: If True, block until completion. If False, return CommandExecution handle. - - Returns: - If wait=True: None (blocks until complete) - If wait=False: CommandExecution handle (awaitable, has request_id) - """ - return await self.backend.initialize(wait=wait) - - async def reset( - self, - device_id: str = "ODTC", - event_receiver_uri: Optional[str] = None, - simulation_mode: bool = False, - wait: bool = True, - ) -> Optional[CommandExecution]: - """Reset the device. - - Args: - device_id: Device identifier. - event_receiver_uri: Event receiver URI (auto-detected if None). - simulation_mode: Enable simulation mode. - wait: If True, block until completion. If False, return CommandExecution handle. - - Returns: - If wait=True: None (blocks until complete) - If wait=False: CommandExecution handle (awaitable, has request_id) - """ - return await self.backend.reset(device_id, event_receiver_uri, simulation_mode, wait=wait) - - async def get_device_identification(self) -> dict: - """Get device identification information. - - Returns: - Device identification dictionary. - """ - return await self.backend.get_device_identification() - - async def lock_device(self, lock_id: str, lock_timeout: Optional[float] = None, wait: bool = True) -> Optional[CommandExecution]: - """Lock the device for exclusive access. - - Args: - lock_id: Unique lock identifier. - lock_timeout: Lock timeout in seconds (optional). - wait: If True, block until completion. If False, return CommandExecution handle. - - Returns: - If wait=True: None (blocks until complete) - If wait=False: CommandExecution handle (awaitable, has request_id) - """ - return await self.backend.lock_device(lock_id, lock_timeout, wait=wait) - - async def unlock_device(self, wait: bool = True) -> Optional[CommandExecution]: - """Unlock the device. - - Args: - wait: If True, block until completion. If False, return CommandExecution handle. - - Returns: - If wait=True: None (blocks until complete) - If wait=False: CommandExecution handle (awaitable, has request_id) - """ - return await self.backend.unlock_device(wait=wait) - - # Door control methods - - async def open_door(self, wait: bool = True) -> Optional[CommandExecution]: - """Open the drawer door. - - Args: - wait: If True, block until completion. If False, return CommandExecution handle. - - Returns: - If wait=True: None (blocks until complete) - If wait=False: CommandExecution handle (awaitable, has request_id) - """ - return await self.backend.open_door(wait=wait) - - async def close_door(self, wait: bool = True) -> Optional[CommandExecution]: - """Close the drawer door. - - Args: - wait: If True, block until completion. If False, return CommandExecution handle. - - Returns: - If wait=True: None (blocks until complete) - If wait=False: CommandExecution handle (awaitable, has request_id) - """ - return await self.backend.close_door(wait=wait) - - # Data retrieval methods - - async def get_data_events( - self, request_id: Optional[int] = None - ) -> Dict[int, List[Dict[str, Any]]]: - """Get collected DataEvents. - - Args: - request_id: If provided, return events for this request_id only. - If None, return all collected events. - - Returns: - Dict mapping request_id to list of DataEvent payloads. - """ - return await self.backend.get_data_events(request_id) - - async def get_last_data(self) -> str: - """Get temperature trace of last executed method (CSV format). - - Returns: - CSV string with temperature trace data. - """ - return await self.backend.get_last_data() - - - # Protocol conversion helpers with model-aware defaults - - def get_default_config(self, **kwargs) -> ODTCConfig: - """Get a default ODTCConfig with variant set to match this thermocycler's model. - - Args: - **kwargs: Additional parameters to override defaults (e.g., name, lid_temperature). - - Returns: - ODTCConfig with variant matching the thermocycler model (96 or 384-well). - - Example: - ```python - # For a 384-well ODTC, this returns config with variant=384000 - config = tc.get_default_config(name="my_protocol", lid_temperature=115.0) - method = protocol_to_odtc_method(protocol, config=config) - ``` - """ - return ODTCConfig(variant=self._variant, **kwargs) - - def get_constraints(self): - """Get hardware constraints for this thermocycler's model. - - Returns: - ODTCHardwareConstraints for the current model (96 or 384-well). - - Example: - ```python - constraints = tc.get_constraints() - print(f"Max heating slope: {constraints.max_heating_slope} °C/s") - print(f"Max lid temp: {constraints.max_lid_temp} °C") - ``` - """ - from .odtc_xml import get_constraints - return get_constraints(self._variant) diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index 0040578c042..be944a33d8a 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -4,7 +4,7 @@ import asyncio import logging -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any, Dict, List, Literal, Optional, Union, cast from pylabrobot.thermocycling.backend import ThermocyclerBackend @@ -16,21 +16,101 @@ POLLING_START_BUFFER, SiLAState, ) -from .odtc_xml import ( +from .odtc_model import ( ODTCMethod, + ODTCConfig, ODTCMethodSet, ODTCPreMethod, + PREMETHOD_ESTIMATED_DURATION_SECONDS, ODTCSensorValues, + ODTCHardwareConstraints, + StoredProtocol, + estimate_method_duration_seconds, generate_odtc_timestamp, + get_constraints, get_method_by_name, list_method_names, method_set_to_xml, + normalize_variant, + odtc_method_to_protocol, parse_method_set, parse_method_set_file, parse_sensor_values, + protocol_to_odtc_method, resolve_protocol_name, ) +# Buffer (seconds) added to estimated duration for timeout cap (fail faster than full lifetime). +LIFETIME_BUFFER_SECONDS: float = 60.0 + + +def _volume_to_fluid_quantity(volume_ul: float) -> int: + """Map volume in µL to ODTC fluid_quantity code. + + Args: + volume_ul: Volume in microliters. + + Returns: + fluid_quantity code: 0 (10-29ul), 1 (30-74ul), or 2 (75-100ul). + + Raises: + ValueError: If volume > 100 µL. + """ + if volume_ul > 100: + raise ValueError( + f"Volume {volume_ul} µL exceeds ODTC maximum of 100 µL. " + "Please use a volume between 0-100 µL." + ) + elif volume_ul <= 29: + return 0 # 10-29ul + elif volume_ul <= 74: + return 1 # 30-74ul + else: # 75 <= volume_ul <= 100 + return 2 # 75-100ul + + +def _validate_volume_fluid_quantity( + volume_ul: float, + fluid_quantity: int, + is_premethod: bool = False, + logger: Optional[logging.Logger] = None, +) -> None: + """Validate that volume matches fluid_quantity and warn if mismatch. + + Args: + volume_ul: Volume in microliters. + fluid_quantity: ODTC fluid_quantity code (0, 1, or 2). + is_premethod: If True, suppress warnings for volume=0 (premethods don't need volume). + logger: Logger for warnings (uses module logger if None). + """ + log = logger or logging.getLogger(__name__) + if volume_ul <= 0: + if not is_premethod: + log.warning( + f"block_max_volume={volume_ul} µL is invalid. Using default fluid_quantity=1 (30-74ul). " + "Please provide a valid volume for accurate thermal calibration." + ) + return + + if volume_ul > 100: + raise ValueError( + f"Volume {volume_ul} µL exceeds ODTC maximum of 100 µL. " + "Please use a volume between 0-100 µL." + ) + + expected_fluid_quantity = _volume_to_fluid_quantity(volume_ul) + if fluid_quantity != expected_fluid_quantity: + volume_ranges = { + 0: "10-29 µL", + 1: "30-74 µL", + 2: "75-100 µL", + } + log.warning( + f"Volume mismatch: block_max_volume={volume_ul} µL suggests fluid_quantity={expected_fluid_quantity} " + f"({volume_ranges[expected_fluid_quantity]}), but config has fluid_quantity={fluid_quantity} " + f"({volume_ranges.get(fluid_quantity, 'unknown')}). This may affect thermal calibration accuracy." + ) + @dataclass class CommandExecution: @@ -38,7 +118,8 @@ class CommandExecution: Sometimes called a job or task handle in other automation systems. Returned from async commands when wait=False. Provides: - - Awaitable interface (can be awaited like a Task) + - Awaitable interface (can be awaited like a Task); ``await handle`` and + ``await handle.wait()`` are equivalent. - Request ID access for DataEvent tracking - Command completion waiting - done, status, estimated_remaining_time, started_at, lifetime for ETA and resumable wait @@ -54,6 +135,8 @@ class CommandExecution: def __await__(self): """Make this awaitable like a Task.""" + if not self._future.done(): + self._log_wait_info() return self._future.__await__() @property @@ -72,8 +155,39 @@ def status(self) -> str: except Exception: return "error" + def _log_wait_info(self) -> None: + """Log command/method name, duration (lifetime), and remaining time (computed at call time). + + Includes a timestamp so log history gives a clear sense of when each wait + was logged and what remaining time was at that moment, without re-querying. + """ + import time + + method_name = getattr(self, "method_name", None) + if isinstance(self, MethodExecution) and method_name: + name = f"{method_name} ({self.command_name})" + else: + name = self.command_name + + lifetime = self.lifetime if self.lifetime is not None else self.backend._get_effective_lifetime() + started_at = self.started_at if self.started_at is not None else time.time() + now = time.time() + elapsed = now - started_at + remaining = max(0.0, lifetime - elapsed) if lifetime is not None else None + + ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now)) + msg = f"[{ts}] Waiting for {name}, duration (timeout)={lifetime}s" + if remaining is not None: + msg += f", remaining={remaining:.0f}s" + self.backend.logger.info(msg) + async def wait(self) -> None: - """Wait for command completion.""" + """Wait for command completion. + + Equivalent to ``await self`` (the handle is awaitable via __await__). + """ + if not self._future.done(): + self._log_wait_info() await self._future async def wait_resumable(self, poll_interval: float = 5.0) -> None: @@ -92,6 +206,7 @@ async def wait_resumable(self, poll_interval: float = 5.0) -> None: """ import time + self._log_wait_info() started_at = self.started_at if self.started_at is not None else time.time() lifetime = self.lifetime if self.lifetime is not None else self.backend._get_effective_lifetime() await self.backend.wait_for_completion_by_time( @@ -149,11 +264,16 @@ class ODTCBackend(ThermocyclerBackend): 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=147, size_y=298, size_z=130 (mm). + Construct: backend = ODTCBackend(odtc_ip="...", variant=384000); then + Thermocycler(name="odtc1", size_x=147, size_y=298, size_z=130, backend=backend, ...). """ def __init__( self, odtc_ip: str, + variant: int = 960000, client_ip: Optional[str] = None, logger: Optional[logging.Logger] = None, poll_interval: float = 5.0, @@ -164,6 +284,9 @@ def __init__( Args: odtc_ip: IP address of the ODTC device. + variant: Well count (96, 384) or ODTC variant code (960000, 384000, 3840000). + Accepted 96/384 are normalized to 960000/384000. Used for default config + and constraints (e.g. max slopes, lid temp). client_ip: IP address of this client (auto-detected if None). logger: Logger instance (creates one if None). poll_interval: Seconds between GetStatus calls in the async completion polling fallback (SiLA2 subscribe_by_polling style). Default 5.0. @@ -171,6 +294,8 @@ def __init__( on_response_event_missing: When completion is detected via polling but ResponseEvent was not received: "warn_and_continue" (resolve with None, log warning) or "error" (set exception). Default "warn_and_continue". """ super().__init__() + self._variant = normalize_variant(variant) + self._current_execution: Optional[MethodExecution] = None self._sila = ODTCSiLAInterface( machine_ip=odtc_ip, client_ip=client_ip, @@ -181,6 +306,26 @@ def __init__( ) 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 code (960000 or 384000).""" + return self._variant + + @property + def current_execution(self) -> Optional[MethodExecution]: + """Current method execution handle (set when a method is started with wait=False or wait=True).""" + return self._current_execution + + def _clear_current_execution_if(self, handle: MethodExecution) -> None: + """Clear _current_execution only if it still refers to the given handle.""" + if self._current_execution is handle: + self._current_execution = None + async def setup(self) -> None: """Prepare the ODTC connection and bring the device to idle. @@ -241,7 +386,8 @@ def serialize(self) -> dict: """Return serialized representation of the backend.""" return { **super().serialize(), - "odtc_ip": self._sila._machine_ip, + "odtc_ip": self.odtc_ip, + "variant": self.variant, "port": self._sila.bound_port, } @@ -257,6 +403,7 @@ async def _run_async_command( wait: bool, execution_class: type, method_name: Optional[str] = None, + estimated_duration_seconds: Optional[float] = None, **send_kwargs: Any, ) -> Optional[Union[CommandExecution, MethodExecution]]: """Run an async SiLA command; return None if wait else execution handle.""" @@ -264,9 +411,16 @@ async def _run_async_command( await self._sila.send_command(command_name, **send_kwargs) return None fut, request_id, eta, started_at = await self._sila.start_command( - command_name, **send_kwargs + command_name, estimated_duration_seconds=estimated_duration_seconds, **send_kwargs ) - lifetime = self._get_effective_lifetime() + effective = self._get_effective_lifetime() + if estimated_duration_seconds is not None and estimated_duration_seconds > 0: + lifetime = min( + estimated_duration_seconds + LIFETIME_BUFFER_SECONDS, + effective, + ) + else: + lifetime = effective if execution_class is MethodExecution: return MethodExecution( request_id=request_id, @@ -601,27 +755,44 @@ async def execute_method( self, method_name: str, priority: Optional[int] = None, - wait: bool = True, - ) -> Optional[MethodExecution]: + wait: bool = False, + estimated_duration_seconds: Optional[float] = None, + ) -> MethodExecution: """Execute a method or premethod by name (SiLA: ExecuteMethod; methodName). In ODTC/SiLA, a method is a runnable protocol (thermocycling program). + Always starts the method and returns an execution handle; wait only + controls whether we await completion before returning. Args: method_name: Name of the method or premethod to execute (SiLA: methodName). priority: Priority (SiLA spec; not used by ODTC). - wait: If True, block until completion. If False, return an execution - handle (MethodExecution). + wait: If False (default), return handle immediately. If True, block until + completion then return the (completed) handle. + estimated_duration_seconds: Optional estimated duration in seconds (used for + polling timing and timeout; not sent to device). Returns: - If wait=True: None. If wait=False: execution handle (awaitable). + MethodExecution handle (completed if wait=True). """ + self._current_execution = None params: dict = {"methodName": method_name} if priority is not None: params["priority"] = priority - return await self._run_async_command( - "ExecuteMethod", wait, MethodExecution, method_name=method_name, **params + handle = await self._run_async_command( + "ExecuteMethod", + False, + MethodExecution, + method_name=method_name, + estimated_duration_seconds=estimated_duration_seconds, + **params, ) + assert handle is not None and isinstance(handle, MethodExecution) + handle._future.add_done_callback(lambda _: self._clear_current_execution_if(handle)) + self._current_execution = handle + if wait: + await handle.wait() + return handle async def stop_method(self, wait: bool = True) -> Optional[CommandExecution]: """Stop the currently running method (SiLA: StopMethod). @@ -654,18 +825,24 @@ async def wait_for_method_completion( ) -> None: """Wait until method execution completes. - Polls GetStatus at poll_interval until state returns to 'idle'. - Useful when method was started with wait=False and you need to wait. + Uses current execution handle (lifetime/eta) when present; otherwise + polls GetStatus at poll_interval until state returns to 'idle'. Args: - poll_interval: Seconds between status checks. Default 5.0. - timeout: Maximum seconds to wait. None for no timeout. + poll_interval: Seconds between status checks (used by handle.wait_resumable + or fallback poll). Default 5.0. + timeout: Maximum seconds to wait (fallback poll only). None for no timeout. Raises: - TimeoutError: If timeout is exceeded. + TimeoutError: If timeout is exceeded (fallback poll only). """ import time + if self._current_execution is not None: + if self._current_execution.done: + return + await self._current_execution.wait_resumable(poll_interval=poll_interval) + return start_time = time.time() while await self.is_method_running(): if timeout is not None: @@ -759,32 +936,138 @@ async def get_method_set(self) -> ODTCMethodSet: # Parse MethodSet XML (it's escaped in the response) return parse_method_set(method_set_xml) - async def get_method(self, name: str) -> Optional[Union[ODTCMethod, ODTCPreMethod]]: - """Get a method by name from the device (searches both methods and premethods). + async def get_protocol(self, name: str) -> Optional[StoredProtocol]: + """Get a stored protocol by name (runnable methods only; premethods return None). - In ODTC/SiLA, a method is a runnable protocol (thermocycling program). - SiLA command: ExecuteMethod; parameter: methodName. + Resolves the stored method by name. If it is a runnable method (ODTCMethod), + converts it to Protocol + config and returns StoredProtocol. If it is a + premethod (ODTCPreMethod) or not found, returns None. Args: - name: Method name to retrieve. + name: Protocol name to retrieve. Returns: - ODTCMethod or ODTCPreMethod if found, None otherwise. + StoredProtocol(name, protocol, config) if a runnable method exists, None otherwise. """ method_set = await self.get_method_set() - return get_method_by_name(method_set, name) - - async def list_methods(self) -> List[str]: - """List all method names (both methods and premethods) on the device. + resolved = get_method_by_name(method_set, name) + if resolved is None: + return None + if isinstance(resolved, ODTCPreMethod): + return None + protocol, config = odtc_method_to_protocol(resolved) + return StoredProtocol(name=name, protocol=protocol, config=config) - In ODTC/SiLA, a method is a runnable protocol (thermocycling program). + async def list_protocols(self) -> List[str]: + """List all protocol names (both methods and premethods) on the device. Returns: - List of method names (strings). + List of protocol names (strings). """ method_set = await self.get_method_set() return list_method_names(method_set) + def get_default_config(self, **kwargs) -> ODTCConfig: + """Get a default ODTCConfig with variant set to this backend's variant. + + Args: + **kwargs: Additional parameters to override defaults (e.g. name, lid_temperature). + + Returns: + ODTCConfig with variant matching this backend (96 or 384-well). + """ + return ODTCConfig(variant=self._variant, **kwargs) + + def get_constraints(self) -> ODTCHardwareConstraints: + """Get hardware constraints for this backend's variant. + + Returns: + ODTCHardwareConstraints for the current variant (96 or 384-well). + """ + return get_constraints(self._variant) + + async def upload_protocol( + self, + protocol: Protocol, + name: Optional[str] = None, + config: Optional[ODTCConfig] = None, + block_max_volume: Optional[float] = None, + allow_overwrite: bool = False, + debug_xml: bool = False, + xml_output_path: Optional[str] = None, + ) -> str: + """Upload a Protocol to the device. + + Args: + protocol: PyLabRobot Protocol to upload. + name: Method name. If None, uses scratch name "plr_currentProtocol". + config: Optional ODTCConfig. If None, uses variant-aware defaults; if + block_max_volume is provided and in 0–100 µL, sets fluid_quantity from it. + block_max_volume: Optional volume in µL. If provided and config is None, + used to set fluid_quantity. If config is provided, validates volume + matches fluid_quantity. + allow_overwrite: If False, raise ValueError if method name already exists. + debug_xml: If True, log generated XML at DEBUG. + xml_output_path: Optional path to save MethodSet XML. + + Returns: + Resolved method name (string). + + Raises: + ValueError: If allow_overwrite=False and method name already exists. + ValueError: If block_max_volume > 100 µL. + """ + if config is None: + if block_max_volume is not None and block_max_volume > 0 and block_max_volume <= 100: + fluid_quantity = _volume_to_fluid_quantity(block_max_volume) + config = self.get_default_config(fluid_quantity=fluid_quantity) + else: + config = self.get_default_config() + elif block_max_volume is not None and block_max_volume > 0: + _validate_volume_fluid_quantity( + block_max_volume, config.fluid_quantity, is_premethod=False, logger=self.logger + ) + + if name is not None: + config = replace(config, name=name) + + method = protocol_to_odtc_method(protocol, config=config) + await self.upload_method( + method, + allow_overwrite=allow_overwrite, + execute=False, + debug_xml=debug_xml, + xml_output_path=xml_output_path, + ) + return resolve_protocol_name(method.name) + + async def run_stored_protocol(self, name: str, wait: bool = False, **kwargs) -> MethodExecution: + """Execute a stored protocol by name (single SiLA ExecuteMethod call). + + No fetch or round-trip; calls the instrument execute-by-name directly. + Resolves estimated duration from stored method/premethod when available. + + Args: + name: Name of the stored protocol (method) to run. + wait: If False (default), start and return handle. If True, block until + completion then return the (completed) handle. + **kwargs: Ignored (for API compatibility with base backend). + + Returns: + MethodExecution handle (completed if wait=True). + """ + eta: Optional[float] = None + stored = await self.get_protocol(name) + if stored is not None: + method = protocol_to_odtc_method(stored.protocol, config=stored.config) + eta = estimate_method_duration_seconds(method) + else: + method_set = await self.get_method_set() + resolved = get_method_by_name(method_set, name) + if isinstance(resolved, ODTCPreMethod): + eta = PREMETHOD_ESTIMATED_DURATION_SECONDS + return await self.execute_method(name, wait=wait, estimated_duration_seconds=eta) + async def upload_method_set( self, method_set: ODTCMethodSet, @@ -1055,42 +1338,84 @@ async def save_method_set_to_file(self, filepath: str) -> None: # ThermocyclerBackend Abstract Methods # ============================================================================ - async def open_lid(self) -> None: + async def open_lid(self, wait: bool = True, **kwargs: Any): """Open the thermocycler lid (ODTC SiLA: OpenDoor).""" - await self.open_door() + return await self.open_door(wait=wait) - async def close_lid(self) -> None: + async def close_lid(self, wait: bool = True, **kwargs: Any): """Close the thermocycler lid (ODTC SiLA: CloseDoor).""" - await self.close_door() + return await self.close_door(wait=wait) - async def set_block_temperature(self, temperature: List[float]) -> None: - """Set block temperature. + async def set_block_temperature( + self, + temperature: List[float], + lid_temperature: Optional[float] = None, + wait: bool = False, + debug_xml: bool = False, + xml_output_path: Optional[str] = None, + **kwargs: Any, + ): + """Set block (mount) temperature and hold it via PreMethod. - Note: ODTC doesn't have a direct SetBlockTemperature command. - Temperature is controlled via ExecuteMethod with PreMethod or Method. - This is a placeholder that raises NotImplementedError. + ODTC has no direct SetBlockTemperature command; this creates and runs a + PreMethod to set block and lid temperatures. Args: - temperature: Target temperature(s) in °C. + 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 MethodExecution handle. + debug_xml: If True, log generated XML at DEBUG. + xml_output_path: Optional path to save MethodSet XML. + **kwargs: Ignored (for API compatibility). + + Returns: + If wait=True: None. If wait=False: MethodExecution handle. """ - raise NotImplementedError( - "ODTC doesn't support direct block temperature setting. " - "Use ExecuteMethod with a PreMethod or Method instead." + if not temperature: + raise ValueError("At least one block temperature required") + block_temp = temperature[0] + if lid_temperature is not None: + target_lid_temp = lid_temperature + else: + constraints = self.get_constraints() + target_lid_temp = constraints.max_lid_temp + + resolved_name = resolve_protocol_name(None) + premethod = ODTCPreMethod( + name=resolved_name, + target_block_temperature=block_temp, + target_lid_temperature=target_lid_temp, + datetime=generate_odtc_timestamp(), + ) + await self.upload_premethod( + premethod, + allow_overwrite=True, + debug_xml=debug_xml, + xml_output_path=xml_output_path, + ) + return await self.execute_method( + resolved_name, + wait=wait, + estimated_duration_seconds=PREMETHOD_ESTIMATED_DURATION_SECONDS, ) async def set_lid_temperature(self, temperature: List[float]) -> None: """Set lid temperature. - Note: ODTC doesn't have a direct SetLidTemperature command. - Lid temperature is controlled via ExecuteMethod with PreMethod or Method. - This is a placeholder that raises NotImplementedError. + ODTC does not have a direct SetLidTemperature command; lid temperature is + set per protocol (ODTCConfig.lid_temperature) or inside a Method. Use + run_protocol() or run_stored_protocol() instead of run_pcr_profile(). Args: temperature: Target temperature(s) in °C. + + Raises: + NotImplementedError: ODTC does not support direct lid temperature setting. """ raise NotImplementedError( - "ODTC doesn't support direct lid temperature setting. " - "Use ExecuteMethod with a PreMethod or Method instead." + "ODTC does not support set_lid_temperature; lid temperature is set per " + "protocol or via ODTCConfig. Use run_protocol() or run_stored_protocol() " + "instead of run_pcr_profile()." ) async def deactivate_block(self) -> None: @@ -1101,19 +1426,53 @@ async def deactivate_lid(self) -> None: """Deactivate lid (maps to StopMethod).""" await self.stop_method() - async def run_protocol(self, protocol: Protocol, block_max_volume: float) -> None: - """Execute thermocycler protocol. + async def run_protocol( + self, + protocol: Protocol, + block_max_volume: float, + **kwargs: Any, + ) -> MethodExecution: + """Execute thermocycler protocol (convert, upload, execute). - Note: This requires converting Protocol to ODTCMethod and uploading it. - For now, this is a placeholder. + Converts Protocol to ODTCMethod, uploads it, then executes by name. + Always returns immediately with a MethodExecution handle; to block until + completion, await handle.wait() or use wait_for_profile_completion(). + Config is derived from block_max_volume and backend variant if not provided. Args: protocol: Protocol to execute. - block_max_volume: Maximum block volume (µL). + block_max_volume: Maximum block volume (µL) for safety; used to set + fluid_quantity when config is None. + **kwargs: Backend-specific options. ODTC accepts ``config`` (ODTCConfig, + optional); if omitted, built from block_max_volume and variant. + + Returns: + MethodExecution handle. Caller can await handle.wait() or + wait_for_profile_completion() to block until done. """ - raise NotImplementedError( - "Protocol execution requires converting Protocol to ODTCMethod. " - "Use protocol_to_odtc_method() from odtc_xml.py, then upload and execute." + config = kwargs.pop("config", None) + if config is None: + if block_max_volume > 0 and block_max_volume <= 100: + fluid_quantity = _volume_to_fluid_quantity(block_max_volume) + config = self.get_default_config(fluid_quantity=fluid_quantity) + else: + config = self.get_default_config() + if block_max_volume > 0 and block_max_volume <= 100: + _validate_volume_fluid_quantity( + block_max_volume, config.fluid_quantity, is_premethod=False, logger=self.logger + ) + else: + if block_max_volume > 0: + _validate_volume_fluid_quantity( + block_max_volume, config.fluid_quantity, is_premethod=False, logger=self.logger + ) + + method = protocol_to_odtc_method(protocol, config=config) + await self.upload_method(method, allow_overwrite=True, execute=False) + resolved_name = resolve_protocol_name(method.name) + eta = estimate_method_duration_seconds(method) + return await self.execute_method( + resolved_name, wait=False, estimated_duration_seconds=eta ) async def get_block_current_temperature(self) -> List[float]: @@ -1134,9 +1493,13 @@ async def get_block_target_temperature(self) -> List[float]: Raises: RuntimeError: If no target is set. """ - # ODTC doesn't expose target temperature directly - # Would need to query current method execution state - raise RuntimeError("Target temperature not available - method execution state not tracked") + # ODTC does not expose block target temperature; use is_method_running() or + # wait_for_method_completion() to monitor execution. + raise RuntimeError( + "ODTC does not report block target temperature; method execution state " + "not tracked. Use backend.is_method_running() or wait_for_method_completion() " + "to monitor execution." + ) async def get_lid_current_temperature(self) -> List[float]: """Get current lid temperature. @@ -1156,22 +1519,29 @@ async def get_lid_target_temperature(self) -> List[float]: Raises: RuntimeError: If no target is set. """ - # ODTC doesn't expose target temperature directly - raise RuntimeError("Target temperature not available - method execution state not tracked") + # ODTC does not expose lid target temperature; use is_method_running() or + # wait_for_method_completion() to monitor execution. + raise RuntimeError( + "ODTC does not report lid target temperature; method execution state " + "not tracked. Use backend.is_method_running() or wait_for_method_completion() " + "to monitor execution." + ) async def get_lid_open(self) -> bool: """Check if lid is open. + ODTC does not expose door open/closed state. Use open_door()/close_door() to + control the door; there is no query for current state. + Returns: True if lid/door is open. Raises: - NotImplementedError: Door status query not available. Call open_door() or - close_door() explicitly to control and confirm door state. + NotImplementedError: ODTC does not support querying lid/door open state. """ raise NotImplementedError( - "Door status query not available. Call open_door() or close_door() " - "explicitly to control and confirm door state." + "ODTC does not support get_lid_open; door status is not reported. Use " + "open_door() or close_door() to control the door." ) async def get_lid_status(self) -> LidStatus: @@ -1203,44 +1573,89 @@ async def get_block_status(self) -> BlockStatus: async def get_hold_time(self) -> float: """Get remaining hold time. + ODTC does not report per-step hold time. Use is_method_running() or + wait_for_method_completion() to monitor execution. + Returns: Remaining hold time in seconds. + + Raises: + NotImplementedError: ODTC does not report hold time. """ - # Not directly available from ODTC - would need method execution state - raise NotImplementedError("Hold time not available - method execution state not tracked") + raise NotImplementedError( + "ODTC does not report remaining hold time; method execution state not " + "tracked. Use is_method_running() or wait_for_method_completion() to " + "monitor execution." + ) async def get_current_cycle_index(self) -> int: """Get current cycle index. + ODTC does not report cycle/step indices. Use is_method_running() or + wait_for_method_completion() to monitor execution. + Returns: Zero-based cycle index. + + Raises: + NotImplementedError: ODTC does not report cycle index. """ - # Not directly available from ODTC - would need method execution state - raise NotImplementedError("Cycle index not available - method execution state not tracked") + raise NotImplementedError( + "ODTC does not report current cycle index; method execution state not " + "tracked. Use is_method_running() or wait_for_method_completion() to " + "monitor execution." + ) async def get_total_cycle_count(self) -> int: """Get total cycle count. + ODTC does not report cycle/step counts. Use is_method_running() or + wait_for_method_completion() to monitor execution. + Returns: Total number of cycles. + + Raises: + NotImplementedError: ODTC does not report total cycle count. """ - # Not directly available from ODTC - would need method execution state - raise NotImplementedError("Cycle count not available - method execution state not tracked") + raise NotImplementedError( + "ODTC does not report total cycle count; method execution state not " + "tracked. Use is_method_running() or wait_for_method_completion() to " + "monitor execution." + ) async def get_current_step_index(self) -> int: """Get current step index. + ODTC does not report cycle/step indices. Use is_method_running() or + wait_for_method_completion() to monitor execution. + Returns: Zero-based step index. + + Raises: + NotImplementedError: ODTC does not report current step index. """ - # Not directly available from ODTC - would need method execution state - raise NotImplementedError("Step index not available - method execution state not tracked") + raise NotImplementedError( + "ODTC does not report current step index; method execution state not " + "tracked. Use is_method_running() or wait_for_method_completion() to " + "monitor execution." + ) async def get_total_step_count(self) -> int: """Get total step count. + ODTC does not report cycle/step counts. Use is_method_running() or + wait_for_method_completion() to monitor execution. + Returns: Total number of steps. + + Raises: + NotImplementedError: ODTC does not report total step count. """ - # Not directly available from ODTC - would need method execution state - raise NotImplementedError("Step count not available - method execution state not tracked") + raise NotImplementedError( + "ODTC does not report total step count; method execution state not " + "tracked. Use is_method_running() or wait_for_method_completion() to " + "monitor execution." + ) diff --git a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py index 73268874ed0..645d29a298c 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend_tests.py @@ -7,14 +7,132 @@ from unittest.mock import AsyncMock, MagicMock, patch from pylabrobot.thermocycling.inheco.odtc_backend import CommandExecution, MethodExecution, ODTCBackend +from pylabrobot.thermocycling.inheco.odtc_model import ( + ODTCMethod, + ODTCMethodSet, + ODTC_DIMENSIONS, + ODTCPreMethod, + ODTCStep, + PREMETHOD_ESTIMATED_DURATION_SECONDS, + StoredProtocol, + estimate_method_duration_seconds, + normalize_variant, +) +from pylabrobot.thermocycling.inheco.odtc_thermocycler import ODTCThermocycler +from pylabrobot.resources import Coordinate from pylabrobot.thermocycling.inheco.odtc_sila_interface import ( SiLATimeoutError, ODTCSiLAInterface, SiLAState, - _parse_iso8601_duration_seconds, ) +class TestNormalizeVariant(unittest.TestCase): + """Tests for normalize_variant (96/384 -> 960000/384000).""" + + def test_96_maps_to_960000(self): + self.assertEqual(normalize_variant(96), 960000) + + def test_384_maps_to_384000(self): + self.assertEqual(normalize_variant(384), 384000) + + def test_3840000_normalizes_to_384000(self): + self.assertEqual(normalize_variant(3840000), 384000) + + def test_invalid_raises(self): + with self.assertRaises(ValueError) as cm: + normalize_variant(123) + self.assertIn("123", str(cm.exception)) + self.assertIn("Valid", str(cm.exception)) + + +class 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.""" + method = ODTCMethod(name="empty", start_block_temperature=20.0, steps=[]) + self.assertEqual(estimate_method_duration_seconds(method), 0.0) + + def test_single_step_no_loop(self): + """Single step: ramp + plateau + overshoot. Ramp = |95 - 20| / 4.4 ≈ 17.045 s.""" + method = ODTCMethod( + 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, + ), + ], + ) + # Ramp: 75 / 4.4 ≈ 17.045; plateau: 30; overshoot: 5 + got = estimate_method_duration_seconds(method) + 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.""" + method = ODTCMethod( + 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, + ), + ], + ) + # Ramp: 75 / 0.1 = 750 s (clamped); plateau: 10 + got = estimate_method_duration_seconds(method) + 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.""" + method = ODTCMethod( + 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 + ), + ], + ) + # Execution: step1, step2, step1, step2 + # Step1: ramp 75/4.4 + 10; step2: ramp 35/2.2 + 5; step1 again: 35/4.4 + 10; step2 again: 35/2.2 + 5 + got = estimate_method_duration_seconds(method) + self.assertGreater(got, 0) + self.assertLess(got, 1000) + + class TestODTCSiLAInterface(unittest.IsolatedAsyncioTestCase): """Tests for ODTCSiLAInterface.""" @@ -153,18 +271,6 @@ def test_get_terminal_state(self): self.assertEqual(self.interface._get_terminal_state("ExecuteMethod"), "idle") - def test_parse_iso8601_duration_seconds(self): - """Test ISO 8601 duration parsing (seconds, minutes, hours).""" - self.assertEqual(_parse_iso8601_duration_seconds("PT30.7S"), 30.7) - self.assertEqual(_parse_iso8601_duration_seconds("PT30M"), 30 * 60) - self.assertEqual(_parse_iso8601_duration_seconds("PT2H"), 2 * 3600) - self.assertEqual(_parse_iso8601_duration_seconds("PT1H30M10S"), 3600 + 30 * 60 + 10) - self.assertEqual(_parse_iso8601_duration_seconds("PT0.1S"), 0.1) - self.assertIsNone(_parse_iso8601_duration_seconds("")) - self.assertIsNone(_parse_iso8601_duration_seconds("invalid")) - self.assertEqual(_parse_iso8601_duration_seconds("P1D"), 86400) - - # Minimal SOAP responses for dual-track tests (return_code 2 = async accepted; no duration = poll immediately). _OPEN_DOOR_ASYNC_RESPONSE = b""" @@ -191,19 +297,6 @@ def test_parse_iso8601_duration_seconds(self): """ -_OPEN_DOOR_ASYNC_RESPONSE_WITH_DURATION = b""" - - - - - 2 - Accepted - PT0.1S - - - -""" - _GET_STATUS_BUSY_RESPONSE = b""" @@ -236,13 +329,15 @@ def mock_urlopen(req): cm.__exit__.return_value = None return cm - with patch("urllib.request.urlopen", side_effect=mock_urlopen): - # Lifetime must exceed POLLING_START_BUFFER (10s) so polling can run before timeout. + with patch("urllib.request.urlopen", side_effect=mock_urlopen), patch( + "pylabrobot.thermocycling.inheco.odtc_sila_interface.POLLING_START_BUFFER", 0.05 + ): + # Short POLLING_START_BUFFER in test so we don't wait 10s; lifetime still allows polling to run. interface = ODTCSiLAInterface( machine_ip="192.168.1.100", client_ip="127.0.0.1", - poll_interval=0.05, - lifetime_of_execution=15.0, + poll_interval=0.02, + lifetime_of_execution=2.0, on_response_event_missing="warn_and_continue", ) interface._current_state = SiLAState.IDLE @@ -266,12 +361,15 @@ def mock_urlopen(req): cm.__exit__.return_value = None return cm - with patch("urllib.request.urlopen", side_effect=mock_urlopen): + with patch("urllib.request.urlopen", side_effect=mock_urlopen), patch( + "pylabrobot.thermocycling.inheco.odtc_sila_interface.POLLING_START_BUFFER", 0.02 + ): + # Short POLLING_START_BUFFER so timeout (0.5s) is hit quickly instead of waiting 10s. interface = ODTCSiLAInterface( machine_ip="192.168.1.100", client_ip="127.0.0.1", - poll_interval=0.2, - lifetime_of_execution=0.5, + poll_interval=0.05, + lifetime_of_execution=0.2, on_response_event_missing="warn_and_continue", ) interface._current_state = SiLAState.IDLE @@ -295,6 +393,14 @@ def setUp(self): self.backend._sila._lifetime_of_execution = None self.backend._sila._client_ip = "127.0.0.1" + def test_backend_odtc_ip_property(self): + """Backend.odtc_ip returns machine IP from sila.""" + self.assertEqual(self.backend.odtc_ip, "192.168.1.100") + + def test_backend_variant_property(self): + """Backend.variant returns normalized variant (default 960000).""" + self.assertEqual(self.backend.variant, 960000) + async def test_setup(self): """Test backend setup.""" self.backend._sila.setup = AsyncMock() # type: ignore[method-assign] @@ -366,12 +472,18 @@ async def test_read_temperatures(self): self.assertAlmostEqual(sensor_values.lid, 25.75, places=2) # 2575 * 0.01 async def test_execute_method(self): - """Test execute_method.""" - self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign] - await self.backend.execute_method("MyMethod") - self.backend._sila.send_command.assert_called_once_with( - "ExecuteMethod", methodName="MyMethod" + """Test execute_method with wait=True; uses start_command then await handle, returns MethodExecution.""" + fut: asyncio.Future[Any] = asyncio.Future() + fut.set_result(None) + self.backend._sila.start_command = AsyncMock( # type: ignore[method-assign] + return_value=(fut, 12345, None, 0.0) ) + result = await self.backend.execute_method("MyMethod", wait=True) + self.assertIsInstance(result, MethodExecution) + self.assertEqual(result.method_name, "MyMethod") + self.backend._sila.start_command.assert_called_once() + call_kwargs = self.backend._sila.start_command.call_args[1] + self.assertEqual(call_kwargs["methodName"], "MyMethod") async def test_stop_method(self): """Test stop_method.""" @@ -430,15 +542,6 @@ async def test_get_lid_current_temperature(self): self.assertEqual(len(temps), 1) self.assertAlmostEqual(temps[0], 26.0, places=2) - async def test_execute_method_wait_true(self): - """Test execute_method with wait=True (blocking).""" - self.backend._sila.send_command = AsyncMock(return_value=None) # type: ignore[method-assign] - result = await self.backend.execute_method("PCR_30cycles", wait=True) - self.assertIsNone(result) - self.backend._sila.send_command.assert_called_once_with( - "ExecuteMethod", methodName="PCR_30cycles" - ) - async def test_execute_method_wait_false(self): """Test execute_method with wait=False (returns handle).""" fut: asyncio.Future[Any] = asyncio.Future() @@ -449,12 +552,12 @@ async def test_execute_method_wait_false(self): self.assertIsInstance(execution, MethodExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.method_name, "PCR_30cycles") - self.backend._sila.start_command.assert_called_once_with( - "ExecuteMethod", methodName="PCR_30cycles" - ) + self.backend._sila.start_command.assert_called_once() + call_kwargs = self.backend._sila.start_command.call_args[1] + self.assertEqual(call_kwargs["methodName"], "PCR_30cycles") async def test_method_execution_awaitable(self): - """Test that MethodExecution is awaitable.""" + """Test that MethodExecution is awaitable and wait() completes.""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result("success") execution = MethodExecution( @@ -466,18 +569,6 @@ async def test_method_execution_awaitable(self): ) result = await execution self.assertEqual(result, "success") - - async def test_method_execution_wait(self): - """Test MethodExecution.wait() method.""" - fut: asyncio.Future[Any] = asyncio.Future() - fut.set_result(None) - execution = MethodExecution( - request_id=12345, - command_name="ExecuteMethod", - method_name="PCR_30cycles", - _future=fut, - backend=self.backend - ) await execution.wait() # Should not raise async def test_method_execution_is_running(self): @@ -524,7 +615,7 @@ async def test_method_execution_inheritance(self): self.assertEqual(execution.method_name, "PCR_30cycles") async def test_command_execution_awaitable(self): - """Test that CommandExecution is awaitable.""" + """Test that CommandExecution is awaitable and wait() completes.""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result("success") execution = CommandExecution( @@ -535,17 +626,6 @@ async def test_command_execution_awaitable(self): ) result = await execution self.assertEqual(result, "success") - - async def test_command_execution_wait(self): - """Test CommandExecution.wait() method.""" - fut: asyncio.Future[Any] = asyncio.Future() - fut.set_result(None) - execution = CommandExecution( - request_id=12345, - command_name="OpenDoor", - _future=fut, - backend=self.backend - ) await execution.wait() # Should not raise async def test_command_execution_get_data_events(self): @@ -566,8 +646,8 @@ async def test_command_execution_get_data_events(self): self.assertEqual(len(events), 2) self.assertEqual(events[0]["requestId"], 12345) - async def test_open_door_wait_false(self): - """Test open_door with wait=False (returns handle).""" + async def test_open_door_wait_false_returns_command_execution(self): + """Test open_door with wait=False returns CommandExecution handle.""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] @@ -576,41 +656,11 @@ async def test_open_door_wait_false(self): self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "OpenDoor") - self.backend._sila.start_command.assert_called_once_with("OpenDoor") - - async def test_open_door_wait_true(self): - """Test open_door with wait=True (blocking).""" - self.backend._sila.send_command = AsyncMock(return_value=None) # type: ignore[method-assign] - result = await self.backend.open_door(wait=True) - self.assertIsNone(result) - self.backend._sila.send_command.assert_called_once_with("OpenDoor") - - async def test_close_door_wait_false(self): - """Test close_door with wait=False (returns handle).""" - fut: asyncio.Future[Any] = asyncio.Future() - fut.set_result(None) - self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] - execution = await self.backend.close_door(wait=False) - assert execution is not None # Type narrowing - self.assertIsInstance(execution, CommandExecution) - self.assertEqual(execution.request_id, 12345) - self.assertEqual(execution.command_name, "CloseDoor") - self.backend._sila.start_command.assert_called_once_with("CloseDoor") - - async def test_initialize_wait_false(self): - """Test initialize with wait=False (returns handle).""" - fut: asyncio.Future[Any] = asyncio.Future() - fut.set_result(None) - self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] - execution = await self.backend.initialize(wait=False) - assert execution is not None # Type narrowing - self.assertIsInstance(execution, CommandExecution) - self.assertEqual(execution.request_id, 12345) - self.assertEqual(execution.command_name, "Initialize") - self.backend._sila.start_command.assert_called_once_with("Initialize") + self.backend._sila.start_command.assert_called_once() + self.assertEqual(self.backend._sila.start_command.call_args[0][0], "OpenDoor") - async def test_reset_wait_false(self): - """Test reset with wait=False (returns handle).""" + async def test_reset_wait_false_returns_handle_with_kwargs(self): + """Test reset with wait=False returns CommandExecution and passes deviceId/eventReceiverURI.""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] @@ -625,45 +675,6 @@ async def test_reset_wait_false(self): self.assertEqual(call_kwargs["eventReceiverURI"], "http://127.0.0.1:8080/") self.assertFalse(call_kwargs["simulationMode"]) - async def test_lock_device_wait_false(self): - """Test lock_device with wait=False (returns handle).""" - fut: asyncio.Future[Any] = asyncio.Future() - fut.set_result(None) - self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] - execution = await self.backend.lock_device("my_lock", wait=False) - assert execution is not None # Type narrowing - self.assertIsInstance(execution, CommandExecution) - self.assertEqual(execution.request_id, 12345) - self.assertEqual(execution.command_name, "LockDevice") - self.backend._sila.start_command.assert_called_once_with( - "LockDevice", lock_id="my_lock", lockId="my_lock", PMSId="PyLabRobot" - ) - - async def test_unlock_device_wait_false(self): - """Test unlock_device with wait=False (returns handle).""" - fut: asyncio.Future[Any] = asyncio.Future() - fut.set_result(None) - self.backend._sila._lock_id = "my_lock" - self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] - execution = await self.backend.unlock_device(wait=False) - assert execution is not None # Type narrowing - self.assertIsInstance(execution, CommandExecution) - self.assertEqual(execution.request_id, 12345) - self.assertEqual(execution.command_name, "UnlockDevice") - self.backend._sila.start_command.assert_called_once_with("UnlockDevice", lock_id="my_lock") - - async def test_stop_method_wait_false(self): - """Test stop_method with wait=False (returns handle).""" - fut: asyncio.Future[Any] = asyncio.Future() - fut.set_result(None) - self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] - execution = await self.backend.stop_method(wait=False) - assert execution is not None # Type narrowing - self.assertIsInstance(execution, CommandExecution) - self.assertEqual(execution.request_id, 12345) - self.assertEqual(execution.command_name, "StopMethod") - self.backend._sila.start_command.assert_called_once_with("StopMethod") - async def test_is_method_running(self): """Test is_method_running().""" with patch.object( @@ -725,6 +736,171 @@ async def test_get_data_events(self): self.assertEqual(len(events), 1) self.assertEqual(len(events[99999]), 0) + async def test_list_protocols(self): + """Test list_protocols returns method and premethod names.""" + method_set = ODTCMethodSet( + methods=[ODTCMethod(name="PCR_30")], + premethods=[ODTCPreMethod(name="Pre25")], + ) + self.backend.get_method_set = AsyncMock(return_value=method_set) # type: ignore[method-assign] + names = await self.backend.list_protocols() + self.assertEqual(names, ["PCR_30", "Pre25"]) + + 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=[ODTCPreMethod(name="Pre25")], + ) + 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 StoredProtocol for runnable method.""" + method_set = ODTCMethodSet( + methods=[ + ODTCMethod( + name="PCR_30", + steps=[ODTCStep(number=1, plateau_temperature=95.0, plateau_time=30.0)], + ) + ], + 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, StoredProtocol) + assert result is not None # narrow for type checker + self.assertEqual(result.name, "PCR_30") + self.assertEqual(len(result.protocol.stages), 1) + self.assertEqual(len(result.protocol.stages[0].steps), 1) + + async def test_run_stored_protocol_calls_execute_method(self): + """Test run_stored_protocol calls execute_method with name, wait, and estimated_duration_seconds.""" + self.backend.execute_method = AsyncMock(return_value=None) # type: ignore[method-assign] + with patch.object( + self.backend, "get_protocol", new_callable=AsyncMock, return_value=None + ), patch.object( + self.backend, "get_method_set", new_callable=AsyncMock, return_value=ODTCMethodSet() + ): + await self.backend.run_stored_protocol("MyMethod", wait=True) + self.backend.execute_method.assert_called_once_with( + "MyMethod", wait=True, estimated_duration_seconds=None + ) + + +class TestODTCThermocycler(unittest.TestCase): + """Tests for ODTCThermocycler resource.""" + + def test_construct_creates_backend_and_uses_dimensions(self): + """Constructing with odtc_ip and variant creates ODTCBackend and ODTC dimensions.""" + with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"): + tc = ODTCThermocycler( + name="odtc1", + odtc_ip="192.168.1.100", + variant=384, + child_location=Coordinate.zero(), + ) + self.assertIsInstance(tc.backend, ODTCBackend) + self.assertEqual(tc.backend.variant, 384000) + self.assertEqual(tc.get_size_x(), ODTC_DIMENSIONS.x) + self.assertEqual(tc.get_size_y(), ODTC_DIMENSIONS.y) + self.assertEqual(tc.get_size_z(), ODTC_DIMENSIONS.z) + self.assertEqual(tc.model, "ODTC 384") + + def test_construct_variant_96_model(self): + """Constructing with variant=96 sets model ODTC 96.""" + with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"): + tc = ODTCThermocycler(name="tc", odtc_ip="192.168.1.1", variant=96) + self.assertEqual(tc.backend.variant, 960000) + self.assertEqual(tc.model, "ODTC 96") + + def test_serialize_includes_odtc_ip_and_variant(self): + """serialize() includes odtc_ip and variant from backend.""" + with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"): + tc = ODTCThermocycler( + name="odtc1", + odtc_ip="192.168.1.50", + variant=384, + child_location=Coordinate.zero(), + ) + tc.backend._sila._machine_ip = "192.168.1.50" + data = tc.serialize() + self.assertEqual(data["odtc_ip"], "192.168.1.50") + self.assertEqual(data["variant"], 384000) + + def test_get_default_config_delegates_to_backend(self): + """get_default_config returns backend.get_default_config().""" + with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"): + tc = ODTCThermocycler(name="tc", odtc_ip="192.168.1.1", variant=384) + config = tc.get_default_config(name="MyPCR") + self.assertEqual(config.variant, 384000) + self.assertEqual(config.name, "MyPCR") + + def test_get_constraints_delegates_to_backend(self): + """get_constraints returns backend.get_constraints().""" + with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"): + tc = ODTCThermocycler(name="tc", odtc_ip="192.168.1.1", variant=384) + constraints = tc.get_constraints() + self.assertEqual(constraints.variant, 384000) + self.assertEqual(constraints.variant_name, "ODTC 384") + + def test_well_count_96(self): + """well_count is 96 when backend variant is 960000.""" + with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"): + tc = ODTCThermocycler(name="tc", odtc_ip="192.168.1.1", variant=96) + self.assertEqual(tc.well_count, 96) + + def test_well_count_384(self): + """well_count is 384 when backend variant is 384000.""" + with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"): + tc = ODTCThermocycler(name="tc", odtc_ip="192.168.1.1", variant=384) + self.assertEqual(tc.well_count, 384) + + def test_is_profile_running_delegates_to_backend(self): + """is_profile_running delegates to backend.is_method_running().""" + with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"): + tc = ODTCThermocycler(name="tc", odtc_ip="192.168.1.1", variant=384) + tc.backend.is_method_running = AsyncMock(return_value=False) # type: ignore[method-assign] + result = asyncio.run(tc.is_profile_running()) + self.assertFalse(result) + tc.backend.is_method_running.assert_called_once() + + def test_wait_for_profile_completion_delegates_to_backend(self): + """wait_for_profile_completion delegates to backend.wait_for_method_completion().""" + with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"): + tc = ODTCThermocycler(name="tc", odtc_ip="192.168.1.1", variant=384) + tc.backend.wait_for_method_completion = AsyncMock() # type: ignore[method-assign] + asyncio.run(tc.wait_for_profile_completion(poll_interval=5.0)) + tc.backend.wait_for_method_completion.assert_called_once_with( + poll_interval=5.0, + timeout=None, + ) + + def test_backend_provided_uses_it_dimensions_from_constant(self): + """When backend= is provided, that backend is used; dimensions still from ODTC_DIMENSIONS.""" + with patch("pylabrobot.thermocycling.inheco.odtc_backend.ODTCSiLAInterface"): + backend = ODTCBackend(odtc_ip="10.0.0.1", variant=384) + backend._sila = MagicMock(spec=ODTCSiLAInterface) + backend._sila._machine_ip = "10.0.0.1" + tc = ODTCThermocycler( + name="odtc1", + odtc_ip="192.168.1.1", + variant=384, + backend=backend, + child_location=Coordinate.zero(), + ) + self.assertIs(tc.backend, backend) + self.assertEqual(tc.get_size_x(), ODTC_DIMENSIONS.x) + self.assertEqual(tc.get_size_y(), ODTC_DIMENSIONS.y) + self.assertEqual(tc.get_size_z(), ODTC_DIMENSIONS.z) + class TestODTCSiLAInterfaceDataEvents(unittest.TestCase): """Tests for DataEvent storage in ODTCSiLAInterface.""" diff --git a/pylabrobot/thermocycling/inheco/odtc_xml.py b/pylabrobot/thermocycling/inheco/odtc_model.py similarity index 91% rename from pylabrobot/thermocycling/inheco/odtc_xml.py rename to pylabrobot/thermocycling/inheco/odtc_model.py index cc230197835..d693f6f9571 100644 --- a/pylabrobot/thermocycling/inheco/odtc_xml.py +++ b/pylabrobot/thermocycling/inheco/odtc_model.py @@ -1,8 +1,9 @@ """ -Schema-driven XML serialization for ODTC MethodSet. +ODTC model: domain types, XML serialization, and Protocol conversion. -Uses dataclass field metadata to define XML mapping, enabling automatic -bidirectional conversion between Python objects and XML. +Defines ODTC dataclasses (ODTCMethod, ODTCPreMethod, ODTCConfig, etc.), +schema-driven XML serialization for MethodSet, and conversion between +PyLabRobot Protocol and ODTC method representation. """ from __future__ import annotations @@ -67,6 +68,21 @@ def resolve_protocol_name(name: Optional[str]) -> str: # ============================================================================= +@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=147.0, y=298.0, z=130.0) + +# 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. @@ -130,6 +146,35 @@ def get_constraints(variant: int) -> ODTCHardwareConstraints: return _CONSTRAINTS_MAP[variant] +_VALID_VARIANTS = (96, 384, 960000, 384000, 3840000) + + +def normalize_variant(variant: int) -> int: + """Normalize variant to ODTC device code. + + Accepts well count (96, 384) or device codes (960000, 384000, 3840000). + Maps 96 -> 960000, 384 -> 384000; passes through 960000, 384000, 3840000 unchanged. + + Args: + variant: Well count (96, 384) or ODTC variant code (960000, 384000, 3840000). + + Returns: + ODTC variant code: 960000 or 384000. + + Raises: + ValueError: If variant is not one of 96, 384, 960000, 384000, 3840000. + """ + if variant == 96: + return 960000 + if variant in (384, 3840000): + return 384000 + if variant in (960000, 384000): + return variant + raise ValueError( + f"Unknown variant {variant}. Valid: {list(_VALID_VARIANTS)}" + ) + + # ============================================================================= # XML Field Metadata # ============================================================================= @@ -425,6 +470,19 @@ def validate(self) -> List[str]: return errors +@dataclass +class StoredProtocol: + """A protocol stored on the device, with instrument config for running it. + + Returned by backend get_protocol(name). Use stored.protocol and stored.config + to inspect or run via run_protocol(stored.protocol, block_max_volume, config=stored.config). + """ + + name: str + protocol: "Protocol" + config: ODTCConfig + + # ============================================================================= # Generic XML Serialization/Deserialization # ============================================================================= @@ -1004,6 +1062,67 @@ def _analyze_loop_structure( return sorted(loops, key=lambda x: x[1]) # Sort by end position +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 estimate_method_duration_seconds(method: ODTCMethod) -> 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. Ramp time = |delta T| / slope; + slope is clamped to avoid division by zero. + + Args: + method: ODTCMethod with steps and start_block_temperature. + + Returns: + Estimated duration in seconds. + """ + if not method.steps: + return 0.0 + loops = _analyze_loop_structure(method.steps) + step_nums = _expand_step_sequence(method.steps, loops) + steps_by_num = {s.number: s for s in method.steps} + + total = 0.0 + prev_temp = method.start_block_temperature + min_slope = 0.1 # Avoid division by zero + + 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 + + def odtc_method_to_protocol( method: ODTCMethod, ) -> Tuple["Protocol", ODTCConfig]: diff --git a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py index ae6030edae3..96e9fd4da6e 100644 --- a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py +++ b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py @@ -12,7 +12,6 @@ import asyncio import logging -import re import time import urllib.request from dataclasses import dataclass @@ -158,41 +157,6 @@ class SiLAState(str, Enum): POLLING_START_BUFFER: float = 10.0 -def _parse_iso8601_duration_seconds(duration_str: str) -> Optional[float]: - """Parse ISO 8601 duration string to total seconds (supports D, H, M, S). - - Examples: PT30.7S, PT30M, PT2H, PT1H30M10S, P1DT2H30M10.5S. - H, M, S are only parsed in the time part (after T) so P1M (month) is not treated as minutes. - Returns None if parsing fails. - """ - if not isinstance(duration_str, str) or not duration_str.strip(): - return None - total = 0.0 - # Days: P1D - d_match = re.search(r"(\d+(?:\.\d+)?)D", duration_str, re.IGNORECASE) - if d_match: - total += float(d_match.group(1)) * 86400 - # Time part (after T): H, M, S - time_part = re.search(r"T(.+)$", duration_str) - if time_part: - t = time_part.group(1) - h_match = re.search(r"(\d+(?:\.\d+)?)H", t, re.IGNORECASE) - if h_match: - total += float(h_match.group(1)) * 3600 - m_match = re.search(r"(\d+(?:\.\d+)?)M", t, re.IGNORECASE) - if m_match: - total += float(m_match.group(1)) * 60 - s_match = re.search(r"(\d+(?:\.\d+)?)S", t, re.IGNORECASE) - if s_match: - total += float(s_match.group(1)) - else: - # No T: e.g. PT30.7S might be written as P30.7S in some variants; treat S only - s_match = re.search(r"(\d+(?:\.\d+)?)S", duration_str, re.IGNORECASE) - if s_match: - total += float(s_match.group(1)) - return total if total > 0 else None - - @dataclass(frozen=True) class PendingCommand: """Tracks a pending async command.""" @@ -201,7 +165,7 @@ class PendingCommand: request_id: int fut: asyncio.Future[Any] started_at: float - estimated_remaining_time: Optional[float] = None # Seconds from device duration (ISO 8601) + estimated_remaining_time: Optional[float] = None # Caller-provided estimate (seconds) lock_id: Optional[str] = None # LockId sent with LockDevice command (for tracking) @@ -743,6 +707,9 @@ async def _execute_command( if self._closed: raise RuntimeError("Interface is closed") + # Caller-provided estimate; must not be sent to device. + estimated_duration_seconds: Optional[float] = kwargs.pop("estimated_duration_seconds", None) + if command != "GetStatus": self._validate_lock_id(lock_id) @@ -819,11 +786,7 @@ def _do_request() -> bytes: if return_code == 2: fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future() - result = decoded.get(f"{command}Response", {}).get(f"{command}Result", {}) - duration_str = result.get("duration") - estimated_remaining_time: Optional[float] = None - if duration_str: - estimated_remaining_time = _parse_iso8601_duration_seconds(str(duration_str)) + estimated_remaining_time: Optional[float] = estimated_duration_seconds pending_lock_id = None if command == "LockDevice" and "lockId" in params: @@ -957,7 +920,9 @@ async def start_command( Args: command: Command name (must be an async command). lock_id: LockId (defaults to None, validated if device is locked). - **kwargs: Additional command parameters. + **kwargs: Additional command parameters. May include estimated_duration_seconds + (optional float, seconds); it is used as estimated_remaining_time on the handle + and is not sent to the device. Returns: (future, request_id, estimated_remaining_time, started_at) tuple. diff --git a/pylabrobot/thermocycling/inheco/odtc_thermocycler.py b/pylabrobot/thermocycling/inheco/odtc_thermocycler.py new file mode 100644 index 00000000000..9b769535999 --- /dev/null +++ b/pylabrobot/thermocycling/inheco/odtc_thermocycler.py @@ -0,0 +1,117 @@ +"""ODTC thermocycler resource: subclass that owns connection params and dimensions.""" + +from __future__ import annotations + +from typing import Any, Optional + +from pylabrobot.resources import Coordinate, ItemizedResource +from pylabrobot.thermocycling.thermocycler import Thermocycler + +from .odtc_backend import ODTCBackend +from .odtc_model import ODTCConfig, ODTCHardwareConstraints, ODTC_DIMENSIONS + + +def _model_from_variant(variant: int) -> str: + """Return model string from ODTC variant code.""" + if variant == 960000: + return "ODTC 96" + if variant == 384000: + return "ODTC 384" + return "ODTC" + + +class ODTCThermocycler(Thermocycler): + """Inheco ODTC thermocycler resource. + + Owns connection params (odtc_ip, variant) and creates ODTCBackend by default. + Dimensions (147 x 298 x 130 mm) are set from ODTC_DIMENSIONS. + """ + + def __init__( + self, + name: str, + odtc_ip: str, + variant: int = 384, + child_location: Coordinate = Coordinate.zero(), + child: Optional[ItemizedResource] = None, + backend: Optional[ODTCBackend] = None, + **backend_kwargs: Any, + ): + """Initialize ODTC thermocycler. + + Args: + name: Human-readable name. + odtc_ip: IP address of the ODTC device. + variant: Well count (96, 384) or ODTC variant code (960000, 384000, 3840000). + Normalized via backend; default 384. + child_location: Position where a plate sits on the block. + child: Optional plate/rack already loaded on the module. + backend: Optional pre-constructed ODTCBackend; if None, one is created + from odtc_ip, variant, and backend_kwargs. + **backend_kwargs: Passed to ODTCBackend when backend is None (e.g. client_ip, + logger, poll_interval, lifetime_of_execution, on_response_event_missing). + """ + backend = backend or ODTCBackend(odtc_ip=odtc_ip, variant=variant, **backend_kwargs) + model = _model_from_variant(backend.variant) + super().__init__( + name=name, + size_x=ODTC_DIMENSIONS.x, + size_y=ODTC_DIMENSIONS.y, + size_z=ODTC_DIMENSIONS.z, + backend=backend, + child_location=child_location, + category="thermocycler", + model=model, + ) + self.backend: ODTCBackend = backend + self.child = child + if child is not None: + self.assign_child_resource(child, location=child_location) + + def serialize(self) -> dict: + """Return a serialized representation of the thermocycler.""" + return { + **super().serialize(), + "odtc_ip": self.backend.odtc_ip, + "variant": self.backend.variant, + } + + def get_default_config(self, **kwargs: Any) -> ODTCConfig: + """Get default ODTCConfig for this backend's variant. Delegates to backend.""" + return self.backend.get_default_config(**kwargs) + + def get_constraints(self) -> ODTCHardwareConstraints: + """Get hardware constraints for this backend's variant. Delegates to backend.""" + return self.backend.get_constraints() + + @property + def well_count(self) -> int: + """Well count (96 or 384) from backend variant.""" + if self.backend.variant == 960000: + return 96 + return 384 + + async def is_profile_running(self, **backend_kwargs: Any) -> bool: + """Return True if a profile (method) is still running. + + For ODTC, this uses device busy state (GetStatus) via + backend.is_method_running(); ODTC does not report per-step/cycle progress. + """ + return await self.backend.is_method_running() + + async def wait_for_profile_completion( + self, + poll_interval: float = 60.0, + **backend_kwargs: Any, + ) -> None: + """Block until the profile (method) finishes. + + For ODTC, this delegates to backend.wait_for_method_completion(), which + polls GetStatus until the device returns to idle. Pass timeout=... in + backend_kwargs to limit wait time. + """ + timeout = backend_kwargs.get("timeout") + await self.backend.wait_for_method_completion( + poll_interval=poll_interval, + timeout=timeout, + ) diff --git a/pylabrobot/thermocycling/opentrons_backend.py b/pylabrobot/thermocycling/opentrons_backend.py index f2b444c786d..f6ace7f85a6 100644 --- a/pylabrobot/thermocycling/opentrons_backend.py +++ b/pylabrobot/thermocycling/opentrons_backend.py @@ -91,7 +91,9 @@ async def deactivate_lid(self): """Deactivate the lid heater.""" return thermocycler_deactivate_lid(module_id=self.opentrons_id) - async def run_protocol(self, protocol: Protocol, block_max_volume: float): + async def run_protocol( + self, protocol: Protocol, block_max_volume: float, **kwargs + ): """Enqueue and return immediately (no wait) the PCR profile command.""" # flatten the protocol to a list of Steps diff --git a/pylabrobot/thermocycling/opentrons_backend_usb.py b/pylabrobot/thermocycling/opentrons_backend_usb.py index 41daf9a002c..481a8edc6de 100644 --- a/pylabrobot/thermocycling/opentrons_backend_usb.py +++ b/pylabrobot/thermocycling/opentrons_backend_usb.py @@ -166,7 +166,9 @@ async def _execute_cycles( volume=volume, ) - async def run_protocol(self, protocol: Protocol, block_max_volume: float): + async def run_protocol( + self, protocol: Protocol, block_max_volume: float, **kwargs + ): """Execute thermocycler protocol using similar execution logic from thermocycler.py. Implements specific to opentrons thermocycler: diff --git a/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py b/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py index 2f036676ce2..349f2653303 100644 --- a/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py +++ b/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py @@ -927,6 +927,7 @@ async def run_protocol( cover_enabled=True, protocol_name: str = "PCR_Protocol", stage_name_prefixes: Optional[List[str]] = None, + **kwargs, ): assert block_id is not None, "block_id must be specified" diff --git a/pylabrobot/thermocycling/thermocycler.py b/pylabrobot/thermocycling/thermocycler.py index 622599d47a2..dd2bd46b59e 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,14 +83,29 @@ 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. block_max_volume: Maximum block volume (µL) for safety. - """ + **backend_kwargs: Backend-specific options (e.g. ODTC accepts + config=ODTCConfig). + 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(). + """ num_zones = len(protocol.stages[0].steps[0].temperature) for stage in protocol.stages: for i, step in enumerate(stage.steps): @@ -96,7 +115,31 @@ async def run_protocol(self, protocol: Protocol, block_max_volume: float, **back f"Expected {num_zones}, got {len(step.temperature)} in step {i}." ) - return await self.backend.run_protocol(protocol, block_max_volume, **backend_kwargs) + 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, @@ -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) From 44ab52a34aadd59dd0273f8b5e2bb05297da2b90 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:04:18 -0800 Subject: [PATCH 13/28] Reconnect without Reset. Simulation Mode. Clean up Prints for temperatures/waits. Docs+Formatting --- pylabrobot/thermocycling/inheco/README.md | 49 ++-- .../thermocycling/inheco/odtc_backend.py | 119 ++++++++-- pylabrobot/thermocycling/inheco/odtc_model.py | 58 ++++- .../{odtc_backend_tests.py => odtc_tests.py} | 84 ++++++- .../thermocycling/inheco/odtc_tutorial.ipynb | 224 ++++++++++++++++++ 5 files changed, 490 insertions(+), 44 deletions(-) rename pylabrobot/thermocycling/inheco/{odtc_backend_tests.py => odtc_tests.py} (90%) create mode 100644 pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb diff --git a/pylabrobot/thermocycling/inheco/README.md b/pylabrobot/thermocycling/inheco/README.md index 8d01ca68135..8931f2808c1 100644 --- a/pylabrobot/thermocycling/inheco/README.md +++ b/pylabrobot/thermocycling/inheco/README.md @@ -4,7 +4,7 @@ Interface for Inheco ODTC thermocyclers via SiLA (SOAP over HTTP). Supports asynchronous method execution (blocking and non-blocking), round-trip protocol conversion (ODTC XML ↔ PyLabRobot `Protocol` with lossless ODTC parameters), parallel commands (e.g. read temperatures during run), and DataEvent collection. -**New users:** Start with **Connection and Setup**, then **Recommended Workflows** (run by name, round-trip for thermal performance, set block/lid temp). Use **Running Commands** and **Getting Protocols** for async handles and device introspection; **XML to Protocol + Config** for conversion detail. +**New users:** Start with **Connection and Setup**, then **Recommended Workflows** (run by name, round-trip for thermal performance, set block/lid temp). A step-by-step tutorial notebook is in **`dev/odtc_tutorial.ipynb`**. Use **Running Commands** and **Getting Protocols** for async handles and device introspection; **XML to Protocol + Config** for conversion detail. ## Architecture @@ -36,10 +36,7 @@ Understanding the distinction between **Protocol** and **Method** is crucial for - Used to reference methods when executing: `await tc.run_protocol(method_name="PCR_30cycles")` - Can be a Method or PreMethod name (both are stored on the device) -### Key API - -- **Resource:** `tc.run_protocol(protocol, block_max_volume)` — in-memory (upload + execute); `tc.run_stored_protocol(name)` — by name (ODTC only). -- **Backend:** `tc.backend.list_protocols()`, `get_protocol(name)` (runnable methods only; premethods → `None`), `upload_protocol(...)`, `set_block_temperature(...)` (PreMethod), `get_default_config()`, `get_constraints()`, `execute_method(method_name)` (low-level). +**API:** Resource: `tc.run_protocol(protocol, block_max_volume)` or `tc.run_stored_protocol(name)`. Backend: `tc.backend.list_protocols()`, `get_protocol(name)` (runnable only; premethods → `None`), `upload_protocol(...)`, `set_block_temperature(...)`, `get_default_config()`, `execute_method(method_name)`. ## Connection and Setup @@ -71,6 +68,16 @@ await tc.setup() **Estimated duration:** The device does not return duration in the async response. We compute it: PreMethod = 10 min; Method = from steps (ramp + plateau + overshoot, with loops). This estimate is used for `handle.estimated_remaining_time`, when to start polling, and a tighter timeout cap. +**Setup options:** `setup(full=True, simulation_mode=False, max_attempts=3, retry_backoff_base_seconds=1.0)`. When `full=True` (default), the full path runs up to `max_attempts` times with exponential backoff on failure (e.g. flaky network). Use `max_attempts=1` to disable retry. Use `full=False` to only start the event receiver without resetting the device (see **Reconnecting after session loss** below). + +### Simulation mode + +Enter simulation mode: `await tc.backend.reset(simulation_mode=True)`. Exit: `await tc.backend.reset(simulation_mode=False)`. In simulation mode, commands return immediately with estimated duration; valid until the next Reset. Check state without resetting: `tc.backend.simulation_mode` reflects the last `reset(simulation_mode=...)` call. To bring the device up in simulation: `await tc.setup(simulation_mode=True)` (full path with simulation enabled). + +### Reconnecting after session loss + +If the session or connection was lost while a method is running, you can reconnect without aborting the method. Create a new backend (or thermocycler), then call `await tc.backend.setup(full=False)` to only start the event receiver—do **not** call full setup (that would Reset and abort the method). Then use `wait_for_completion_by_time(...)` or a persisted handle's `wait_resumable()` to wait for the in-flight method to complete. After the method is done, call `setup(full=True)` if you need a full session for subsequent commands. + ### Cleanup ```python @@ -162,8 +169,8 @@ Most ODTC commands are asynchronous and support both blocking and non-blocking e ```python # Block until command completes -await tc.open_door() # Returns None when complete -await tc.close_door() +await tc.open_lid() # Returns None when complete +await tc.close_lid() await tc.initialize() await tc.reset() ``` @@ -172,7 +179,7 @@ await tc.reset() ```python # Start command and get execution handle -door_opening = await tc.open_door(wait=False) +door_opening = await tc.open_lid(wait=False) # Returns CommandExecution handle immediately # Do other work while command runs @@ -192,7 +199,7 @@ await door_opening.wait() # Explicit wait method ```python # Non-blocking door operation -door_opening = await tc.open_door(wait=False) +door_opening = await tc.open_lid(wait=False) # Get DataEvents for this execution events = await door_opening.get_data_events() @@ -205,7 +212,7 @@ await door_opening - **Blocking:** `await tc.run_stored_protocol("PCR_30cycles")` or `await tc.run_protocol(protocol, block_max_volume=50.0)` (upload + execute). - **Non-blocking:** `execution = await tc.run_stored_protocol("PCR_30cycles", wait=False)`; then `await execution` or `await execution.wait()` or `await tc.wait_for_method_completion()`. -- While a method runs you can call `read_temperatures()`, `open_door(wait=False)`, etc. (parallel where allowed). +- While a method runs you can call `read_temperatures()`, `open_lid(wait=False)`, etc. (parallel where allowed). #### MethodExecution Handle @@ -259,9 +266,9 @@ execution = await tc.run_stored_protocol("PCR_30cycles", wait=False) # These can run in parallel: temps = await tc.read_temperatures() -door_opening = await tc.open_door(wait=False) +door_opening = await tc.open_lid(wait=False) -# Wait for door to complete +# Wait for lid to complete await door_opening # These will queue/wait: @@ -278,8 +285,8 @@ method2 = await tc.run_protocol(method_name="PCR_40cycles", wait=False) # Waits ```python # CommandExecution example -door_opening = await tc.open_door(wait=False) -await door_opening # Wait for door to open +door_opening = await tc.open_lid(wait=False) +await door_opening # Wait for lid to open # MethodExecution example (has additional features) method_exec = await tc.run_stored_protocol("PCR_30cycles", wait=False) @@ -301,6 +308,14 @@ for name in protocol_names: print(f"Protocol: {name}") ``` +### List Methods and PreMethods Separately + +```python +# Returns (method_names, premethod_names); methods are runnable, premethods are setup-only +methods, premethods = await tc.backend.list_methods() +# methods + premethods equals list_protocols() +``` + ### Get Runnable Protocol by Name ```python @@ -331,11 +346,17 @@ for premethod in method_set.premethods: # Get runnable protocol from device (StoredProtocol has protocol + config) stored = await tc.backend.get_protocol("PCR_30cycles") if stored: + print(stored) # Human-readable summary (name, stages, steps, config) # stored.protocol: Generic PyLabRobot Protocol (stages, steps, temperatures, times) # stored.config: ODTCConfig preserving all ODTC-specific parameters await tc.run_protocol(stored.protocol, block_max_volume=50.0, config=stored.config) ``` +### Display and logging + +- **StoredProtocol** and **ODTCSensorValues**: `print(stored)` and `print(await tc.backend.read_temperatures())` show labeled summaries. ODTCSensorValues `__str__` is multi-line for display; use `format_compact()` for single-line logs. +- **Wait messages**: When you `await handle`, `handle.wait()`, or `handle.wait_resumable()`, the message logged at INFO is multi-line (command, duration, remaining time) for clear console/notebook display. + ## Running Protocols (reference) - **By name:** See **Recommended Workflows → Run stored protocol by name**. `await tc.run_stored_protocol(name)` or `wait=False` for a handle. diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index be944a33d8a..b482d666672 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -5,7 +5,7 @@ import asyncio import logging from dataclasses import dataclass, replace -from typing import Any, Dict, List, Literal, Optional, Union, cast +from typing import Any, Dict, List, Literal, Optional, Tuple, Union, cast from pylabrobot.thermocycling.backend import ThermocyclerBackend from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol @@ -30,6 +30,8 @@ get_constraints, get_method_by_name, list_method_names, + list_method_names_only, + list_premethod_names, method_set_to_xml, normalize_variant, odtc_method_to_protocol, @@ -158,8 +160,9 @@ def status(self) -> str: def _log_wait_info(self) -> None: """Log command/method name, duration (lifetime), and remaining time (computed at call time). - Includes a timestamp so log history gives a clear sense of when each wait - was logged and what remaining time was at that moment, without re-querying. + Multi-line format for clear display in console/notebook. Includes a timestamp + so log history gives a clear sense of when each wait was logged and what + remaining time was at that moment, without re-querying. """ import time @@ -176,10 +179,14 @@ def _log_wait_info(self) -> None: remaining = max(0.0, lifetime - elapsed) if lifetime is not None else None ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now)) - msg = f"[{ts}] Waiting for {name}, duration (timeout)={lifetime}s" + lines = [ + f"[{ts}] Waiting for command", + f" Command: {name}", + f" Duration (timeout): {lifetime}s", + ] if remaining is not None: - msg += f", remaining={remaining:.0f}s" - self.backend.logger.info(msg) + lines.append(f" Remaining: {remaining:.0f}s") + self.backend.logger.info("\n".join(lines)) async def wait(self) -> None: """Wait for command completion. @@ -296,6 +303,7 @@ def __init__( super().__init__() self._variant = normalize_variant(variant) self._current_execution: Optional[MethodExecution] = None + self._simulation_mode: bool = False self._sila = ODTCSiLAInterface( machine_ip=odtc_ip, client_ip=client_ip, @@ -321,38 +329,84 @@ def current_execution(self) -> Optional[MethodExecution]: """Current method execution handle (set when a method is started with wait=False or wait=True).""" return self._current_execution + @property + def simulation_mode(self) -> bool: + """Whether the device is in simulation mode (from the last reset() call). + + Reflects the last simulation_mode passed to reset(); valid once that Reset + has completed (or immediately if wait=True). Use this to check state without + calling reset again. + """ + return self._simulation_mode + def _clear_current_execution_if(self, handle: MethodExecution) -> None: """Clear _current_execution only if it still refers to the given handle.""" if self._current_execution is handle: self._current_execution = None - async def setup(self) -> None: - """Prepare the ODTC connection and bring the device to idle. + async def setup( + self, + full: bool = True, + simulation_mode: bool = False, + max_attempts: int = 3, + retry_backoff_base_seconds: float = 1.0, + ) -> None: + """Prepare the ODTC connection. - Performs the full SiLA connection lifecycle: - 1. Sets up the HTTP event receiver server - 2. Calls Reset to move from startup -> standby and register event receiver - 3. Waits for Reset to complete and checks state - 4. Calls Initialize (SiLA command) to move from standby -> idle - 5. Verifies device is in idle state after Initialize + When full=True (default): full SiLA lifecycle (event receiver, Reset, + Initialize, verify idle), with optional retry and exponential backoff. + When full=False: only start the event receiver (reconnect without reset); + use after session loss so a running method is not aborted; then use + wait_for_completion_by_time() or a persisted handle's wait_resumable(). - This is lifecycle/connection setup; initialize() is the SiLA command that - moves standby -> idle (called by setup() when needed). + 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)). """ - # Step 1: Set up the HTTP event receiver server + if not full: + await self._sila.setup() + return + + last_error: Optional[Exception] = None + for attempt in range(max_attempts): + try: + await self._setup_full_path(simulation_mode) + return + except Exception as e: # noqa: BLE001 + last_error = e + if attempt < max_attempts - 1: + wait_time = retry_backoff_base_seconds * (2 ** attempt) + self.logger.warning( + "Setup attempt %s/%s failed: %s. Retrying in %.1fs.", + attempt + 1, + max_attempts, + e, + wait_time, + ) + await asyncio.sleep(wait_time) + else: + raise last_error from e + if last_error is not None: + raise last_error from last_error + + async def _setup_full_path(self, simulation_mode: bool) -> None: + """Run the full connection path: event receiver, Reset, Initialize, verify idle.""" await self._sila.setup() - # Step 2: Reset (startup -> standby) - registers event receiver URI - # Reset is async, so we wait for it to complete event_receiver_uri = f"http://{self._sila._client_ip}:{self._sila.bound_port}/" await self.reset( device_id="ODTC", event_receiver_uri=event_receiver_uri, - simulation_mode=False, + simulation_mode=simulation_mode, ) - # Step 3: Check state after Reset completes - # GetStatus is synchronous and will update our internal state tracking status = await self.get_status() self.logger.info(f"GetStatus returned raw state: {status!r} (type: {type(status).__name__})") @@ -360,7 +414,6 @@ async def setup(self) -> None: self.logger.info("Device is in standby state, calling Initialize...") await self.initialize() - # Step 4: Verify device is in idle state after Initialize status_after_init = await self.get_status() if status_after_init == SiLAState.IDLE.value: @@ -371,7 +424,6 @@ async def setup(self) -> None: f"but got {status_after_init!r}." ) elif status == SiLAState.IDLE.value: - # Already in idle, nothing to do self.logger.info("Device already in idle state after Reset") else: raise RuntimeError( @@ -580,6 +632,10 @@ async def reset( ) -> Optional[CommandExecution]: """Reset the device (SiLA command: startup -> standby, register event receiver). + The simulation_mode attribute on this backend is updated to the value passed + here; it reflects the last reset() call and is valid once that Reset has + completed (or immediately if wait=True). + Args: device_id: Device identifier (SiLA: deviceId). event_receiver_uri: Event receiver URI (SiLA: eventReceiverURI; auto-detected if None). @@ -590,6 +646,7 @@ async def reset( Returns: If wait=True: None. If wait=False: execution handle (awaitable). """ + self._simulation_mode = simulation_mode if event_receiver_uri is None: event_receiver_uri = f"http://{self._sila._client_ip}:{self._sila.bound_port}/" return await self._run_async_command( @@ -737,7 +794,9 @@ async def read_temperatures(self) -> ODTCSensorValues: ) # Parse the XML string (it's escaped in the response) - return parse_sensor_values(sensor_xml) + sensor_values = parse_sensor_values(sensor_xml) + self.logger.debug("ReadActualTemperature: %s", sensor_values.format_compact()) + return sensor_values async def get_last_data(self) -> str: """Get temperature trace of last executed method (CSV format). @@ -967,6 +1026,16 @@ async def list_protocols(self) -> List[str]: method_set = await self.get_method_set() return list_method_names(method_set) + async def list_methods(self) -> Tuple[List[str], List[str]]: + """List method names and premethod names separately. + + Returns: + Tuple of (method_names, premethod_names). Methods are runnable protocols; + premethods are setup-only (e.g. set block/lid temperature). + """ + method_set = await self.get_method_set() + return (list_method_names_only(method_set), list_premethod_names(method_set)) + def get_default_config(self, **kwargs) -> ODTCConfig: """Get a default ODTCConfig with variant set to this backend's variant. diff --git a/pylabrobot/thermocycling/inheco/odtc_model.py b/pylabrobot/thermocycling/inheco/odtc_model.py index d693f6f9571..21b26669ce2 100644 --- a/pylabrobot/thermocycling/inheco/odtc_model.py +++ b/pylabrobot/thermocycling/inheco/odtc_model.py @@ -328,6 +328,36 @@ class ODTCSensorValues: 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 @@ -482,6 +512,32 @@ class StoredProtocol: protocol: "Protocol" config: ODTCConfig + def __str__(self) -> str: + """Human-readable summary: name, stage/step counts, optional config (variant, lid temp).""" + lines: List[str] = [f"StoredProtocol(name={self.name!r})"] + stages = self.protocol.stages + if not stages: + lines.append(" protocol: 0 stages") + else: + lines.append(f" protocol: {len(stages)} stage(s)") + for i, stage in enumerate(stages): + step_count = len(stage.steps) + first_temp = "" + if stage.steps: + temps = stage.steps[0].temperature + first_temp = f", first step temp={temps[0]:.1f}°C" if temps else "" + lines.append( + f" stage {i + 1}: {stage.repeats} repeat(s), {step_count} step(s){first_temp}" + ) + c = self.config + if c.variant is not None or c.lid_temperature is not None: + variant_str = f"variant={c.variant}" if c.variant is not None else "" + lid_str = f"lid_temperature={c.lid_temperature}°C" if c.lid_temperature is not None else "" + config_parts = [x for x in (variant_str, lid_str) if x] + if config_parts: + lines.append(" config: " + ", ".join(config_parts)) + return "\n".join(lines) + # ============================================================================= # Generic XML Serialization/Deserialization @@ -815,7 +871,7 @@ def get_method_by_name(method_set: ODTCMethodSet, name: str) -> Optional[Union[O return None -def _list_method_names_only(method_set: ODTCMethodSet) -> List[str]: +def list_method_names_only(method_set: ODTCMethodSet) -> List[str]: """Get all method names (methods only, not premethods).""" return [m.name for m in method_set.methods] diff --git a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py b/pylabrobot/thermocycling/inheco/odtc_tests.py similarity index 90% rename from pylabrobot/thermocycling/inheco/odtc_backend_tests.py rename to pylabrobot/thermocycling/inheco/odtc_tests.py index 645d29a298c..f3bc69806f5 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend_tests.py +++ b/pylabrobot/thermocycling/inheco/odtc_tests.py @@ -1,4 +1,4 @@ -"""Tests for ODTC backend and SiLA interface.""" +"""Tests for ODTC: backend, thermocycler resource, SiLA interface, and model utilities.""" import asyncio import unittest @@ -402,15 +402,77 @@ def test_backend_variant_property(self): self.assertEqual(self.backend.variant, 960000) async def test_setup(self): - """Test backend setup.""" + """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] - # Mock the read-only property by setting it on the mock object - setattr(self.backend._sila, 'bound_port', 8080) # type: ignore[misc] + setattr(self.backend._sila, "bound_port", 8080) # type: ignore[misc] self.backend.reset = AsyncMock() # type: ignore[method-assign] self.backend.get_status = AsyncMock(return_value="idle") # type: ignore[method-assign] await self.backend.setup() self.backend._sila.setup.assert_called_once() + self.backend.reset.assert_called_once() + call_kwargs = self.backend.reset.call_args[1] + self.assertFalse(call_kwargs.get("simulation_mode", False)) + + async def test_setup_full_false_only_sila_setup(self): + """Test setup(full=False) only calls _sila.setup(), not reset or initialize.""" + self.backend._sila.setup = AsyncMock() # type: ignore[method-assign] + self.backend.reset = AsyncMock() # type: ignore[method-assign] + self.backend.get_status = AsyncMock() # type: ignore[method-assign] + await self.backend.setup(full=False) + self.backend._sila.setup.assert_called_once() + self.backend.reset.assert_not_called() + self.backend.get_status.assert_not_called() + + async def test_setup_simulation_mode_passed_to_reset(self): + """Test setup(simulation_mode=True) passes simulation_mode to reset.""" + self.backend._sila.setup = AsyncMock() # type: ignore[method-assign] + self.backend.reset = AsyncMock() # type: ignore[method-assign] + self.backend.get_status = AsyncMock(return_value="idle") # type: ignore[method-assign] + await self.backend.setup(simulation_mode=True) + self.backend.reset.assert_called_once() + self.assertTrue(self.backend.reset.call_args[1]["simulation_mode"]) + + async def test_reset_sets_simulation_mode(self): + """Test reset(simulation_mode=X) updates backend.simulation_mode.""" + fut: asyncio.Future[Any] = asyncio.Future() + fut.set_result(None) + self.backend._sila.start_command = AsyncMock(return_value=(fut, 1, None, 0.0)) # type: ignore[method-assign] + self.assertFalse(self.backend.simulation_mode) + await self.backend.reset(simulation_mode=True) + self.assertTrue(self.backend.simulation_mode) + await self.backend.reset(simulation_mode=False) + self.assertFalse(self.backend.simulation_mode) + + async def test_setup_retries_with_backoff(self): + """Test setup(full=True, max_attempts=3) retries on failure with backoff.""" + self.backend._sila.setup = AsyncMock() # type: ignore[method-assign] + self.backend.reset = AsyncMock() # type: ignore[method-assign] + call_count = 0 + + async def mock_get_status(): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise RuntimeError("transient") + return "idle" + + self.backend.get_status = AsyncMock(side_effect=mock_get_status) # type: ignore[method-assign] + with patch("asyncio.sleep", new_callable=AsyncMock): + await self.backend.setup(full=True, max_attempts=3) + self.assertEqual(call_count, 3) + # Full path runs 3 times (fail twice, succeed on third) + self.assertEqual(self.backend._sila.setup.call_count, 3) + self.assertEqual(self.backend.reset.call_count, 3) + + async def test_setup_raises_when_all_attempts_fail(self): + """Test setup(full=True, max_attempts=2) raises when all attempts fail.""" + self.backend._sila.setup = AsyncMock() # type: ignore[method-assign] + self.backend.reset = AsyncMock() # type: ignore[method-assign] + self.backend.get_status = AsyncMock(side_effect=RuntimeError("fail")) # type: ignore[method-assign] + with patch("asyncio.sleep", new_callable=AsyncMock), self.assertRaises(RuntimeError) as cm: + await self.backend.setup(full=True, max_attempts=2) + self.assertIn("fail", str(cm.exception)) async def test_stop(self): """Test backend stop.""" @@ -674,6 +736,7 @@ async def test_reset_wait_false_returns_handle_with_kwargs(self): self.assertEqual(call_kwargs["deviceId"], "ODTC") self.assertEqual(call_kwargs["eventReceiverURI"], "http://127.0.0.1:8080/") self.assertFalse(call_kwargs["simulationMode"]) + self.assertFalse(self.backend.simulation_mode) async def test_is_method_running(self): """Test is_method_running().""" @@ -746,6 +809,19 @@ async def test_list_protocols(self): names = await self.backend.list_protocols() self.assertEqual(names, ["PCR_30", "Pre25"]) + async def test_list_methods(self): + """Test list_methods returns (method_names, premethod_names) and matches list_protocols.""" + method_set = ODTCMethodSet( + methods=[ODTCMethod(name="PCR_30"), ODTCMethod(name="PCR_35")], + premethods=[ODTCPreMethod(name="Pre25"), ODTCPreMethod(name="Pre37")], + ) + self.backend.get_method_set = AsyncMock(return_value=method_set) # type: ignore[method-assign] + methods, premethods = await self.backend.list_methods() + self.assertEqual(methods, ["PCR_30", "PCR_35"]) + self.assertEqual(premethods, ["Pre25", "Pre37"]) + all_names = await self.backend.list_protocols() + self.assertEqual(methods + premethods, all_names) + 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] diff --git a/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb b/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb new file mode 100644 index 00000000000..a1cc71c786a --- /dev/null +++ b/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb @@ -0,0 +1,224 @@ +{ + "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": [ + "## 1. Imports and thermocycler\n", + "\n", + "Use **ODTCThermocycler** (recommended): it owns `odtc_ip`, `variant`, and dimensions. Alternative: generic `Thermocycler` + `ODTCBackend` for custom backend options." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import logging\n", + "from pylabrobot.resources import Coordinate\n", + "from pylabrobot.thermocycling.inheco import ODTCThermocycler\n", + "# Preferred: ODTCThermocycler (dimensions from ODTC_DIMENSIONS; variant 96 or 384)\n", + "tc = ODTCThermocycler(name=\"odtc_test\", odtc_ip=\"192.168.1.50\", variant=96, child_location=Coordinate.zero())\n", + "# Override: tc = Thermocycler(..., backend=ODTCBackend(odtc_ip=..., variant=96, logger=...), ...) for custom backend" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Connect and list device methods\n", + "\n", + "`setup()` resets and initializes the device. **Methods** = runnable protocols on the device; **premethods** = setup-only (e.g. set temperature). Use `list_protocols()` for names; `get_protocol(name)` returns a `StoredProtocol` for methods (None for premethods)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await tc.setup()\n", + "print(\"✓ Connected and initialized.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**After `get_protocol(name)`** you get a `StoredProtocol` (`.protocol` + `.config` with overshoot, PID, etc.):\n", + "\n", + "- **Roundtrip:** `run_protocol(stored.protocol, block_max_volume, config=stored.config)` — same device-calculated config.\n", + "- **Run by name (recommended for PCR):** `run_stored_protocol(\"MethodName\")` — device runs its Script Editor method; optimal thermal (overshoots utilized, device-tuned ramps).\n", + "- **Weaker option:** Uploading a custom protocol via `run_protocol(protocol, block_max_volume)` **without** a corresponding calculated config — no device overshoot/PID, so thermal performance is not optimized." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "protocol_names = await tc.backend.list_protocols()\n", + "print(f\"Device methods/premethods ({len(protocol_names)}):\", protocol_names[:10], \"...\" if len(protocol_names) > 10 else \"\")\n", + "\n", + "# Optional: inspect a runnable method (get_protocol returns None for premethods)\n", + "# StoredProtocol has .protocol (Protocol) and .config (ODTCConfig: overshoot, PID, etc.)\n", + "if protocol_names:\n", + " stored = await tc.backend.get_protocol(protocol_names[0])\n", + " if stored:\n", + " print(f\"Example: {stored.name} (variant {stored.config.variant}) — {len(stored.protocol.stages)} stage(s)\")\n", + " # Roundtrip: run with same ODTC config (overshoot, PID) via run_protocol(stored.protocol, block_max_volume, config=stored.config)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Lid (door) commands\n", + "\n", + "The Thermocycler API uses **`open_lid`** / **`close_lid`** (ODTC device calls this the door). Use **`wait=False`** to get an execution handle and avoid blocking; then **`await handle.wait()`** or **`await handle`** when you need to wait. 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": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Non-blocking: returns CommandExecution handle; 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. Use **`config=tc.backend.get_default_config(post_heating=True)`** to hold temperatures after the method ends." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await door_handle.wait()\n", + "print(\"Door closed.\")\n", + "\n", + "# set_block_temperature runs a premethod; wait=False returns MethodExecution handle\n", + "# Override: debug_xml=True, xml_output_path=\"out.xml\" to save generated MethodSet XML\n", + "mount_handle = await tc.set_block_temperature([37.0],\n", + " wait=False,\n", + " debug_xml=True,\n", + " xml_output_path=\"debug_set_mount_temp.xml\"\n", + " )\n", + "block = await tc.get_block_current_temperature()\n", + "lid = await tc.get_lid_current_temperature()\n", + "print(f\"Block: {block[0]:.1f} °C Lid: {lid[0]:.1f} °C\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Poll temps while method runs (optional)\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": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "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 MethodExecution. To block: await handle.wait() or tc.wait_for_profile_completion()\n", + "config = tc.backend.get_default_config(post_heating=False) # if True: hold temps after method ends\n", + "cycle_protocol = Protocol(stages=[\n", + " Stage(steps=[\n", + " Step(temperature=[37.0], hold_seconds=10.0),\n", + " Step(temperature=[60.0], hold_seconds=10.0),\n", + " Step(temperature=[10.0], hold_seconds=10.0),\n", + " ], repeats=1)\n", + "])\n", + "execution = await tc.run_protocol(cycle_protocol, 50.0, config=config)\n", + "# Override: run_stored_protocol(\"MethodName\") to run a device-stored method by name\n", + "print(f\"Protocol started (request_id={execution.request_id})\")\n", + "block, lid = await tc.get_block_current_temperature(), await tc.get_lid_current_temperature()\n", + "print(f\"Block: {block[0]:.1f} °C Lid: {lid[0]:.1f} °C\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 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": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Block until protocol done (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 +} From b94e165ed149c340d240ea51588b228256720ed6 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:10:25 -0800 Subject: [PATCH 14/28] Remove redundant notebook --- .../inheco/dev/test_door_commands.ipynb | 486 ------------------ 1 file changed, 486 deletions(-) delete mode 100644 pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb diff --git a/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb b/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb deleted file mode 100644 index 080f42fa1ef..00000000000 --- a/pylabrobot/thermocycling/inheco/dev/test_door_commands.ipynb +++ /dev/null @@ -1,486 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# ODTC Door Command Test\n", - "\n", - "This notebook demonstrates connecting to an ODTC device and testing the open/close door commands.\n", - "\n", - "## Setup\n", - "\n", - "Before running, ensure:\n", - "1. The ODTC device is powered on and connected to the network\n", - "2. You know the IP address of the ODTC device\n", - "3. Your computer is on the same network as the ODTC\n", - "4. The ODTC device is accessible (not locked by another client)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# 1. Libraries and configuration" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Logging configured. Debug information will be displayed.\n" - ] - } - ], - "source": [ - "# Import required modules\n", - "import logging\n", - "\n", - "from pylabrobot.thermocycling.inheco import InhecoODTC, ODTCBackend\n", - "\n", - "# Configure logging to show debug information\n", - "logging.basicConfig(\n", - " level=logging.DEBUG, # Set to DEBUG for more verbose output\n", - " format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',\n", - " datefmt='%H:%M:%S'\n", - ")\n", - "\n", - "# Get logger for ODTC backend to see status debugging\n", - "odtc_logger = logging.getLogger('pylabrobot.thermocycling.inheco.odtc_backend')\n", - "odtc_logger.setLevel(logging.DEBUG) # Set to DEBUG for more verbose output\n", - "\n", - "sila_logger = logging.getLogger('pylabrobot.thermocycling.inheco.odtc_sila_interface')\n", - "sila_logger.setLevel(logging.DEBUG) # Set to DEBUG for more verbose output\n", - "\n", - "print(\"Logging configured. Debug information will be displayed.\")" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Backend and thermocycler objects created.\n", - "Backend logger level: 10 (DEBUG)\n" - ] - } - ], - "source": [ - "# Configuration\n", - "backend = ODTCBackend(odtc_ip=\"192.168.1.50\", logger=odtc_logger)\n", - "tc = InhecoODTC(name=\"odtc_test\", backend=backend, model=\"96\")\n", - "print(\"Backend and thermocycler objects created.\")\n", - "print(f\"Backend logger level: {backend.logger.level} ({logging.getLevelName(backend.logger.level)})\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# 2. Connection and method introspection" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Starting device setup...\n", - "============================================================\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-01-26 22:42:19,718 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device reset (unlocked)\n", - "2026-01-26 22:42:20,270 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - GetStatus returned raw state: 'standby' (type: str)\n", - "2026-01-26 22:42:20,270 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device is in standby state, calling Initialize...\n", - "2026-01-26 22:42:29,348 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device successfully initialized and is in idle state\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "============================================================\n", - "✓ Connected and initialized successfully!\n" - ] - } - ], - "source": [ - "print(\"Starting device setup...\")\n", - "print(\"=\" * 60)\n", - "\n", - "await tc.setup()\n", - "\n", - "print(\"=\" * 60)\n", - "print(\"✓ Connected and initialized successfully!\")" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Methods and PreMethods (77):\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", - " - 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" - ] - } - ], - "source": [ - "# List all methods and premethods stored on the device\n", - "# Note: Requires device to be in Idle state (after setup/initialize)\n", - "# Returns unified list of all method names (both methods and premethods)\n", - "method_names = await tc.list_methods()\n", - "\n", - "print(f\"Methods and PreMethods ({len(method_names)}):\")\n", - "if method_names:\n", - " for name in method_names:\n", - " print(f\" - {name}\")\n", - "else:\n", - " print(\" (none)\")\n", - "\n", - "# You can also get a specific method by name:\n", - "# method = await tc.get_method(\"PCR_30cycles\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# 3. Commands" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Non-Blocking Door Operations\n", - "\n", - "The door commands support non-blocking execution using the `wait=False` parameter. This returns a `CommandExecution` handle that you can await later, allowing you to do other work while the door operation completes. \n", - "\n", - "Note: Just set wait=True to treat these as blocking and run them as normal commands" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Starting door close (non-blocking)...\n", - "✓ Door close command started! Request ID: 897717862\n" - ] - } - ], - "source": [ - "# Non-blocking door close\n", - "print(\"Starting door close (non-blocking)...\")\n", - "door_closing = await tc.close_door(wait=False)\n", - "print(f\"✓ Door close command started! Request ID: {door_closing.request_id}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# 5. Temperature Control" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Waiting for door to close...\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-01-26 22:42:43,082 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - MethodSet XML saved to: debug_set_mount_temp.xml\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "✓ Door close completed!\n", - "Setting mount temperature to 37°C...\n", - "✓ Method started! Request ID: 1515759907\n", - "Current temperatures:\n", - "ODTCSensorValues(timestamp='2026-01-27T00:01:44Z', mount=24.8, mount_monitor=24.71, lid=41.21, lid_monitor=41.300000000000004, ambient=23.32, pcb=27.52, heatsink=25.22, heatsink_tec=24.77)\n", - "Check debug_set_mount_temp.xml for the generated XML\n" - ] - } - ], - "source": [ - "# Set mount temperature to 37°C\n", - "# This creates a minimal protocol and uploads/runs it to the scratch file\n", - "# post_heating=True keeps temperatures held after method completes\n", - "print(\"Waiting for door to close...\")\n", - "await door_closing.wait()\n", - "print(\"✓ Door close completed!\")\n", - "\n", - "print(\"Setting mount temperature to 37°C...\")\n", - "execution = await tc.set_mount_temperature(\n", - " 37.0,\n", - " wait=False,\n", - " debug_xml=True, # Enable XML logging (shows in console if DEBUG logging is on)\n", - " xml_output_path=\"debug_set_mount_temp.xml\" # Save XML to file\n", - ")\n", - "\n", - "if await tc.get_status() == \"busy\":\n", - " print(f\"✓ Method started! Request ID: {execution.request_id}\")\n", - " print(f\"Current temperatures:\\n{await tc.read_temperatures()}\")\n", - " print(\"Check debug_set_mount_temp.xml for the generated XML\")" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "ODTCSensorValues(timestamp='2026-01-27T00:01:45Z', mount=25.23, mount_monitor=24.96, lid=41.45, lid_monitor=41.42, ambient=22.77, pcb=27.47, heatsink=25.2, heatsink_tec=24.63)" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await tc.read_temperatures()" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Waiting for mount to hit target temperature...\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-01-26 22:50:38,661 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - MethodSet XML saved to: debug_cycling_protocol.xml\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Running temperature cycling protocol...\n", - "✓ Protocol started! Request ID: 1179967139\n", - "✓ Method started! Request ID: 1179967139\n", - "Current temperatures:\n", - "ODTCSensorValues(timestamp='2026-01-27T00:09:38Z', mount=36.85, mount_monitor=36.99, lid=109.87, lid_monitor=110.86, ambient=23.44, pcb=25.6, heatsink=23.28, heatsink_tec=22.94)\n", - "Check debug_cycling_protocol.xml for the generated XML\n" - ] - } - ], - "source": [ - "from pylabrobot.thermocycling.standard import Protocol, Stage, Step\n", - "\n", - "cycle_protocol = Protocol(stages=[\n", - " Stage(\n", - " steps=[\n", - " Step(temperature=[37.0], hold_seconds=10.0), # 10 second hold at 37°C\n", - " Step(temperature=[60.0], hold_seconds=10.0), # Ramp to 60°C\n", - " Step(temperature=[10.0], hold_seconds=10.0), # Ramp down to 10°C\n", - " ],\n", - " repeats=1\n", - " )\n", - "])\n", - "config = tc.get_default_config(post_heating=True)\n", - "\n", - "print(\"Waiting for mount to hit target temperature...\")\n", - "await execution\n", - "\n", - "print(\"Running temperature cycling protocol...\")\n", - "execution = await tc.run_protocol(\n", - " protocol=cycle_protocol,\n", - " config=config,\n", - " method_name=None,\n", - " wait=False, # Non-blocking, returns execution handle\n", - " debug_xml=True, # Enable XML logging\n", - " xml_output_path=\"debug_cycling_protocol.xml\" # Save XML to file\n", - ")\n", - "\n", - "print(f\"✓ Protocol started! Request ID: {execution.request_id}\")\n", - "if await tc.get_status() == \"busy\":\n", - " print(f\"✓ Method started! Request ID: {execution.request_id}\")\n", - " print(f\"Current temperatures:\\n{await tc.read_temperatures()}\")\n", - " print(\"Check debug_cycling_protocol.xml for the generated XML\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# 6. Close the connection" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Protocol Completed, door opening with request ID: 1844714615\n", - "✓ Door open completed!\n", - "✓ Connection closed.\n" - ] - } - ], - "source": [ - "# Wait for completion\n", - "await execution\n", - "\n", - "door_opening = await tc.open_door(wait=False)\n", - "print(f\"Protocol Completed, door opening with request ID: {door_opening.request_id}\")\n", - "await door_opening\n", - "print(\"✓ Door open completed!\")\n", - "\n", - "# Close the connection\n", - "await tc.stop()\n", - "print(\"✓ Connection closed.\")" - ] - } - ], - "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 -} From ea6ceae4ff0f7e24b0e469f5c0901b4e20154316 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:34:06 -0800 Subject: [PATCH 15/28] Separate PreMethods vs Methods for Viewing. Display full protocol for viewing. --- pylabrobot/thermocycling/inheco/__init__.py | 3 +- .../thermocycling/inheco/odtc_backend.py | 23 +++++--- pylabrobot/thermocycling/inheco/odtc_model.py | 50 ++++++++++++++++- pylabrobot/thermocycling/inheco/odtc_tests.py | 4 +- .../thermocycling/inheco/odtc_tutorial.ipynb | 55 ++++++++++++++++--- 5 files changed, 114 insertions(+), 21 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/__init__.py b/pylabrobot/thermocycling/inheco/__init__.py index 386a3a54827..39202c88f56 100644 --- a/pylabrobot/thermocycling/inheco/__init__.py +++ b/pylabrobot/thermocycling/inheco/__init__.py @@ -27,7 +27,7 @@ """ from .odtc_backend import CommandExecution, MethodExecution, ODTCBackend -from .odtc_model import ODTC_DIMENSIONS, StoredProtocol, normalize_variant +from .odtc_model import ODTC_DIMENSIONS, ProtocolList, StoredProtocol, normalize_variant from .odtc_thermocycler import ODTCThermocycler __all__ = [ @@ -36,6 +36,7 @@ "ODTCBackend", "ODTC_DIMENSIONS", "ODTCThermocycler", + "ProtocolList", "StoredProtocol", "normalize_variant", ] diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index b482d666672..ee10959c334 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -24,12 +24,12 @@ PREMETHOD_ESTIMATED_DURATION_SECONDS, ODTCSensorValues, ODTCHardwareConstraints, + ProtocolList, StoredProtocol, estimate_method_duration_seconds, generate_odtc_timestamp, get_constraints, get_method_by_name, - list_method_names, list_method_names_only, list_premethod_names, method_set_to_xml, @@ -722,7 +722,9 @@ async def open_door(self, wait: bool = True) -> Optional[CommandExecution]: Returns: If wait=True: None. If wait=False: execution handle (awaitable). """ - return await self._run_async_command("OpenDoor", wait, CommandExecution) + return await self._run_async_command( + "OpenDoor", wait, CommandExecution, estimated_duration_seconds=60.0 + ) async def close_door(self, wait: bool = True) -> Optional[CommandExecution]: """Close the door (thermocycler lid). SiLA: CloseDoor. @@ -734,7 +736,9 @@ async def close_door(self, wait: bool = True) -> Optional[CommandExecution]: Returns: If wait=True: None. If wait=False: execution handle (awaitable). """ - return await self._run_async_command("CloseDoor", wait, CommandExecution) + return await self._run_async_command( + "CloseDoor", wait, CommandExecution, estimated_duration_seconds=60.0 + ) # Sensor commands TODO: We cleaned this up at the xml extraction level, clean the method up for temperature reporting @@ -1017,14 +1021,19 @@ async def get_protocol(self, name: str) -> Optional[StoredProtocol]: protocol, config = odtc_method_to_protocol(resolved) return StoredProtocol(name=name, protocol=protocol, config=config) - async def list_protocols(self) -> List[str]: - """List all protocol names (both methods and premethods) on the device. + async def list_protocols(self) -> ProtocolList: + """List all protocol names (methods and premethods) on the device. Returns: - List of protocol names (strings). + ProtocolList with .methods, .premethods, .all (flat list), and a __str__ + that prints Methods and PreMethods in clear sections. Iteration yields + all names (methods then premethods). """ method_set = await self.get_method_set() - return list_method_names(method_set) + return ProtocolList( + methods=list_method_names_only(method_set), + premethods=list_premethod_names(method_set), + ) async def list_methods(self) -> Tuple[List[str], List[str]]: """List method names and premethod names separately. diff --git a/pylabrobot/thermocycling/inheco/odtc_model.py b/pylabrobot/thermocycling/inheco/odtc_model.py index 21b26669ce2..7a6953995c1 100644 --- a/pylabrobot/thermocycling/inheco/odtc_model.py +++ b/pylabrobot/thermocycling/inheco/odtc_model.py @@ -13,7 +13,7 @@ import xml.etree.ElementTree as ET from dataclasses import dataclass, field, fields from enum import Enum -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, TypeVar, Union, cast, get_args, get_origin, get_type_hints +from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Tuple, Type, TypeVar, Union, cast, get_args, get_origin, get_type_hints if TYPE_CHECKING: from pylabrobot.thermocycling.standard import Protocol @@ -513,7 +513,7 @@ class StoredProtocol: config: ODTCConfig def __str__(self) -> str: - """Human-readable summary: name, stage/step counts, optional config (variant, lid temp).""" + """Human-readable summary: name, stage/step counts, steps, optional config (variant, lid temp).""" lines: List[str] = [f"StoredProtocol(name={self.name!r})"] stages = self.protocol.stages if not stages: @@ -529,6 +529,14 @@ def __str__(self) -> str: lines.append( f" stage {i + 1}: {stage.repeats} repeat(s), {step_count} step(s){first_temp}" ) + # Step-by-step instruction set + for j, step in enumerate(stage.steps): + temps = step.temperature + t_str = f"{temps[0]:.1f}°C" if temps else "—" + hold = step.hold_seconds + hold_str = f"{hold:.1f}s" if hold != float("inf") else "∞" + rate_str = f" @ {step.rate:.1f}°C/s" if step.rate is not None else "" + lines.append(f" step {j + 1}: {t_str} hold {hold_str}{rate_str}") c = self.config if c.variant is not None or c.lid_temperature is not None: variant_str = f"variant={c.variant}" if c.variant is not None else "" @@ -888,6 +896,44 @@ def list_method_names(method_set: ODTCMethodSet) -> List[str]: return method_names + premethod_names +class ProtocolList: + """Result of list_protocols(): methods and premethods with nice __str__ and backward-compat .all / iteration.""" + + def __init__(self, methods: List[str], premethods: List[str]) -> None: + self.methods = list(methods) + self.premethods = list(premethods) + + @property + def all(self) -> List[str]: + """Flat list of all protocol names (methods then premethods), for backward compatibility.""" + return self.methods + self.premethods + + def __iter__(self) -> Iterator[str]: + yield from self.all + + def __str__(self) -> str: + lines: List[str] = ["Methods (runnable protocols):"] + if self.methods: + for name in self.methods: + lines.append(f" - {name}") + else: + lines.append(" (none)") + lines.append("PreMethods (setup-only, e.g. set temperature):") + if self.premethods: + for name in self.premethods: + lines.append(f" - {name}") + else: + lines.append(" (none)") + return "\n".join(lines) + + def __eq__(self, other: object) -> bool: + if isinstance(other, list): + return self.all == other + if isinstance(other, ProtocolList): + return self.methods == other.methods and self.premethods == other.premethods + return NotImplemented + + # ============================================================================= # Protocol Conversion Functions # ============================================================================= diff --git a/pylabrobot/thermocycling/inheco/odtc_tests.py b/pylabrobot/thermocycling/inheco/odtc_tests.py index f3bc69806f5..0f25500a7e3 100644 --- a/pylabrobot/thermocycling/inheco/odtc_tests.py +++ b/pylabrobot/thermocycling/inheco/odtc_tests.py @@ -819,8 +819,8 @@ async def test_list_methods(self): methods, premethods = await self.backend.list_methods() self.assertEqual(methods, ["PCR_30", "PCR_35"]) self.assertEqual(premethods, ["Pre25", "Pre37"]) - all_names = await self.backend.list_protocols() - self.assertEqual(methods + premethods, all_names) + protocol_list = await self.backend.list_protocols() + self.assertEqual(methods + premethods, protocol_list.all) async def test_get_protocol_returns_none_for_missing(self): """Test get_protocol returns None when name not found.""" diff --git a/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb b/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb index a1cc71c786a..dc8f4ce763e 100644 --- a/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb +++ b/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb @@ -38,7 +38,7 @@ "source": [ "## 2. Connect and list device methods\n", "\n", - "`setup()` resets and initializes the device. **Methods** = runnable protocols on the device; **premethods** = setup-only (e.g. set temperature). Use `list_protocols()` for names; `get_protocol(name)` returns a `StoredProtocol` for methods (None for premethods)." + "`setup()` resets and initializes the device. **Methods** = runnable protocols on the device; **premethods** = setup-only (e.g. set temperature). Use `list_protocols()` to get a `ProtocolList` (methods and premethods in clear sections); `get_protocol(name)` returns a `StoredProtocol` for methods (None for premethods)." ] }, { @@ -68,16 +68,20 @@ "metadata": {}, "outputs": [], "source": [ - "protocol_names = await tc.backend.list_protocols()\n", - "print(f\"Device methods/premethods ({len(protocol_names)}):\", protocol_names[:10], \"...\" if len(protocol_names) > 10 else \"\")\n", + "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", - "# StoredProtocol has .protocol (Protocol) and .config (ODTCConfig: overshoot, PID, etc.)\n", - "if protocol_names:\n", - " stored = await tc.backend.get_protocol(protocol_names[0])\n", + "# StoredProtocol __str__ includes the full step-by-step instruction set\n", + "if protocol_list.all:\n", + " first_name = protocol_list.methods[0] if protocol_list.methods else protocol_list.premethods[0]\n", + " stored = await tc.backend.get_protocol(first_name)\n", " if stored:\n", - " print(f\"Example: {stored.name} (variant {stored.config.variant}) — {len(stored.protocol.stages)} stage(s)\")\n", - " # Roundtrip: run with same ODTC config (overshoot, PID) via run_protocol(stored.protocol, block_max_volume, config=stored.config)\n" + " print(\"\\nExample stored protocol (full structure and steps):\")\n", + " print(stored)\n", + " # Roundtrip: run with same ODTC config via run_protocol(stored.protocol, block_max_volume, config=stored.config)\n" ] }, { @@ -86,7 +90,7 @@ "source": [ "## 3. Lid (door) commands\n", "\n", - "The Thermocycler API uses **`open_lid`** / **`close_lid`** (ODTC device calls this the door). Use **`wait=False`** to get an execution handle and avoid blocking; then **`await handle.wait()`** or **`await handle`** when you need to wait. Omit `wait=False` to block until the command finishes." + "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. Omit `wait=False` to block until the command finishes." ] }, { @@ -175,6 +179,39 @@ "print(f\"Block: {block[0]:.1f} °C Lid: {lid[0]:.1f} °C\")\n" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Data events during protocol run** — The ODTC sends **DataEvent** messages while a method is running (e.g. progress or sensor data). The execution handle’s **`get_data_events()`** returns a list of DataEvent payloads (dicts) for this run. Run the cell below after starting the protocol to poll and inspect their structure." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import asyncio\n", + "import json\n", + "\n", + "# Poll data events a few times while the protocol runs (run this cell after starting the protocol)\n", + "events_so_far = []\n", + "for poll in range(6):\n", + " evs = await execution.get_data_events()\n", + " events_so_far = evs\n", + " print(f\"Poll {poll + 1}: {len(evs)} DataEvent(s) so far\")\n", + " if evs:\n", + " sample = evs[-1]\n", + " print(f\" Sample event keys: {list(sample.keys())}\")\n", + " print(f\" Sample event (last): {json.dumps(sample, indent=2, default=str)}\")\n", + " await asyncio.sleep(5)\n", + "\n", + "print(f\"\\nTotal DataEvents collected: {len(events_so_far)}\")\n", + "if events_so_far:\n", + " print(\"Structure of first event:\", list(events_so_far[0].keys()))" + ] + }, { "cell_type": "markdown", "metadata": {}, From 8371259ab379e85f1fce0bc317e0a74ed596ae20 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:06:55 -0800 Subject: [PATCH 16/28] Checkpoint. Data Event handling and progress. --- .../thermocycling/inheco/odtc_backend.py | 421 ++++++++++++------ .../thermocycling/inheco/odtc_data_events.py | 88 ++++ pylabrobot/thermocycling/inheco/odtc_model.py | 38 +- pylabrobot/thermocycling/inheco/odtc_tests.py | 2 +- 4 files changed, 386 insertions(+), 163 deletions(-) create mode 100644 pylabrobot/thermocycling/inheco/odtc_data_events.py diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index ee10959c334..813ca36f530 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -5,11 +5,12 @@ import asyncio import logging from dataclasses import dataclass, replace -from typing import Any, Dict, List, Literal, Optional, Tuple, Union, cast +from typing import Any, Callable, Dict, List, Literal, NamedTuple, Optional, Tuple, Union, cast from pylabrobot.thermocycling.backend import ThermocyclerBackend from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol +from .odtc_data_events import ODTCDataEventSnapshot, parse_data_event_payload from .odtc_sila_interface import ( DEFAULT_LIFETIME_OF_EXECUTION, ODTCSiLAInterface, @@ -46,6 +47,71 @@ LIFETIME_BUFFER_SECONDS: float = 60.0 +class ProtocolProgress(NamedTuple): + """Position in a protocol at a given elapsed time (ODTC-specific). All indices zero-based.""" + + current_cycle_index: int + current_step_index: int + total_cycle_count: int + total_step_count: int + remaining_hold_s: float + + +class ODTCProgress(NamedTuple): + """Full progress for one request: protocol position plus optional temps (for logging/callback).""" + + elapsed_s: float + current_cycle_index: int + current_step_index: int + total_cycle_count: int + total_step_count: int + remaining_hold_s: float + target_temp_c: Optional[float] + current_temp_c: Optional[float] + lid_temp_c: Optional[float] + + +def _protocol_progress(protocol: Protocol, elapsed_s: float) -> ProtocolProgress: + """Walk protocol (stages → repeats → steps) and return position at elapsed_s. + + Uses hold_seconds only. If elapsed_s <= 0 returns first step; if beyond end + returns last step with remaining_hold_s = 0. + """ + if not protocol.stages: + return ProtocolProgress(0, 0, 0, 0, 0.0) + t = 0.0 + last_progress = ProtocolProgress(0, 0, 0, 0, 0.0) + for _stage_idx, stage in enumerate(protocol.stages): + total_cycles = stage.repeats + total_steps = len(stage.steps) + if total_steps == 0: + continue + for cycle_idx in range(stage.repeats): + for step_idx, step in enumerate(stage.steps): + segment_start = t + t += step.hold_seconds + if elapsed_s < t: + remaining = min( + step.hold_seconds, + max(0.0, step.hold_seconds - (elapsed_s - segment_start)), + ) + return ProtocolProgress( + current_cycle_index=cycle_idx, + current_step_index=step_idx, + total_cycle_count=total_cycles, + total_step_count=total_steps, + remaining_hold_s=remaining, + ) + last_progress = ProtocolProgress( + current_cycle_index=cycle_idx, + current_step_index=step_idx, + total_cycle_count=total_cycles, + total_step_count=total_steps, + remaining_hold_s=0.0, + ) + return last_progress._replace(remaining_hold_s=0.0) + + def _volume_to_fluid_quantity(volume_ul: float) -> int: """Map volume in µL to ODTC fluid_quantity code. @@ -203,7 +269,8 @@ async def wait_resumable(self, poll_interval: float = 5.0) -> None: Use when the in-memory Future is not available (e.g. after process restart). Persist the handle (request_id, started_at, estimated_remaining_time, lifetime), reconnect the backend, then call this. Uses backend.wait_for_completion_by_time. - Terminal state is 'idle' for most commands. + Terminal state is 'idle' for most commands. Uses backend progress_log_interval + and progress_callback for progress reporting during wait. Args: poll_interval: Seconds between GetStatus calls. @@ -223,6 +290,8 @@ async def wait_resumable(self, poll_interval: float = 5.0) -> None: lifetime=lifetime, poll_interval=poll_interval, terminal_state="idle", + progress_log_interval=self.backend.progress_log_interval, + progress_callback=self.backend.progress_callback, ) async def get_data_events(self) -> List[Dict[str, Any]]: @@ -286,6 +355,8 @@ def __init__( poll_interval: float = 5.0, lifetime_of_execution: Optional[float] = None, on_response_event_missing: Literal["warn_and_continue", "error"] = "warn_and_continue", + progress_log_interval: Optional[float] = 30.0, + progress_callback: Optional[Callable[..., None]] = None, ): """Initialize ODTC backend. @@ -299,11 +370,17 @@ def __init__( poll_interval: Seconds between GetStatus calls in the async completion polling fallback (SiLA2 subscribe_by_polling style). Default 5.0. lifetime_of_execution: Max seconds to wait for async command completion (SiLA2 deadline). If None, uses 3 hours. Protocol execution is always bounded. on_response_event_missing: When completion is detected via polling but ResponseEvent was not received: "warn_and_continue" (resolve with None, log warning) or "error" (set exception). Default "warn_and_continue". + progress_log_interval: Seconds between progress log lines during wait. None or 0 to disable. Default 30.0. + progress_callback: Optional callback(progress) called each progress_log_interval during wait. """ super().__init__() self._variant = normalize_variant(variant) self._current_execution: Optional[MethodExecution] = None self._simulation_mode: bool = False + self._protocol_by_request_id: Dict[int, Protocol] = {} + self._last_snapshot_by_request_id: Dict[int, ODTCDataEventSnapshot] = {} + self.progress_log_interval: Optional[float] = progress_log_interval + self.progress_callback: Optional[Callable[..., None]] = progress_callback self._sila = ODTCSiLAInterface( machine_ip=odtc_ip, client_ip=client_ip, @@ -344,6 +421,12 @@ def _clear_current_execution_if(self, handle: MethodExecution) -> None: if self._current_execution is handle: self._current_execution = None + def _clear_execution_state_for_handle(self, handle: MethodExecution) -> None: + """Clear current execution, protocol, and snapshot cache for this handle.""" + self._clear_current_execution_if(handle) + self._protocol_by_request_id.pop(handle.request_id, None) + self._last_snapshot_by_request_id.pop(handle.request_id, None) + async def setup( self, full: bool = True, @@ -588,6 +671,51 @@ def _extract_xml_parameter( return str(string_elem.text) + def _get_parameter_string_from_response( + self, + resp: Any, + param_name: str, + command_name: str, + response_data_path: Optional[List[str]] = None, + allow_root_fallback: bool = False, + ) -> str: + """Get a parameter's String value from a command response (dict or ElementTree). + + Handles both synchronous (dict) and asynchronous (ElementTree) response shapes. + For dict responses, response_data_path must be the path to ResponseData + (e.g. ["ReadActualTemperatureResponse", "ResponseData"]). + """ + if isinstance(resp, dict): + if not response_data_path: + raise ValueError( + f"{command_name}: response_data_path required when response is dict" + ) + response_data = self._extract_dict_path( + resp, response_data_path, command_name + ) + param = response_data.get("Parameter") + if isinstance(param, list): + sensor_param = next( + (p for p in param if p.get("name") == param_name), None + ) + elif isinstance(param, dict): + sensor_param = param if param.get("name") == param_name else None + else: + sensor_param = None + if sensor_param is None: + raise ValueError( + f"Parameter '{param_name}' not found in {command_name} response" + ) + value = sensor_param.get("String") + if value is None: + raise ValueError( + f"String element not found in {param_name} parameter" + ) + return str(value) + return self._extract_xml_parameter( + resp, param_name, command_name, allow_root_fallback=allow_root_fallback + ) + # ============================================================================ # Basic ODTC Commands # ============================================================================ @@ -741,7 +869,6 @@ async def close_door(self, wait: bool = True) -> Optional[CommandExecution]: ) - # Sensor commands TODO: We cleaned this up at the xml extraction level, clean the method up for temperature reporting async def read_temperatures(self) -> ODTCSensorValues: """Read all temperature sensors. @@ -749,55 +876,17 @@ async def read_temperatures(self) -> ODTCSensorValues: ODTCSensorValues with temperatures in °C. """ resp = await self._sila.send_command("ReadActualTemperature") - - # Debug logging to see what we actually received self.logger.debug( - f"ReadActualTemperature response type: {type(resp).__name__}, " - f"isinstance dict: {isinstance(resp, dict)}, " - f"isinstance ElementTree: {hasattr(resp, 'find') if resp else False}" + "ReadActualTemperature response type: %s", + type(resp).__name__, + ) + sensor_xml = self._get_parameter_string_from_response( + resp, + "SensorValues", + "ReadActualTemperature", + response_data_path=["ReadActualTemperatureResponse", "ResponseData"], + allow_root_fallback=True, ) - - # Handle both synchronous (dict) and asynchronous (ElementTree) responses - if isinstance(resp, dict): - # Synchronous response (return_code == 1) - extract from dict structure - # Structure: ReadActualTemperatureResponse -> ResponseData -> Parameter -> String - # Parameter might be a dict or list, so we need to find the one with name="SensorValues" - self.logger.debug(f"ReadActualTemperature dict response keys: {list(resp.keys())}") - response_data = self._extract_dict_path( - resp, ["ReadActualTemperatureResponse", "ResponseData"], "ReadActualTemperature" - ) - self.logger.debug(f"ResponseData structure: {response_data}") - - # Parameter might be a dict or list - param = response_data.get("Parameter") - if isinstance(param, list): - # Find parameter with name="SensorValues" - sensor_param = next((p for p in param if p.get("name") == "SensorValues"), None) - elif isinstance(param, dict): - # Single parameter dict - sensor_param = param if param.get("name") == "SensorValues" else None - else: - sensor_param = None - - if sensor_param is None: - raise ValueError( - "SensorValues parameter not found in ReadActualTemperature response" - ) - - sensor_xml = sensor_param.get("String") - if sensor_xml is None: - raise ValueError( - "String element not found in SensorValues parameter" - ) - else: - # Asynchronous response (return_code == 2) - resp is ElementTree root - # Response structure: ResponseData/Parameter[@name='SensorValues']/String - # Use fallback for temperature data which may be in root without name attribute - sensor_xml = self._extract_xml_parameter( - resp, "SensorValues", "ReadActualTemperature", allow_root_fallback=True - ) - - # Parse the XML string (it's escaped in the response) sensor_values = parse_sensor_values(sensor_xml) self.logger.debug("ReadActualTemperature: %s", sensor_values.format_compact()) return sensor_values @@ -820,6 +909,7 @@ async def execute_method( priority: Optional[int] = None, wait: bool = False, estimated_duration_seconds: Optional[float] = None, + protocol: Optional[Protocol] = None, ) -> MethodExecution: """Execute a method or premethod by name (SiLA: ExecuteMethod; methodName). @@ -834,6 +924,7 @@ async def execute_method( completion then return the (completed) handle. estimated_duration_seconds: Optional estimated duration in seconds (used for polling timing and timeout; not sent to device). + protocol: Optional Protocol to associate with this run (for progress/get_*). Returns: MethodExecution handle (completed if wait=True). @@ -851,7 +942,11 @@ async def execute_method( **params, ) assert handle is not None and isinstance(handle, MethodExecution) - handle._future.add_done_callback(lambda _: self._clear_current_execution_if(handle)) + handle._future.add_done_callback( + lambda _: self._clear_execution_state_for_handle(handle) + ) + if protocol is not None: + self._protocol_by_request_id[handle.request_id] = protocol self._current_execution = handle if wait: await handle.wait() @@ -924,6 +1019,8 @@ async def wait_for_completion_by_time( lifetime: float, poll_interval: float = 5.0, terminal_state: str = "idle", + progress_log_interval: Optional[float] = None, + progress_callback: Optional[Callable[..., None]] = None, ) -> None: """Wait for async command completion using only wall-clock and GetStatus (resumable). @@ -934,20 +1031,26 @@ async def wait_for_completion_by_time( (a) Waits until time.time() >= started_at + estimated_remaining_time + buffer. (b) Then polls GetStatus every poll_interval until state == terminal_state or time.time() - started_at >= lifetime (then raises TimeoutError). + When progress_log_interval is set, logs and/or calls progress_callback at that interval. Args: - request_id: SiLA request ID (for logging; not used for correlation). + request_id: SiLA request ID (for logging and DataEvent lookup). started_at: time.time() when the command was sent. estimated_remaining_time: Device-estimated duration in seconds (or None). lifetime: Max seconds to wait (e.g. from handle.lifetime). poll_interval: Seconds between GetStatus calls. terminal_state: Device state that indicates command finished (default "idle"). + progress_log_interval: Seconds between progress reports; None/0 to disable. Uses backend default if None. + progress_callback: Called with ODTCProgress each interval; uses backend default if None. Raises: TimeoutError: If lifetime exceeded before terminal state. """ import time + interval = progress_log_interval if progress_log_interval is not None else self.progress_log_interval + callback = progress_callback if progress_callback is not None else self.progress_callback + last_progress_log = 0.0 buffer = POLLING_START_BUFFER eta = estimated_remaining_time or 0.0 while True: @@ -962,6 +1065,36 @@ async def wait_for_completion_by_time( if remaining_wait > 0: await asyncio.sleep(min(remaining_wait, poll_interval)) continue + # Progress reporting: fetch events, parse latest, log and/or callback + if interval and interval > 0 and now - last_progress_log >= interval: + last_progress_log = now + events_dict = await self.get_data_events(request_id) + events = events_dict.get(request_id, []) + if events: + snapshot = parse_data_event_payload(events[-1]) + if snapshot is not None: + self._last_snapshot_by_request_id[request_id] = snapshot + progress = await self._get_progress(request_id) + if progress is not None: + if callback is not None: + try: + callback(progress) + except Exception: # noqa: S110 + pass + else: + self.logger.info( + "ODTC progress: elapsed %.0fs, block %.1f°C (target %.1f°C), lid %.1f°C, " + "step %d/%d, cycle %d/%d, hold remaining ~%.0fs", + progress.elapsed_s, + progress.current_temp_c or 0.0, + progress.target_temp_c or 0.0, + progress.lid_temp_c or 0.0, + progress.current_step_index + 1, + progress.total_step_count, + progress.current_cycle_index + 1, + progress.total_cycle_count, + progress.remaining_hold_s, + ) status = await self.get_status() if status == terminal_state: return @@ -1144,7 +1277,12 @@ async def run_stored_protocol(self, name: str, wait: bool = False, **kwargs) -> resolved = get_method_by_name(method_set, name) if isinstance(resolved, ODTCPreMethod): eta = PREMETHOD_ESTIMATED_DURATION_SECONDS - return await self.execute_method(name, wait=wait, estimated_duration_seconds=eta) + return await self.execute_method( + name, + wait=wait, + estimated_duration_seconds=eta, + protocol=stored.protocol if stored is not None else None, + ) async def upload_method_set( self, @@ -1549,9 +1687,11 @@ async def run_protocol( await self.upload_method(method, allow_overwrite=True, execute=False) resolved_name = resolve_protocol_name(method.name) eta = estimate_method_duration_seconds(method) - return await self.execute_method( + handle = await self.execute_method( resolved_name, wait=False, estimated_duration_seconds=eta ) + self._protocol_by_request_id[handle.request_id] = protocol + return handle async def get_block_current_temperature(self) -> List[float]: """Get current block temperature. @@ -1648,92 +1788,107 @@ async def get_block_status(self) -> BlockStatus: except Exception: return BlockStatus.IDLE - async def get_hold_time(self) -> float: - """Get remaining hold time. - - ODTC does not report per-step hold time. Use is_method_running() or - wait_for_method_completion() to monitor execution. - - Returns: - Remaining hold time in seconds. - - Raises: - NotImplementedError: ODTC does not report hold time. - """ - raise NotImplementedError( - "ODTC does not report remaining hold time; method execution state not " - "tracked. Use is_method_running() or wait_for_method_completion() to " - "monitor execution." + async def _get_progress(self, request_id: int) -> Optional[ODTCProgress]: + """Get progress for a run: protocol position + temps from latest DataEvent. Returns None if no protocol.""" + protocol = self._protocol_by_request_id.get(request_id) + if protocol is None: + return None + snapshot = self._last_snapshot_by_request_id.get(request_id) + if snapshot is None: + events_dict = await self.get_data_events(request_id) + events = events_dict.get(request_id, []) + if events: + snapshot = parse_data_event_payload(events[-1]) + if snapshot is not None: + self._last_snapshot_by_request_id[request_id] = snapshot + if snapshot is None: + snapshot = ODTCDataEventSnapshot(elapsed_s=0.0) + proto = _protocol_progress(protocol, snapshot.elapsed_s) + return ODTCProgress( + elapsed_s=snapshot.elapsed_s, + current_cycle_index=proto.current_cycle_index, + current_step_index=proto.current_step_index, + total_cycle_count=proto.total_cycle_count, + total_step_count=proto.total_step_count, + remaining_hold_s=proto.remaining_hold_s, + target_temp_c=snapshot.target_temp_c, + current_temp_c=snapshot.current_temp_c, + lid_temp_c=snapshot.lid_temp_c, ) - async def get_current_cycle_index(self) -> int: - """Get current cycle index. - - ODTC does not report cycle/step indices. Use is_method_running() or - wait_for_method_completion() to monitor execution. + def _request_id_for_get_progress(self) -> Optional[int]: + """Request ID of current execution for get_* methods; None if none or done.""" + ex = self._current_execution + if ex is None or ex.done: + return None + return ex.request_id - Returns: - Zero-based cycle index. + async def get_hold_time(self) -> float: + """Get remaining hold time in seconds for the current step.""" + request_id = self._request_id_for_get_progress() + if request_id is None: + raise RuntimeError( + "No profile running; get_hold_time requires an active method execution." + ) + progress = await self._get_progress(request_id) + if progress is None: + raise NotImplementedError( + "ODTC does not report remaining hold time; no protocol associated with this run." + ) + return progress.remaining_hold_s - Raises: - NotImplementedError: ODTC does not report cycle index. - """ - raise NotImplementedError( - "ODTC does not report current cycle index; method execution state not " - "tracked. Use is_method_running() or wait_for_method_completion() to " - "monitor execution." - ) + async def get_current_cycle_index(self) -> int: + """Get zero-based current cycle index.""" + request_id = self._request_id_for_get_progress() + if request_id is None: + raise RuntimeError( + "No profile running; get_current_cycle_index requires an active method execution." + ) + progress = await self._get_progress(request_id) + if progress is None: + raise NotImplementedError( + "ODTC does not report current cycle index; no protocol associated with this run." + ) + return progress.current_cycle_index async def get_total_cycle_count(self) -> int: - """Get total cycle count. - - ODTC does not report cycle/step counts. Use is_method_running() or - wait_for_method_completion() to monitor execution. - - Returns: - Total number of cycles. - - Raises: - NotImplementedError: ODTC does not report total cycle count. - """ - raise NotImplementedError( - "ODTC does not report total cycle count; method execution state not " - "tracked. Use is_method_running() or wait_for_method_completion() to " - "monitor execution." - ) + """Get total cycle count for the current stage.""" + request_id = self._request_id_for_get_progress() + if request_id is None: + raise RuntimeError( + "No profile running; get_total_cycle_count requires an active method execution." + ) + progress = await self._get_progress(request_id) + if progress is None: + raise NotImplementedError( + "ODTC does not report total cycle count; no protocol associated with this run." + ) + return progress.total_cycle_count async def get_current_step_index(self) -> int: - """Get current step index. - - ODTC does not report cycle/step indices. Use is_method_running() or - wait_for_method_completion() to monitor execution. - - Returns: - Zero-based step index. - - Raises: - NotImplementedError: ODTC does not report current step index. - """ - raise NotImplementedError( - "ODTC does not report current step index; method execution state not " - "tracked. Use is_method_running() or wait_for_method_completion() to " - "monitor execution." - ) + """Get zero-based current step index within the cycle.""" + request_id = self._request_id_for_get_progress() + if request_id is None: + raise RuntimeError( + "No profile running; get_current_step_index requires an active method execution." + ) + progress = await self._get_progress(request_id) + if progress is None: + raise NotImplementedError( + "ODTC does not report current step index; no protocol associated with this run." + ) + return progress.current_step_index async def get_total_step_count(self) -> int: - """Get total step count. - - ODTC does not report cycle/step counts. Use is_method_running() or - wait_for_method_completion() to monitor execution. - - Returns: - Total number of steps. - - Raises: - NotImplementedError: ODTC does not report total step count. - """ - raise NotImplementedError( - "ODTC does not report total step count; method execution state not " - "tracked. Use is_method_running() or wait_for_method_completion() to " - "monitor execution." - ) + """Get total number of steps in the current cycle.""" + request_id = self._request_id_for_get_progress() + if request_id is None: + raise RuntimeError( + "No profile running; get_total_step_count requires an active method execution." + ) + progress = await self._get_progress(request_id) + if progress is None: + raise NotImplementedError( + "ODTC does not report total step count; no protocol associated with this run." + ) + return progress.total_step_count diff --git a/pylabrobot/thermocycling/inheco/odtc_data_events.py b/pylabrobot/thermocycling/inheco/odtc_data_events.py new file mode 100644 index 00000000000..90d303c8158 --- /dev/null +++ b/pylabrobot/thermocycling/inheco/odtc_data_events.py @@ -0,0 +1,88 @@ +"""Parse ODTC SiLA DataEvent payloads into structured snapshots.""" + +from __future__ import annotations + +import html +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from typing import Any, Dict, Optional + + +@dataclass +class ODTCDataEventSnapshot: + """Parsed snapshot from one DataEvent (elapsed time and temperatures).""" + + elapsed_s: float + target_temp_c: Optional[float] = None + current_temp_c: Optional[float] = None + lid_temp_c: Optional[float] = None + + +def _parse_series_value(series_elem: Any) -> Optional[float]: + """Extract last integerValue from a dataSeries element as float.""" + values = series_elem.findall(".//integerValue") + if not values: + return None + text = values[-1].text + if text is None: + return None + try: + return float(text) + except ValueError: + return None + + +def parse_data_event_payload(payload: Dict[str, Any]) -> Optional[ODTCDataEventSnapshot]: + """Parse a single DataEvent payload into an ODTCDataEventSnapshot. + + Input: dict with 'requestId' and 'dataValue' (string of XML, possibly + double-escaped). Extracts Elapsed time (ms), Target temperature, Current + temperature, LID temperature (1/100°C -> °C). Returns None on parse error. + """ + if not isinstance(payload, dict): + return None + data_value = payload.get("dataValue") + if not data_value or not isinstance(data_value, str): + return None + try: + outer = ET.fromstring(data_value) + except ET.ParseError: + return None + any_data = outer.find(".//{*}AnyData") or outer.find(".//AnyData") + if any_data is None or any_data.text is None: + return None + inner_xml = any_data.text.strip() + if not inner_xml: + return None + if "<" in inner_xml or ">" in inner_xml: + inner_xml = html.unescape(inner_xml) + try: + inner = ET.fromstring(inner_xml) + except ET.ParseError: + return None + elapsed_s = 0.0 + target_temp_c: Optional[float] = None + current_temp_c: Optional[float] = None + lid_temp_c: Optional[float] = None + for elem in inner.iter(): + if not elem.tag.endswith("dataSeries"): + continue + name_id = elem.get("nameId") + unit = elem.get("unit") or "" + raw = _parse_series_value(elem) + if raw is None: + continue + if name_id == "Elapsed time" and unit == "ms": + elapsed_s = raw / 1000.0 + elif name_id == "Target temperature" and unit == "1/100°C": + target_temp_c = raw / 100.0 + elif name_id == "Current temperature" and unit == "1/100°C": + current_temp_c = raw / 100.0 + elif name_id == "LID temperature" and unit == "1/100°C": + lid_temp_c = raw / 100.0 + return ODTCDataEventSnapshot( + elapsed_s=elapsed_s, + target_temp_c=target_temp_c, + current_temp_c=current_temp_c, + lid_temp_c=lid_temp_c, + ) diff --git a/pylabrobot/thermocycling/inheco/odtc_model.py b/pylabrobot/thermocycling/inheco/odtc_model.py index 7a6953995c1..5ff8f04d28a 100644 --- a/pylabrobot/thermocycling/inheco/odtc_model.py +++ b/pylabrobot/thermocycling/inheco/odtc_model.py @@ -770,22 +770,14 @@ def _method_to_xml(method: ODTCMethod, parent: ET.Element) -> ET.Element: # ============================================================================= -def parse_method_set(xml_str: str) -> ODTCMethodSet: - """Parse a MethodSet XML string.""" - root = ET.fromstring(xml_str) - - # Parse DeleteAllMethods +def parse_method_set_from_root(root: ET.Element) -> ODTCMethodSet: + """Parse a MethodSet from an XML root element.""" 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" - - # Parse PreMethods premethods = [from_xml(pm, ODTCPreMethod) for pm in root.findall("PreMethod")] - - # Parse Methods (with special PIDSet handling) methods = [_parse_method(m) for m in root.findall("Method")] - return ODTCMethodSet( delete_all_methods=delete_all, premethods=premethods, @@ -793,28 +785,16 @@ def parse_method_set(xml_str: str) -> ODTCMethodSet: ) +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) - root = tree.getroot() - - # Parse DeleteAllMethods - 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" - - # Parse PreMethods - premethods = [from_xml(pm, ODTCPreMethod) for pm in root.findall("PreMethod")] - - # Parse Methods (with special PIDSet handling) - methods = [_parse_method(m) for m in root.findall("Method")] - - return ODTCMethodSet( - delete_all_methods=delete_all, - premethods=premethods, - methods=methods, - ) + return parse_method_set_from_root(tree.getroot()) def method_set_to_xml(method_set: ODTCMethodSet) -> str: diff --git a/pylabrobot/thermocycling/inheco/odtc_tests.py b/pylabrobot/thermocycling/inheco/odtc_tests.py index 0f25500a7e3..601a5866033 100644 --- a/pylabrobot/thermocycling/inheco/odtc_tests.py +++ b/pylabrobot/thermocycling/inheco/odtc_tests.py @@ -867,7 +867,7 @@ async def test_run_stored_protocol_calls_execute_method(self): ): await self.backend.run_stored_protocol("MyMethod", wait=True) self.backend.execute_method.assert_called_once_with( - "MyMethod", wait=True, estimated_duration_seconds=None + "MyMethod", wait=True, estimated_duration_seconds=None, protocol=None ) From ca8d579d2104d2af1197b6483b215c3fc14a55fe Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:50:43 -0800 Subject: [PATCH 17/28] Refactor. Comms reorganization and ODTC Method, Protocol, Config Consolidation... --- pylabrobot/thermocycling/inheco/README.md | 25 +- pylabrobot/thermocycling/inheco/__init__.py | 4 +- .../thermocycling/inheco/odtc_backend.py | 705 ++++++++---------- .../thermocycling/inheco/odtc_data_events.py | 88 --- pylabrobot/thermocycling/inheco/odtc_model.py | 315 +++++++- pylabrobot/thermocycling/inheco/odtc_tests.py | 38 +- .../thermocycling/inheco/odtc_tutorial.ipynb | 47 +- pylabrobot/thermocycling/thermocycler.py | 18 +- 8 files changed, 662 insertions(+), 578 deletions(-) delete mode 100644 pylabrobot/thermocycling/inheco/odtc_data_events.py diff --git a/pylabrobot/thermocycling/inheco/README.md b/pylabrobot/thermocycling/inheco/README.md index 8931f2808c1..22a79eaec95 100644 --- a/pylabrobot/thermocycling/inheco/README.md +++ b/pylabrobot/thermocycling/inheco/README.md @@ -319,11 +319,11 @@ methods, premethods = await tc.backend.list_methods() ### Get Runnable Protocol by Name ```python -# Get a runnable protocol by name (returns StoredProtocol or None for premethods) -stored = await tc.backend.get_protocol("PCR_30cycles") -if stored: - print(f"Protocol: {stored.name}") - # stored.protocol: Protocol; stored.config: ODTCConfig +# Get a runnable protocol by name (returns ODTCProtocol or None for premethods) +odtc = await tc.backend.get_protocol("PCR_30cycles") +if odtc: + print(f"Protocol: {odtc.name}") + # odtc is ODTCProtocol (subclasses Protocol); use odtc_protocol_to_protocol(odtc) for (Protocol, ODTCProtocol) ``` ### Get Full MethodSet (Advanced) @@ -343,18 +343,17 @@ for premethod in method_set.premethods: ### Inspect Stored Protocol ```python -# Get runnable protocol from device (StoredProtocol has protocol + config) -stored = await tc.backend.get_protocol("PCR_30cycles") -if stored: - print(stored) # Human-readable summary (name, stages, steps, config) - # stored.protocol: Generic PyLabRobot Protocol (stages, steps, temperatures, times) - # stored.config: ODTCConfig preserving all ODTC-specific parameters - await tc.run_protocol(stored.protocol, block_max_volume=50.0, config=stored.config) +# Get runnable protocol from device (ODTCProtocol subclasses Protocol; has name, stages, steps, config fields) +odtc = await tc.backend.get_protocol("PCR_30cycles") +if odtc: + protocol, _ = odtc_protocol_to_protocol(odtc) # from odtc_model + print(odtc) # Human-readable summary (name, stages, steps, config fields) + await tc.run_protocol(odtc, block_max_volume=50.0) # backend accepts ODTCProtocol ``` ### Display and logging -- **StoredProtocol** and **ODTCSensorValues**: `print(stored)` and `print(await tc.backend.read_temperatures())` show labeled summaries. ODTCSensorValues `__str__` is multi-line for display; use `format_compact()` for single-line logs. +- **ODTCProtocol** and **ODTCSensorValues**: `print(odtc)` and `print(await tc.backend.read_temperatures())` show labeled summaries. ODTCSensorValues `__str__` is multi-line for display; use `format_compact()` for single-line logs. - **Wait messages**: When you `await handle`, `handle.wait()`, or `handle.wait_resumable()`, the message logged at INFO is multi-line (command, duration, remaining time) for clear console/notebook display. ## Running Protocols (reference) diff --git a/pylabrobot/thermocycling/inheco/__init__.py b/pylabrobot/thermocycling/inheco/__init__.py index 39202c88f56..4a807f3ab75 100644 --- a/pylabrobot/thermocycling/inheco/__init__.py +++ b/pylabrobot/thermocycling/inheco/__init__.py @@ -27,7 +27,7 @@ """ from .odtc_backend import CommandExecution, MethodExecution, ODTCBackend -from .odtc_model import ODTC_DIMENSIONS, ProtocolList, StoredProtocol, normalize_variant +from .odtc_model import ODTC_DIMENSIONS, ODTCProtocol, ProtocolList, normalize_variant from .odtc_thermocycler import ODTCThermocycler __all__ = [ @@ -35,8 +35,8 @@ "MethodExecution", "ODTCBackend", "ODTC_DIMENSIONS", + "ODTCProtocol", "ODTCThermocycler", "ProtocolList", - "StoredProtocol", "normalize_variant", ] diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index 813ca36f530..6ce17f62ff3 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -4,13 +4,13 @@ import asyncio import logging +import xml.etree.ElementTree as ET from dataclasses import dataclass, replace -from typing import Any, Callable, Dict, List, Literal, NamedTuple, Optional, Tuple, Union, cast +from typing import Any, Callable, Dict, List, Literal, NamedTuple, Optional, Tuple, Union from pylabrobot.thermocycling.backend import ThermocyclerBackend from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol -from .odtc_data_events import ODTCDataEventSnapshot, parse_data_event_payload from .odtc_sila_interface import ( DEFAULT_LIFETIME_OF_EXECUTION, ODTCSiLAInterface, @@ -18,16 +18,15 @@ SiLAState, ) from .odtc_model import ( - ODTCMethod, ODTCConfig, ODTCMethodSet, - ODTCPreMethod, - PREMETHOD_ESTIMATED_DURATION_SECONDS, + ODTCDataEventSnapshot, + ODTCProtocol, ODTCSensorValues, ODTCHardwareConstraints, ProtocolList, - StoredProtocol, - estimate_method_duration_seconds, + PREMETHOD_ESTIMATED_DURATION_SECONDS, + estimate_odtc_protocol_duration_seconds, generate_odtc_timestamp, get_constraints, get_method_by_name, @@ -35,11 +34,12 @@ list_premethod_names, method_set_to_xml, normalize_variant, - odtc_method_to_protocol, + odtc_protocol_to_protocol, + parse_data_event_payload, parse_method_set, parse_method_set_file, parse_sensor_values, - protocol_to_odtc_method, + protocol_to_odtc_protocol, resolve_protocol_name, ) @@ -47,6 +47,155 @@ LIFETIME_BUFFER_SECONDS: float = 60.0 +# ============================================================================= +# SiLA Response Normalization (single abstraction for dict or ET responses) +# ============================================================================= + + +class _NormalizedSiLAResponse: + """Normalized result of a SiLA command (sync dict or async ElementTree). + + Used only by ODTCBackend. Build via from_raw(); then get_value() for + dict-path extraction or get_parameter_string() for Parameter/String. + """ + + def __init__( + self, + command_name: str, + _dict: Optional[Dict[str, Any]] = None, + _et_root: Optional[ET.Element] = None, + ) -> None: + self._command_name = command_name + self._dict = _dict + self._et_root = _et_root + if _dict is not None and _et_root is not None: + raise ValueError("_NormalizedSiLAResponse: provide _dict or _et_root, not both") + if _dict is None and _et_root is None: + raise ValueError("_NormalizedSiLAResponse: provide _dict or _et_root") + + @classmethod + def from_raw( + cls, + raw: Union[Dict[str, Any], ET.Element, None], + command_name: str, + ) -> "_NormalizedSiLAResponse": + """Build from send_command return value (dict for sync, ET root for async).""" + if raw is None: + return cls(command_name=command_name, _dict={}) + if isinstance(raw, dict): + return cls(command_name=command_name, _dict=raw) + return cls(command_name=command_name, _et_root=raw) + + def get_value(self, *path: str, required: bool = True) -> Any: + """Get nested value from dict response by key path. Only for dict (sync) responses.""" + if self._dict is None: + raise ValueError( + f"{self._command_name}: get_value() only supported for dict (sync) responses" + ) + value: Any = self._dict + path_list = list(path) + for key in path_list: + if not isinstance(value, dict): + if required: + raise ValueError( + f"{self._command_name}: Expected dict at path {path_list}, got {type(value).__name__}" + ) + return None + value = value.get(key, {}) + + if value is None or (isinstance(value, dict) and not value and required): + if required: + raise ValueError( + f"{self._command_name}: Could not find value at path {path_list}. Response: {self._dict}" + ) + return None + return value + + def get_parameter_string( + self, + name: str, + allow_root_fallback: bool = False, + ) -> str: + """Get Parameter[@name=name]/String value (dict or ET response).""" + if self._dict is not None: + response_data_path: List[str] = [ + f"{self._command_name}Response", + "ResponseData", + ] + response_data = self._get_dict_path(response_data_path, required=True) + param = response_data.get("Parameter") + if isinstance(param, list): + found = next((p for p in param if p.get("name") == name), None) + elif isinstance(param, dict): + found = param if param.get("name") == name else None + else: + found = None + if found is None: + raise ValueError( + f"Parameter '{name}' not found in {self._command_name} response" + ) + value = found.get("String") + if value is None: + raise ValueError(f"String element not found in {name} parameter") + return str(value) + + resp = self._et_root + if resp is None: + raise ValueError(f"Empty response from {self._command_name}") + + param = None + if resp.tag == "Parameter" and resp.get("name") == name: + param = resp + else: + param = resp.find(f".//Parameter[@name='{name}']") + + if param is None and allow_root_fallback: + param = resp if resp.tag == "Parameter" else resp.find(".//Parameter") + + if param is None: + xml_str = ET.tostring(resp, encoding="unicode") + raise ValueError( + f"Parameter '{name}' not found in {self._command_name} response. " + f"Root element tag: {resp.tag}\nFull XML response:\n{xml_str}" + ) + + string_elem = param.find("String") + if string_elem is None or string_elem.text is None: + raise ValueError( + f"String element not found in {self._command_name} Parameter response" + ) + return str(string_elem.text) + + def _get_dict_path(self, path: List[str], required: bool = True) -> Any: + """Internal: traverse dict by path.""" + if self._dict is None: + if required: + raise ValueError(f"{self._command_name}: response is not dict") + return None + value: Any = self._dict + for key in path: + if not isinstance(value, dict): + if required: + raise ValueError( + f"{self._command_name}: Expected dict at path {path}, got {type(value).__name__}" + ) + return None + value = value.get(key, {}) + if value is None or (isinstance(value, dict) and not value and required): + if required: + raise ValueError(f"{self._command_name}: Could not find value at path {path}") + return None + return value + + def raw(self) -> Union[Dict[str, Any], ET.Element]: + """Return the underlying dict or ET root (e.g. for GetLastData).""" + if self._dict is not None: + return self._dict + if self._et_root is not None: + return self._et_root + return {} + + class ProtocolProgress(NamedTuple): """Position in a protocol at a given elapsed time (ODTC-specific). All indices zero-based.""" @@ -578,143 +727,18 @@ async def _run_async_command( ) # ============================================================================ - # Response Parsing Utilities + # Request + normalized response # ============================================================================ - def _extract_dict_path( - self, resp: dict, path: List[str], command_name: str, required: bool = True - ) -> Any: - """Extract nested value from dict response using path. - - Args: - resp: Response dict from send_command (SOAP-decoded). - path: List of keys to traverse (e.g., ["GetStatusResponse", "state"]). - command_name: Command name for error messages. - required: If True, raise ValueError if path not found. - - Returns: - Extracted value, or None if not required and not found. - - Raises: - ValueError: If required=True and path not found or invalid structure. - """ - value = resp - for key in path: - if not isinstance(value, dict): - if required: - raise ValueError( - f"{command_name}: Expected dict at path {path}, got {type(value).__name__}" - ) - return None - value = value.get(key, {}) - - if value is None or (isinstance(value, dict) and not value and required): - if required: - raise ValueError( - f"{command_name}: Could not find value at path {path}. Response: {resp}" - ) - return None - self.logger.debug(f"{command_name} extracted value at path {path}: {value!r}") - return value - - def _extract_xml_parameter( - self, resp: Any, param_name: str, command_name: str, allow_root_fallback: bool = False - ) -> str: - """Extract parameter value from ElementTree XML 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) - Args: - resp: ElementTree root from send_command. - param_name: Name of parameter to extract (matches 'name' attribute on Parameter element). - command_name: Command name for error messages. - allow_root_fallback: If True, fall back to root-based behavior when parameter - with matching name is not found. If False, raise error if parameter not found. - - Returns: - Parameter text value. - - Raises: - ValueError: If response is None or parameter not found. - """ - if resp is None: - raise ValueError(f"Empty response from {command_name}") - - import xml.etree.ElementTree as ET - - # First, try strict matching by name attribute - # Look for Parameter[@name='param_name'] in ResponseData or anywhere in tree - param = None - if resp.tag == "Parameter" and resp.get("name") == param_name: - param = resp - else: - # Search for Parameter with matching name attribute - param = resp.find(f".//Parameter[@name='{param_name}']") - - # Fallback: if not found and fallback allowed, use root-based behavior - # (for cases where temperature data is in root without name attribute) - if param is None and allow_root_fallback: - # Either root is Parameter, or find first Parameter in ResponseData - param = resp if resp.tag == "Parameter" else resp.find(".//Parameter") - - if param is None: - # Include full XML structure in error for debugging - xml_str = ET.tostring(resp, encoding='unicode') - raise ValueError( - f"Parameter '{param_name}' not found in {command_name} response. " - f"Root element tag: {resp.tag}\n" - f"Full XML response:\n{xml_str}" - ) - - # Extract String element from Parameter (contains escaped XML) - string_elem = param.find("String") - if string_elem is None or string_elem.text is None: - raise ValueError(f"String element not found in {command_name} Parameter response") - - return str(string_elem.text) - - def _get_parameter_string_from_response( - self, - resp: Any, - param_name: str, - command_name: str, - response_data_path: Optional[List[str]] = None, - allow_root_fallback: bool = False, - ) -> str: - """Get a parameter's String value from a command response (dict or ElementTree). - - Handles both synchronous (dict) and asynchronous (ElementTree) response shapes. - For dict responses, response_data_path must be the path to ResponseData - (e.g. ["ReadActualTemperatureResponse", "ResponseData"]). - """ - if isinstance(resp, dict): - if not response_data_path: - raise ValueError( - f"{command_name}: response_data_path required when response is dict" - ) - response_data = self._extract_dict_path( - resp, response_data_path, command_name - ) - param = response_data.get("Parameter") - if isinstance(param, list): - sensor_param = next( - (p for p in param if p.get("name") == param_name), None - ) - elif isinstance(param, dict): - sensor_param = param if param.get("name") == param_name else None - else: - sensor_param = None - if sensor_param is None: - raise ValueError( - f"Parameter '{param_name}' not found in {command_name} response" - ) - value = sensor_param.get("String") - if value is None: - raise ValueError( - f"String element not found in {param_name} parameter" - ) - return str(value) - return self._extract_xml_parameter( - resp, param_name, command_name, allow_root_fallback=allow_root_fallback - ) + 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 @@ -729,11 +753,8 @@ async def get_status(self) -> str: Raises: ValueError: If response format is unexpected and state cannot be extracted. """ - resp = await self._sila.send_command("GetStatus") - # GetStatus is synchronous - resp is a dict from soap_decode - # ODTC standard structure: {"GetStatusResponse": {"state": "idle", ...}} - resp_dict = cast(Dict[str, Any], resp) - state = self._extract_dict_path(resp_dict, ["GetStatusResponse", "state"], "GetStatus") + resp = await self._request("GetStatus") + state = resp.get_value("GetStatusResponse", "state") return str(state) async def initialize(self, wait: bool = True) -> Optional[CommandExecution]: @@ -792,13 +813,10 @@ async def get_device_identification(self) -> dict: Returns: Device identification dictionary. """ - resp = await self._sila.send_command("GetDeviceIdentification") - # GetDeviceIdentification is synchronous - resp is a dict from soap_decode - resp_dict = cast(Dict[str, Any], resp) - result = self._extract_dict_path( - resp_dict, - ["GetDeviceIdentificationResponse", "GetDeviceIdentificationResult"], - "GetDeviceIdentification", + resp = await self._request("GetDeviceIdentification") + result = resp.get_value( + "GetDeviceIdentificationResponse", + "GetDeviceIdentificationResult", required=False, ) return result if isinstance(result, dict) else {} @@ -875,18 +893,8 @@ async def read_temperatures(self) -> ODTCSensorValues: Returns: ODTCSensorValues with temperatures in °C. """ - resp = await self._sila.send_command("ReadActualTemperature") - self.logger.debug( - "ReadActualTemperature response type: %s", - type(resp).__name__, - ) - sensor_xml = self._get_parameter_string_from_response( - resp, - "SensorValues", - "ReadActualTemperature", - response_data_path=["ReadActualTemperatureResponse", "ResponseData"], - allow_root_fallback=True, - ) + 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 @@ -897,10 +905,8 @@ async def get_last_data(self) -> str: Returns: CSV string with temperature trace data. """ - resp = await self._sila.send_command("GetLastData") - # Response contains CSV data in SiLA Data Capture format - # For now, return the raw response - parsing can be added later - return str(resp) # type: ignore + resp = await self._request("GetLastData") + return str(resp.raw()) # Method control commands (SiLA: ExecuteMethod; method = runnable protocol) async def execute_method( @@ -1126,33 +1132,29 @@ async def get_method_set(self) -> ODTCMethodSet: Raises: ValueError: If response is empty or MethodsXML parameter not found. """ - resp = await self._sila.send_command("GetParameters") - # Extract MethodsXML parameter - method_set_xml = self._extract_xml_parameter(resp, "MethodsXML", "GetParameters") - # Parse MethodSet XML (it's escaped in the response) + method_set_xml = await self._get_method_set_xml() return parse_method_set(method_set_xml) - async def get_protocol(self, name: str) -> Optional[StoredProtocol]: + async def get_protocol(self, name: str) -> Optional[ODTCProtocol]: """Get a stored protocol by name (runnable methods only; premethods return None). - Resolves the stored method by name. If it is a runnable method (ODTCMethod), - converts it to Protocol + config and returns StoredProtocol. If it is a - premethod (ODTCPreMethod) or not found, returns None. + Resolves the stored method by name. If it is a runnable method (ODTCProtocol + with kind='method'), returns that ODTCProtocol. If it is a premethod or not + found, returns None. Args: name: Protocol name to retrieve. Returns: - StoredProtocol(name, protocol, config) if a runnable method exists, None otherwise. + ODTCProtocol if a runnable method exists, None otherwise. """ method_set = await self.get_method_set() resolved = get_method_by_name(method_set, name) if resolved is None: return None - if isinstance(resolved, ODTCPreMethod): + if resolved.kind == "premethod": return None - protocol, config = odtc_method_to_protocol(resolved) - return StoredProtocol(name=name, protocol=protocol, config=config) + return resolved async def list_protocols(self) -> ProtocolList: """List all protocol names (methods and premethods) on the device. @@ -1197,9 +1199,56 @@ def get_constraints(self) -> ODTCHardwareConstraints: """ return get_constraints(self._variant) + async def _upload_odtc_protocol( + self, + odtc: ODTCProtocol, + allow_overwrite: bool = False, + execute: bool = False, + wait: bool = True, + debug_xml: bool = False, + xml_output_path: Optional[str] = None, + ) -> Optional[MethodExecution]: + """Upload a single ODTCProtocol (method or premethod) and optionally execute. + + Internal single entrypoint: builds one-item ODTCMethodSet and calls + upload_method_set, then execute_method if execute=True. + + Returns: + MethodExecution if execute=True and wait=False; None otherwise. + """ + resolved_name = resolve_protocol_name(odtc.name) + is_scratch = not odtc.name or odtc.name == "" + resolved_datetime = odtc.datetime or generate_odtc_timestamp() + + if is_scratch and allow_overwrite is False: + allow_overwrite = True + if not odtc.name: + self.logger.warning( + "ODTCProtocol name resolved to scratch name '%s'. " + "Auto-enabling allow_overwrite=True.", + resolved_name, + ) + + odtc_copy = replace(odtc, name=resolved_name, datetime=resolved_datetime) + if odtc.kind == "method": + method_set = ODTCMethodSet(methods=[odtc_copy], premethods=[]) + else: + method_set = ODTCMethodSet(methods=[], premethods=[odtc_copy]) + + await self.upload_method_set( + method_set, + allow_overwrite=allow_overwrite, + debug_xml=debug_xml, + xml_output_path=xml_output_path, + ) + + if execute: + return await self.execute_method(resolved_name, wait=wait) + return None + async def upload_protocol( self, - protocol: Protocol, + protocol: Union[Protocol, ODTCProtocol], name: Optional[str] = None, config: Optional[ODTCConfig] = None, block_max_volume: Optional[float] = None, @@ -1207,13 +1256,14 @@ async def upload_protocol( debug_xml: bool = False, xml_output_path: Optional[str] = None, ) -> str: - """Upload a Protocol to the device. + """Upload a Protocol or ODTCProtocol to the device. Args: - protocol: PyLabRobot Protocol to upload. + protocol: PyLabRobot Protocol or ODTCProtocol to upload. name: Method name. If None, uses scratch name "plr_currentProtocol". - config: Optional ODTCConfig. If None, uses variant-aware defaults; if - block_max_volume is provided and in 0–100 µL, sets fluid_quantity from it. + config: Optional ODTCConfig (used only when protocol is Protocol). If None, + uses variant-aware defaults; if block_max_volume is provided and in 0–100 µL, + sets fluid_quantity from it. block_max_volume: Optional volume in µL. If provided and config is None, used to set fluid_quantity. If config is provided, validates volume matches fluid_quantity. @@ -1228,29 +1278,31 @@ async def upload_protocol( ValueError: If allow_overwrite=False and method name already exists. ValueError: If block_max_volume > 100 µL. """ - if config is None: - if block_max_volume is not None and block_max_volume > 0 and block_max_volume <= 100: - fluid_quantity = _volume_to_fluid_quantity(block_max_volume) - config = self.get_default_config(fluid_quantity=fluid_quantity) - else: - config = self.get_default_config() - elif block_max_volume is not None and block_max_volume > 0: - _validate_volume_fluid_quantity( - block_max_volume, config.fluid_quantity, is_premethod=False, logger=self.logger - ) - - if name is not None: - config = replace(config, name=name) + if isinstance(protocol, ODTCProtocol): + odtc = replace(protocol, name=name or protocol.name) if name is not None else protocol + else: + if config is None: + if block_max_volume is not None and block_max_volume > 0 and block_max_volume <= 100: + fluid_quantity = _volume_to_fluid_quantity(block_max_volume) + config = self.get_default_config(fluid_quantity=fluid_quantity) + else: + config = self.get_default_config() + elif block_max_volume is not None and block_max_volume > 0: + _validate_volume_fluid_quantity( + block_max_volume, config.fluid_quantity, is_premethod=False, logger=self.logger + ) + if name is not None: + config = replace(config, name=name) + odtc = protocol_to_odtc_protocol(protocol, config=config) - method = protocol_to_odtc_method(protocol, config=config) - await self.upload_method( - method, + await self._upload_odtc_protocol( + odtc, allow_overwrite=allow_overwrite, execute=False, debug_xml=debug_xml, xml_output_path=xml_output_path, ) - return resolve_protocol_name(method.name) + return resolve_protocol_name(odtc.name) async def run_stored_protocol(self, name: str, wait: bool = False, **kwargs) -> MethodExecution: """Execute a stored protocol by name (single SiLA ExecuteMethod call). @@ -1267,21 +1319,15 @@ async def run_stored_protocol(self, name: str, wait: bool = False, **kwargs) -> Returns: MethodExecution handle (completed if wait=True). """ - eta: Optional[float] = None - stored = await self.get_protocol(name) - if stored is not None: - method = protocol_to_odtc_method(stored.protocol, config=stored.config) - eta = estimate_method_duration_seconds(method) - else: - method_set = await self.get_method_set() - resolved = get_method_by_name(method_set, name) - if isinstance(resolved, ODTCPreMethod): - eta = PREMETHOD_ESTIMATED_DURATION_SECONDS + method_set = await self.get_method_set() + resolved = get_method_by_name(method_set, name) + eta = estimate_odtc_protocol_duration_seconds(resolved) if resolved else None + protocol_view = odtc_protocol_to_protocol(resolved)[0] if resolved else None return await self.execute_method( name, wait=wait, estimated_duration_seconds=eta, - protocol=stored.protocol if stored is not None else None, + protocol=protocol_view, ) async def upload_method_set( @@ -1316,14 +1362,14 @@ async def upload_method_set( for method in method_set.methods: existing_method = get_method_by_name(existing_method_set, method.name) if existing_method is not None: - method_type = "PreMethod" if isinstance(existing_method, ODTCPreMethod) else "Method" + method_type = "PreMethod" if existing_method.kind == "premethod" else "Method" conflicts.append(f"Method '{method.name}' already exists as {method_type}") # Check all premethod names (unified search) for premethod in method_set.premethods: existing_method = get_method_by_name(existing_method_set, premethod.name) if existing_method is not None: - method_type = "PreMethod" if isinstance(existing_method, ODTCPreMethod) else "Method" + method_type = "PreMethod" if existing_method.kind == "premethod" else "Method" conflicts.append(f"Method '{premethod.name}' already exists as {method_type}") if conflicts: @@ -1373,150 +1419,6 @@ async def upload_method_set( await self._sila.send_command("SetParameters", paramsXML=params_xml) - async def upload_method( - self, - method: ODTCMethod, - allow_overwrite: bool = False, - execute: bool = False, - wait: bool = True, - debug_xml: bool = False, - xml_output_path: Optional[str] = None, - ) -> Optional[MethodExecution]: - """Upload a single method to the device. - - Convenience wrapper that wraps method in MethodSet and uploads. - - Args: - method: ODTCMethod to upload. - allow_overwrite: If False, raise ValueError if method name already exists - on the device. If True, allow overwriting existing method/premethod. - If method name resolves to scratch name and this is not explicitly False, - it will be set to True automatically. - execute: If True, execute the method after uploading. If False, only upload. - wait: If execute=True and wait=True, block until method completes. - If execute=True and wait=False, return MethodExecution handle. - debug_xml: If True, log the generated XML to the logger at DEBUG level. - Passed through to upload_method_set. - xml_output_path: Optional file path to save the generated MethodSet XML. - Passed through to upload_method_set. - - Returns: - If execute=False: None - If execute=True and wait=True: None (blocks until complete) - If execute=True and wait=False: MethodExecution handle (awaitable, has request_id) - - Raises: - ValueError: If allow_overwrite=False and method name already exists - on the device (checking both methods and premethods for conflicts). - """ - # Resolve name (use scratch name if None/empty) - resolved_name = resolve_protocol_name(method.name) - # Check if we're using a scratch name (original name was None/empty) - is_scratch_name = not method.name or method.name == "" - - # Generate timestamp if not already set - resolved_datetime = method.datetime if method.datetime else generate_odtc_timestamp() - - # Auto-overwrite for scratch names unless explicitly disabled - if is_scratch_name and allow_overwrite is False: - # Check if user explicitly passed False (vs default) - # Since we can't distinguish, we'll auto-overwrite for scratch names - # but log a warning if they explicitly set False - allow_overwrite = True - if not method.name: # Only warn if name was actually None/empty (not just resolved) - self.logger.warning( - f"Method name resolved to scratch name '{resolved_name}'. " - "Auto-enabling allow_overwrite=True for scratch methods." - ) - - # Create method copy with resolved name and timestamp - method_copy = ODTCMethod( - name=resolved_name, - variant=method.variant, - plate_type=method.plate_type, - fluid_quantity=method.fluid_quantity, - post_heating=method.post_heating, - start_block_temperature=method.start_block_temperature, - start_lid_temperature=method.start_lid_temperature, - steps=method.steps, - pid_set=method.pid_set, - creator=method.creator, - description=method.description, - datetime=resolved_datetime, - ) - - method_set = ODTCMethodSet(methods=[method_copy], premethods=[]) - await self.upload_method_set( - method_set, - allow_overwrite=allow_overwrite, - debug_xml=debug_xml, - xml_output_path=xml_output_path, - ) - - if execute: - return await self.execute_method(resolved_name, wait=wait) - return None - - async def upload_premethod( - self, - premethod: ODTCPreMethod, - allow_overwrite: bool = False, - debug_xml: bool = False, - xml_output_path: Optional[str] = None, - ) -> None: - """Upload a single premethod to the device. - - Convenience wrapper that wraps premethod in MethodSet and uploads. - - Args: - premethod: ODTCPreMethod to upload. - allow_overwrite: If False, raise ValueError if premethod name already exists - on the device. If True, allow overwriting existing method/premethod. - If premethod name resolves to scratch name and this is not explicitly False, - it will be set to True automatically. - - Raises: - ValueError: If allow_overwrite=False and premethod name already exists - on the device (checking both methods and premethods for conflicts). - """ - # Resolve name (use scratch name if None/empty) - resolved_name = resolve_protocol_name(premethod.name) - # Check if we're using a scratch name (original name was None/empty) - is_scratch_name = not premethod.name or premethod.name == "" - - # Generate timestamp if not already set - resolved_datetime = premethod.datetime if premethod.datetime else generate_odtc_timestamp() - - # Auto-overwrite for scratch names unless explicitly disabled - if is_scratch_name and allow_overwrite is False: - # Check if user explicitly passed False (vs default) - # Since we can't distinguish, we'll auto-overwrite for scratch names - # but log a warning if they explicitly set False - allow_overwrite = True - if not premethod.name: # Only warn if name was actually None/empty (not just resolved) - self.logger.warning( - f"PreMethod name resolved to scratch name '{resolved_name}'. " - "Auto-enabling allow_overwrite=True for scratch premethods." - ) - - # Create premethod copy with resolved name and timestamp - premethod_copy = ODTCPreMethod( - name=resolved_name, - target_block_temperature=premethod.target_block_temperature, - target_lid_temperature=premethod.target_lid_temperature, - creator=premethod.creator, - description=premethod.description, - datetime=resolved_datetime, - ) - - method_set = ODTCMethodSet(methods=[], premethods=[premethod_copy]) - await self.upload_method_set( - method_set, - allow_overwrite=allow_overwrite, - debug_xml=debug_xml, - xml_output_path=xml_output_path, - ) - async def upload_method_set_from_file( self, filepath: str, @@ -1542,11 +1444,7 @@ async def save_method_set_to_file(self, filepath: str) -> None: Args: filepath: Path to save MethodSet XML file. """ - resp = await self._sila.send_command("GetParameters") - # Extract MethodsXML parameter - method_set_xml = self._extract_xml_parameter(resp, "MethodsXML", "GetParameters") - # XML is escaped in the response, so we get it as-is - # Write to file + method_set_xml = await self._get_method_set_xml() with open(filepath, "w", encoding="utf-8") as f: f.write(method_set_xml) @@ -1597,14 +1495,16 @@ async def set_block_temperature( target_lid_temp = constraints.max_lid_temp resolved_name = resolve_protocol_name(None) - premethod = ODTCPreMethod( + odtc = ODTCProtocol( + stages=[], + kind="premethod", name=resolved_name, target_block_temperature=block_temp, target_lid_temperature=target_lid_temp, datetime=generate_odtc_timestamp(), ) - await self.upload_premethod( - premethod, + await self._upload_odtc_protocol( + odtc, allow_overwrite=True, debug_xml=debug_xml, xml_output_path=xml_output_path, @@ -1644,53 +1544,60 @@ async def deactivate_lid(self) -> None: async def run_protocol( self, - protocol: Protocol, + protocol: Union[Protocol, ODTCProtocol], block_max_volume: float, **kwargs: Any, ) -> MethodExecution: - """Execute thermocycler protocol (convert, upload, execute). + """Execute thermocycler protocol (convert if needed, upload, execute). - Converts Protocol to ODTCMethod, uploads it, then executes by name. - Always returns immediately with a MethodExecution handle; to block until - completion, await handle.wait() or use wait_for_profile_completion(). - Config is derived from block_max_volume and backend variant if not provided. + Accepts Protocol or ODTCProtocol. Converts Protocol to ODTCProtocol when + needed, uploads, then executes by name. Always returns immediately with a + MethodExecution handle; to block until completion, await handle.wait() or + use wait_for_profile_completion(). Config is derived from block_max_volume + and backend variant when protocol is Protocol and config is not provided. Args: - protocol: Protocol to execute. + protocol: Protocol or ODTCProtocol to execute. block_max_volume: Maximum block volume (µL) for safety; used to set - fluid_quantity when config is None. + fluid_quantity when protocol is Protocol and config is None. **kwargs: Backend-specific options. ODTC accepts ``config`` (ODTCConfig, - optional); if omitted, built from block_max_volume and variant. + optional); used only when protocol is Protocol. Returns: MethodExecution handle. Caller can await handle.wait() or wait_for_profile_completion() to block until done. """ - config = kwargs.pop("config", None) - if config is None: - if block_max_volume > 0 and block_max_volume <= 100: - fluid_quantity = _volume_to_fluid_quantity(block_max_volume) - config = self.get_default_config(fluid_quantity=fluid_quantity) - else: - config = self.get_default_config() - if block_max_volume > 0 and block_max_volume <= 100: - _validate_volume_fluid_quantity( - block_max_volume, config.fluid_quantity, is_premethod=False, logger=self.logger - ) + if isinstance(protocol, ODTCProtocol): + odtc = protocol + if odtc.kind != "method": + raise ValueError("run_protocol requires a method (ODTCProtocol with kind='method')") else: - if block_max_volume > 0: - _validate_volume_fluid_quantity( - block_max_volume, config.fluid_quantity, is_premethod=False, logger=self.logger - ) + config = kwargs.pop("config", None) + if config is None: + if block_max_volume > 0 and block_max_volume <= 100: + fluid_quantity = _volume_to_fluid_quantity(block_max_volume) + config = self.get_default_config(fluid_quantity=fluid_quantity) + else: + config = self.get_default_config() + if block_max_volume > 0 and block_max_volume <= 100: + _validate_volume_fluid_quantity( + block_max_volume, config.fluid_quantity, is_premethod=False, logger=self.logger + ) + else: + if block_max_volume > 0: + _validate_volume_fluid_quantity( + block_max_volume, config.fluid_quantity, is_premethod=False, logger=self.logger + ) + odtc = protocol_to_odtc_protocol(protocol, config=config) - method = protocol_to_odtc_method(protocol, config=config) - await self.upload_method(method, allow_overwrite=True, execute=False) - resolved_name = resolve_protocol_name(method.name) - eta = estimate_method_duration_seconds(method) + await self._upload_odtc_protocol(odtc, allow_overwrite=True, execute=False) + resolved_name = resolve_protocol_name(odtc.name) + eta = estimate_odtc_protocol_duration_seconds(odtc) handle = await self.execute_method( resolved_name, wait=False, estimated_duration_seconds=eta ) - self._protocol_by_request_id[handle.request_id] = protocol + protocol_view = odtc_protocol_to_protocol(odtc)[0] + self._protocol_by_request_id[handle.request_id] = protocol_view return handle async def get_block_current_temperature(self) -> List[float]: diff --git a/pylabrobot/thermocycling/inheco/odtc_data_events.py b/pylabrobot/thermocycling/inheco/odtc_data_events.py deleted file mode 100644 index 90d303c8158..00000000000 --- a/pylabrobot/thermocycling/inheco/odtc_data_events.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Parse ODTC SiLA DataEvent payloads into structured snapshots.""" - -from __future__ import annotations - -import html -import xml.etree.ElementTree as ET -from dataclasses import dataclass -from typing import Any, Dict, Optional - - -@dataclass -class ODTCDataEventSnapshot: - """Parsed snapshot from one DataEvent (elapsed time and temperatures).""" - - elapsed_s: float - target_temp_c: Optional[float] = None - current_temp_c: Optional[float] = None - lid_temp_c: Optional[float] = None - - -def _parse_series_value(series_elem: Any) -> Optional[float]: - """Extract last integerValue from a dataSeries element as float.""" - values = series_elem.findall(".//integerValue") - if not values: - return None - text = values[-1].text - if text is None: - return None - try: - return float(text) - except ValueError: - return None - - -def parse_data_event_payload(payload: Dict[str, Any]) -> Optional[ODTCDataEventSnapshot]: - """Parse a single DataEvent payload into an ODTCDataEventSnapshot. - - Input: dict with 'requestId' and 'dataValue' (string of XML, possibly - double-escaped). Extracts Elapsed time (ms), Target temperature, Current - temperature, LID temperature (1/100°C -> °C). Returns None on parse error. - """ - if not isinstance(payload, dict): - return None - data_value = payload.get("dataValue") - if not data_value or not isinstance(data_value, str): - return None - try: - outer = ET.fromstring(data_value) - except ET.ParseError: - return None - any_data = outer.find(".//{*}AnyData") or outer.find(".//AnyData") - if any_data is None or any_data.text is None: - return None - inner_xml = any_data.text.strip() - if not inner_xml: - return None - if "<" in inner_xml or ">" in inner_xml: - inner_xml = html.unescape(inner_xml) - try: - inner = ET.fromstring(inner_xml) - except ET.ParseError: - return None - elapsed_s = 0.0 - target_temp_c: Optional[float] = None - current_temp_c: Optional[float] = None - lid_temp_c: Optional[float] = None - for elem in inner.iter(): - if not elem.tag.endswith("dataSeries"): - continue - name_id = elem.get("nameId") - unit = elem.get("unit") or "" - raw = _parse_series_value(elem) - if raw is None: - continue - if name_id == "Elapsed time" and unit == "ms": - elapsed_s = raw / 1000.0 - elif name_id == "Target temperature" and unit == "1/100°C": - target_temp_c = raw / 100.0 - elif name_id == "Current temperature" and unit == "1/100°C": - current_temp_c = raw / 100.0 - elif name_id == "LID temperature" and unit == "1/100°C": - lid_temp_c = raw / 100.0 - return ODTCDataEventSnapshot( - elapsed_s=elapsed_s, - target_temp_c=target_temp_c, - current_temp_c=current_temp_c, - lid_temp_c=lid_temp_c, - ) diff --git a/pylabrobot/thermocycling/inheco/odtc_model.py b/pylabrobot/thermocycling/inheco/odtc_model.py index 5ff8f04d28a..1f042a560f5 100644 --- a/pylabrobot/thermocycling/inheco/odtc_model.py +++ b/pylabrobot/thermocycling/inheco/odtc_model.py @@ -8,15 +8,18 @@ from __future__ import annotations +import html import logging from datetime import datetime import xml.etree.ElementTree as ET from dataclasses import dataclass, field, fields from enum import Enum -from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Tuple, Type, TypeVar, Union, cast, get_args, get_origin, get_type_hints +from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Literal, Optional, Tuple, Type, TypeVar, Union, cast, get_args, get_origin, get_type_hints + +from pylabrobot.thermocycling.standard import Protocol, Stage, Step if TYPE_CHECKING: - from pylabrobot.thermocycling.standard import Protocol + pass # Protocol used at runtime for ODTCProtocol base logger = logging.getLogger(__name__) @@ -303,11 +306,11 @@ def get_loop_structure(self) -> List[tuple]: @dataclass class ODTCMethodSet: - """Container for all methods and premethods.""" + """Container for all methods and premethods (in-memory as ODTCProtocol).""" - delete_all_methods: bool = xml_field(tag="DeleteAllMethods", default=False) - premethods: List[ODTCPreMethod] = xml_child_list(tag="PreMethod") - methods: List[ODTCMethod] = xml_child_list(tag="Method") + delete_all_methods: bool = False + premethods: List[ODTCProtocol] = field(default_factory=list) + methods: List[ODTCProtocol] = field(default_factory=list) @dataclass @@ -359,6 +362,91 @@ def format_compact(self) -> str: return f"ODTCSensorValues {line}" +# ============================================================================= +# DataEvent Snapshots (SiLA DataEvent payload parsing) +# ============================================================================= + + +@dataclass +class ODTCDataEventSnapshot: + """Parsed snapshot from one DataEvent (elapsed time and temperatures).""" + + elapsed_s: float + target_temp_c: Optional[float] = None + current_temp_c: Optional[float] = None + lid_temp_c: Optional[float] = None + + +def _parse_data_event_series_value(series_elem: Any) -> Optional[float]: + """Extract last integerValue from a dataSeries element as float.""" + values = series_elem.findall(".//integerValue") + if not values: + return None + text = values[-1].text + if text is None: + return None + try: + return float(text) + except ValueError: + return None + + +def parse_data_event_payload(payload: Dict[str, Any]) -> Optional[ODTCDataEventSnapshot]: + """Parse a single DataEvent payload into an ODTCDataEventSnapshot. + + Input: dict with 'requestId' and 'dataValue' (string of XML, possibly + double-escaped). Extracts Elapsed time (ms), Target temperature, Current + temperature, LID temperature (1/100°C -> °C). Returns None on parse error. + """ + if not isinstance(payload, dict): + return None + data_value = payload.get("dataValue") + if not data_value or not isinstance(data_value, str): + return None + try: + outer = ET.fromstring(data_value) + except ET.ParseError: + return None + any_data = outer.find(".//{*}AnyData") or outer.find(".//AnyData") + if any_data is None or any_data.text is None: + return None + inner_xml = any_data.text.strip() + if not inner_xml: + return None + if "<" in inner_xml or ">" in inner_xml: + inner_xml = html.unescape(inner_xml) + try: + inner = ET.fromstring(inner_xml) + except ET.ParseError: + return None + elapsed_s = 0.0 + target_temp_c: Optional[float] = None + current_temp_c: Optional[float] = None + lid_temp_c: Optional[float] = None + for elem in inner.iter(): + if not elem.tag.endswith("dataSeries"): + continue + name_id = elem.get("nameId") + unit = elem.get("unit") or "" + raw = _parse_data_event_series_value(elem) + if raw is None: + continue + if name_id == "Elapsed time" and unit == "ms": + elapsed_s = raw / 1000.0 + elif name_id == "Target temperature" and unit == "1/100°C": + target_temp_c = raw / 100.0 + elif name_id == "Current temperature" and unit == "1/100°C": + current_temp_c = raw / 100.0 + elif name_id == "LID temperature" and unit == "1/100°C": + lid_temp_c = raw / 100.0 + return ODTCDataEventSnapshot( + elapsed_s=elapsed_s, + target_temp_c=target_temp_c, + current_temp_c=current_temp_c, + lid_temp_c=lid_temp_c, + ) + + # ============================================================================= # Protocol Conversion Config Classes # ============================================================================= @@ -500,6 +588,160 @@ def validate(self) -> List[str]: return errors +# ============================================================================= +# ODTCProtocol (protocol + config; subclasses Protocol for resource API) +# ============================================================================= + + +@dataclass +class ODTCProtocol(Protocol): + """ODTC runnable unit: protocol + config (method or premethod). + + Subclasses Protocol so Thermocycler.run_protocol(protocol, ...) accepts + ODTCProtocol. For kind='method', stages is the cycle; for kind='premethod', + pass stages=[] (premethods run by name only). + """ + + kind: Literal["method", "premethod"] = "method" + name: str = "" + creator: Optional[str] = None + description: Optional[str] = None + datetime: Optional[str] = None + target_block_temperature: float = 0.0 + target_lid_temperature: float = 0.0 + variant: int = 960000 + plate_type: int = 0 + fluid_quantity: int = 0 + post_heating: bool = False + start_block_temperature: float = 0.0 + start_lid_temperature: float = 0.0 + steps: List[ODTCStep] = field(default_factory=list) + pid_set: List[ODTCPID] = field(default_factory=lambda: [ODTCPID(number=1)]) + step_settings: Dict[int, ODTCStepSettings] = field(default_factory=dict) + default_heating_slope: float = 4.4 + default_cooling_slope: float = 2.2 + + +def _odtc_method_to_odtc_protocol(method: ODTCMethod) -> ODTCProtocol: + """Build ODTCProtocol from ODTCMethod (for methods loaded from device).""" + protocol, config = odtc_method_to_protocol(method) + return ODTCProtocol( + stages=protocol.stages, + kind="method", + name=method.name, + creator=method.creator, + description=method.description, + datetime=method.datetime, + variant=method.variant, + plate_type=method.plate_type, + fluid_quantity=method.fluid_quantity, + post_heating=method.post_heating, + start_block_temperature=method.start_block_temperature, + start_lid_temperature=method.start_lid_temperature, + steps=list(method.steps), + pid_set=list(method.pid_set) if method.pid_set else [ODTCPID(number=1)], + step_settings=dict(config.step_settings), + default_heating_slope=config.default_heating_slope, + default_cooling_slope=config.default_cooling_slope, + ) + + +def _odtc_premethod_to_odtc_protocol(premethod: ODTCPreMethod) -> ODTCProtocol: + """Build ODTCProtocol from ODTCPreMethod (for premethods loaded from device).""" + return ODTCProtocol( + stages=[], + kind="premethod", + name=premethod.name, + creator=premethod.creator, + description=premethod.description, + datetime=premethod.datetime, + target_block_temperature=premethod.target_block_temperature, + target_lid_temperature=premethod.target_lid_temperature, + ) + + +def _odtc_protocol_to_method(odtc: ODTCProtocol) -> ODTCMethod: + """Build ODTCMethod from ODTCProtocol (kind must be 'method').""" + if odtc.kind != "method": + raise ValueError("ODTCProtocol must have kind='method' to convert to ODTCMethod") + return ODTCMethod( + name=odtc.name, + variant=odtc.variant, + plate_type=odtc.plate_type, + fluid_quantity=odtc.fluid_quantity, + post_heating=odtc.post_heating, + start_block_temperature=odtc.start_block_temperature, + start_lid_temperature=odtc.start_lid_temperature, + steps=list(odtc.steps), + pid_set=list(odtc.pid_set) if odtc.pid_set else [ODTCPID(number=1)], + creator=odtc.creator, + description=odtc.description, + datetime=odtc.datetime, + ) + + +def _odtc_protocol_to_premethod(odtc: ODTCProtocol) -> ODTCPreMethod: + """Build ODTCPreMethod from ODTCProtocol (kind must be 'premethod').""" + if odtc.kind != "premethod": + raise ValueError("ODTCProtocol must have kind='premethod' to convert to ODTCPreMethod") + return ODTCPreMethod( + name=odtc.name, + target_block_temperature=odtc.target_block_temperature, + target_lid_temperature=odtc.target_lid_temperature, + creator=odtc.creator, + description=odtc.description, + datetime=odtc.datetime, + ) + + +def protocol_to_odtc_protocol( + protocol: "Protocol", + config: Optional[ODTCConfig] = None, +) -> ODTCProtocol: + """Convert a standard Protocol to ODTCProtocol (kind='method'). + + Args: + protocol: Standard Protocol with stages and steps. + config: Optional ODTC config; if None, defaults are used. + + Returns: + ODTCProtocol ready for upload or run. + """ + method = protocol_to_odtc_method(protocol, config) + return _odtc_method_to_odtc_protocol(method) + + +def odtc_protocol_to_protocol(odtc: ODTCProtocol) -> Tuple["Protocol", ODTCProtocol]: + """Convert ODTCProtocol to Protocol view and return (protocol, odtc). + + For kind='method', builds Protocol from steps; for kind='premethod', + returns Protocol(stages=[]) since premethods have no cycle. + + Returns: + Tuple of (Protocol, ODTCProtocol). The ODTCProtocol is the same object + for convenience (e.g. config fields). + """ + if odtc.kind == "method": + method = _odtc_protocol_to_method(odtc) + protocol, _ = odtc_method_to_protocol(method) + return (protocol, odtc) + return (Protocol(stages=[]), odtc) + + +def estimate_odtc_protocol_duration_seconds(odtc: ODTCProtocol) -> float: + """Estimate total run duration for an ODTCProtocol. + + Premethods use PREMETHOD_ESTIMATED_DURATION_SECONDS; methods use + step/loop-based estimation. + + Returns: + Estimated duration in seconds. + """ + if odtc.kind == "premethod": + return PREMETHOD_ESTIMATED_DURATION_SECONDS + return estimate_method_duration_seconds(_odtc_protocol_to_method(odtc)) + + @dataclass class StoredProtocol: """A protocol stored on the device, with instrument config for running it. @@ -771,17 +1013,27 @@ def _method_to_xml(method: ODTCMethod, parent: ET.Element) -> ET.Element: def parse_method_set_from_root(root: ET.Element) -> ODTCMethodSet: - """Parse a MethodSet from an XML root element.""" + """Parse a MethodSet from an XML root element. + + Parses Method/PreMethod into ODTCMethod/ODTCPreMethod, then converts + each to ODTCProtocol for the in-memory set. + """ 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 = [from_xml(pm, ODTCPreMethod) for pm in root.findall("PreMethod")] - methods = [_parse_method(m) for m in root.findall("Method")] + premethod_protos = [ + _odtc_premethod_to_odtc_protocol(from_xml(pm, ODTCPreMethod)) + for pm in root.findall("PreMethod") + ] + method_protos = [ + _odtc_method_to_odtc_protocol(_parse_method(m)) + for m in root.findall("Method") + ] return ODTCMethodSet( delete_all_methods=delete_all, - premethods=premethods, - methods=methods, + premethods=premethod_protos, + methods=method_protos, ) @@ -798,19 +1050,22 @@ def parse_method_set_file(filepath: str) -> ODTCMethodSet: def method_set_to_xml(method_set: ODTCMethodSet) -> str: - """Serialize a MethodSet to XML string.""" + """Serialize a MethodSet to XML string. + + Converts each ODTCProtocol to ODTCMethod/ODTCPreMethod for serialization. + """ root = ET.Element("MethodSet") # Add DeleteAllMethods ET.SubElement(root, "DeleteAllMethods").text = "true" if method_set.delete_all_methods else "false" # Add PreMethods - for pm in method_set.premethods: - to_xml(pm, "PreMethod", root) + for odtc in method_set.premethods: + to_xml(_odtc_protocol_to_premethod(odtc), "PreMethod", root) # Add Methods (with special PIDSet handling) - for m in method_set.methods: - _method_to_xml(m, root) + for odtc in method_set.methods: + _method_to_xml(_odtc_protocol_to_method(odtc), root) return ET.tostring(root, encoding="unicode", xml_declaration=True) @@ -828,34 +1083,30 @@ def parse_sensor_values(xml_str: str) -> ODTCSensorValues: -def get_premethod_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCPreMethod]: - """Find a premethod by name.""" +def get_premethod_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCProtocol]: + """Find a premethod by name (returns ODTCProtocol).""" return next((pm for pm in method_set.premethods if pm.name == name), None) -# Keep the method-only version as internal helper -def _get_method_only_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCMethod]: +def _get_method_only_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCProtocol]: """Find a method by name (methods only, not premethods).""" return next((m for m in method_set.methods if m.name == name), None) -def get_method_by_name(method_set: ODTCMethodSet, name: str) -> Optional[Union[ODTCMethod, ODTCPreMethod]]: - """Find a method by name, searching both methods and premethods. - +def get_method_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCProtocol]: + """Find a method or premethod by name. + Args: method_set: ODTCMethodSet to search. name: Method name to find. - + Returns: - ODTCMethod or ODTCPreMethod if found, None otherwise. + ODTCProtocol if found (method or premethod), None otherwise. """ - # Search methods first, then premethods - method = _get_method_only_by_name(method_set, name) - if method is not None: - return method - premethod = get_premethod_by_name(method_set, name) - if premethod is not None: - return premethod + m = _get_method_only_by_name(method_set, name) + if m is not None: + return m + return get_premethod_by_name(method_set, name) return None diff --git a/pylabrobot/thermocycling/inheco/odtc_tests.py b/pylabrobot/thermocycling/inheco/odtc_tests.py index 601a5866033..f17918afc65 100644 --- a/pylabrobot/thermocycling/inheco/odtc_tests.py +++ b/pylabrobot/thermocycling/inheco/odtc_tests.py @@ -11,12 +11,15 @@ ODTCMethod, ODTCMethodSet, ODTC_DIMENSIONS, + ODTCProtocol, ODTCPreMethod, ODTCStep, PREMETHOD_ESTIMATED_DURATION_SECONDS, - StoredProtocol, + _odtc_method_to_odtc_protocol, + _odtc_premethod_to_odtc_protocol, estimate_method_duration_seconds, normalize_variant, + odtc_protocol_to_protocol, ) from pylabrobot.thermocycling.inheco.odtc_thermocycler import ODTCThermocycler from pylabrobot.resources import Coordinate @@ -802,8 +805,8 @@ async def test_get_data_events(self): async def test_list_protocols(self): """Test list_protocols returns method and premethod names.""" method_set = ODTCMethodSet( - methods=[ODTCMethod(name="PCR_30")], - premethods=[ODTCPreMethod(name="Pre25")], + methods=[_odtc_method_to_odtc_protocol(ODTCMethod(name="PCR_30"))], + premethods=[_odtc_premethod_to_odtc_protocol(ODTCPreMethod(name="Pre25"))], ) self.backend.get_method_set = AsyncMock(return_value=method_set) # type: ignore[method-assign] names = await self.backend.list_protocols() @@ -812,8 +815,14 @@ async def test_list_protocols(self): async def test_list_methods(self): """Test list_methods returns (method_names, premethod_names) and matches list_protocols.""" method_set = ODTCMethodSet( - methods=[ODTCMethod(name="PCR_30"), ODTCMethod(name="PCR_35")], - premethods=[ODTCPreMethod(name="Pre25"), ODTCPreMethod(name="Pre37")], + methods=[ + _odtc_method_to_odtc_protocol(ODTCMethod(name="PCR_30")), + _odtc_method_to_odtc_protocol(ODTCMethod(name="PCR_35")), + ], + premethods=[ + _odtc_premethod_to_odtc_protocol(ODTCPreMethod(name="Pre25")), + _odtc_premethod_to_odtc_protocol(ODTCPreMethod(name="Pre37")), + ], ) self.backend.get_method_set = AsyncMock(return_value=method_set) # type: ignore[method-assign] methods, premethods = await self.backend.list_methods() @@ -832,30 +841,33 @@ 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=[ODTCPreMethod(name="Pre25")], + premethods=[_odtc_premethod_to_odtc_protocol(ODTCPreMethod(name="Pre25"))], ) 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 StoredProtocol for runnable method.""" + """Test get_protocol returns ODTCProtocol for runnable method.""" method_set = ODTCMethodSet( methods=[ - ODTCMethod( - name="PCR_30", - steps=[ODTCStep(number=1, plateau_temperature=95.0, plateau_time=30.0)], + _odtc_method_to_odtc_protocol( + ODTCMethod( + name="PCR_30", + steps=[ODTCStep(number=1, plateau_temperature=95.0, plateau_time=30.0)], + ) ) ], 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, StoredProtocol) + self.assertIsInstance(result, ODTCProtocol) assert result is not None # narrow for type checker self.assertEqual(result.name, "PCR_30") - self.assertEqual(len(result.protocol.stages), 1) - self.assertEqual(len(result.protocol.stages[0].steps), 1) + protocol, _ = odtc_protocol_to_protocol(result) + self.assertEqual(len(protocol.stages), 1) + self.assertEqual(len(protocol.stages[0].steps), 1) async def test_run_stored_protocol_calls_execute_method(self): """Test run_stored_protocol calls execute_method with name, wait, and estimated_duration_seconds.""" diff --git a/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb b/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb index dc8f4ce763e..a8c3104eaa3 100644 --- a/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb +++ b/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb @@ -38,7 +38,7 @@ "source": [ "## 2. Connect and list device methods\n", "\n", - "`setup()` resets and initializes the device. **Methods** = runnable protocols on the device; **premethods** = setup-only (e.g. set temperature). Use `list_protocols()` to get a `ProtocolList` (methods and premethods in clear sections); `get_protocol(name)` returns a `StoredProtocol` for methods (None for premethods)." + "`setup()` resets and initializes the device. **Methods** = runnable protocols on the device; **premethods** = setup-only (e.g. set temperature). Use `list_protocols()` to get a `ProtocolList` (methods and premethods in clear sections); `get_protocol(name)` returns an **ODTCProtocol** for methods (None for premethods)." ] }, { @@ -55,9 +55,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "**After `get_protocol(name)`** you get a `StoredProtocol` (`.protocol` + `.config` with overshoot, PID, etc.):\n", + "**After `get_protocol(name)`** you get an **ODTCProtocol** (subclasses Protocol; has `.stages`, `.name`, overshoot/PID fields):\n", "\n", - "- **Roundtrip:** `run_protocol(stored.protocol, block_max_volume, config=stored.config)` — same device-calculated config.\n", + "- **Roundtrip:** `run_protocol(odtc, block_max_volume)` — backend accepts ODTCProtocol; same device-calculated config.\n", "- **Run by name (recommended for PCR):** `run_stored_protocol(\"MethodName\")` — device runs its Script Editor method; optimal thermal (overshoots utilized, device-tuned ramps).\n", "- **Weaker option:** Uploading a custom protocol via `run_protocol(protocol, block_max_volume)` **without** a corresponding calculated config — no device overshoot/PID, so thermal performance is not optimized." ] @@ -74,14 +74,14 @@ "\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", - "# StoredProtocol __str__ includes the full step-by-step instruction set\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", - " stored = await tc.backend.get_protocol(first_name)\n", - " if stored:\n", + " odtc = await tc.backend.get_protocol(first_name)\n", + " if odtc:\n", " print(\"\\nExample stored protocol (full structure and steps):\")\n", - " print(stored)\n", - " # Roundtrip: run with same ODTC config via run_protocol(stored.protocol, block_max_volume, config=stored.config)\n" + " print(odtc)\n", + " # Roundtrip: run with same ODTC config via run_protocol(odtc, block_max_volume)\n" ] }, { @@ -183,7 +183,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "**Data events during protocol run** — The ODTC sends **DataEvent** messages while a method is running (e.g. progress or sensor data). The execution handle’s **`get_data_events()`** returns a list of DataEvent payloads (dicts) for this run. Run the cell below after starting the protocol to poll and inspect their structure." + "**Status and progress during a protocol run** — The ODTC sends **DataEvent** messages while a method is running; the backend parses them into progress (elapsed time, cycle/step indices, temperatures). Use these status APIs to monitor a run: **`is_profile_running()`**, **`get_hold_time()`**, **`get_current_cycle_index()`** / **`get_total_cycle_count()`**, **`get_current_step_index()`** / **`get_total_step_count()`**. Run the cell below after starting the protocol to poll and print status." ] }, { @@ -193,23 +193,24 @@ "outputs": [], "source": [ "import asyncio\n", - "import json\n", "\n", - "# Poll data events a few times while the protocol runs (run this cell after starting the protocol)\n", - "events_so_far = []\n", + "# Poll status a few times while the protocol runs (run this cell after starting the protocol)\n", "for poll in range(6):\n", - " evs = await execution.get_data_events()\n", - " events_so_far = evs\n", - " print(f\"Poll {poll + 1}: {len(evs)} DataEvent(s) so far\")\n", - " if evs:\n", - " sample = evs[-1]\n", - " print(f\" Sample event keys: {list(sample.keys())}\")\n", - " print(f\" Sample event (last): {json.dumps(sample, indent=2, default=str)}\")\n", + " running = await tc.is_profile_running()\n", + " if not running:\n", + " print(f\"Poll {poll + 1}: profile no longer running.\")\n", + " break\n", + " hold_s = await tc.get_hold_time()\n", + " cycle = await tc.get_current_cycle_index()\n", + " total_cycles = await tc.get_total_cycle_count()\n", + " step = await tc.get_current_step_index()\n", + " total_steps = await tc.get_total_step_count()\n", + " block = await tc.get_block_current_temperature()\n", + " lid = await tc.get_lid_current_temperature()\n", + " print(f\"Poll {poll + 1}: cycle {cycle + 1}/{total_cycles}, step {step + 1}/{total_steps}, hold_remaining={hold_s:.1f}s, block={block[0]:.1f}°C, lid={lid[0]:.1f}°C\")\n", " await asyncio.sleep(5)\n", - "\n", - "print(f\"\\nTotal DataEvents collected: {len(events_so_far)}\")\n", - "if events_so_far:\n", - " print(\"Structure of first event:\", list(events_so_far[0].keys()))" + "else:\n", + " print(\"Still running after 6 polls; use await execution.wait() or tc.wait_for_profile_completion() to block until done.\")" ] }, { diff --git a/pylabrobot/thermocycling/thermocycler.py b/pylabrobot/thermocycling/thermocycler.py index dd2bd46b59e..cb1dc1aa117 100644 --- a/pylabrobot/thermocycling/thermocycler.py +++ b/pylabrobot/thermocycling/thermocycler.py @@ -97,6 +97,7 @@ async def run_protocol( 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). @@ -106,14 +107,15 @@ async def run_protocol( return a handle. To block until done: await handle.wait() or wait_for_profile_completion(). """ - 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}." - ) + 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 From 939e436e15c524267a68160c2dfdf7e2d7ac4764 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:43:01 -0800 Subject: [PATCH 18/28] ODTCProtocol --- pylabrobot/thermocycling/inheco/README.md | 189 ++-- .../thermocycling/inheco/odtc_backend.py | 18 +- pylabrobot/thermocycling/inheco/odtc_model.py | 956 +++++++++--------- pylabrobot/thermocycling/inheco/odtc_tests.py | 175 +++- pylabrobot/thermocycling/standard.py | 2 +- 5 files changed, 761 insertions(+), 579 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/README.md b/pylabrobot/thermocycling/inheco/README.md index 22a79eaec95..454e92bd7f8 100644 --- a/pylabrobot/thermocycling/inheco/README.md +++ b/pylabrobot/thermocycling/inheco/README.md @@ -4,39 +4,53 @@ Interface for Inheco ODTC thermocyclers via SiLA (SOAP over HTTP). Supports asynchronous method execution (blocking and non-blocking), round-trip protocol conversion (ODTC XML ↔ PyLabRobot `Protocol` with lossless ODTC parameters), parallel commands (e.g. read temperatures during run), and DataEvent collection. -**New users:** Start with **Connection and Setup**, then **Recommended Workflows** (run by name, round-trip for thermal performance, set block/lid temp). A step-by-step tutorial notebook is in **`dev/odtc_tutorial.ipynb`**. Use **Running Commands** and **Getting Protocols** for async handles and device introspection; **XML to Protocol + Config** for conversion detail. +**New users:** Start with **Connection and Setup**, then **ODTC Model** (types and conversion), then **Recommended Workflows** (run by name, round-trip for thermal performance, set block/lid temp). A step-by-step tutorial notebook is in **`odtc_tutorial.ipynb`**. Use **Running Commands** and **Getting Protocols** for async handles; **ODTCProtocol and Protocol + ODTCConfig Conversion** for conversion detail. ## Architecture - **`ODTCSiLAInterface`** (`odtc_sila_interface.py`) — SiLA SOAP layer: `send_command` / `start_command`, parallelism rules, state machine (Startup → Standby → Idle → Busy), lockId, DataEvents. - **`ODTCBackend`** (`odtc_backend.py`) — Implements `ThermocyclerBackend`: method execution, protocol conversion, upload/download, status. - **`ODTCThermocycler`** (`odtc_thermocycler.py`) — Preferred resource: takes `odtc_ip`, `variant` (96/384 or 960000/384000), uses ODTC dimensions (147×298×130 mm). Alternative: generic `Thermocycler` with `ODTCBackend` for custom sizing. -- **`odtc_model.py`** — MethodSet XML (de)serialization, `ODTCMethod` ↔ `Protocol` conversion, `ODTCConfig` for ODTC-specific parameters. +- **`odtc_model.py`** — MethodSet XML (de)serialization, `ODTCProtocol` ↔ `Protocol` conversion, `ODTCConfig` for ODTC-specific parameters. -## Protocol vs Method: Naming Conventions +## ODTC Model: Types and Conversion -Understanding the distinction between **Protocol** and **Method** is crucial for using the ODTC API correctly: +The ODTC implementation is built around **ODTCProtocol**, **ODTCStage**, and **ODTCStep**, which extend PyLabRobot’s generic **Protocol**, **Stage**, and **Step**. The device stores protocols by a **method name** (string); conversion functions map between ODTC types and the generic types for editing and round-trip. -### Protocol (PyLabRobot) -- **`Protocol`**: PyLabRobot's generic protocol object (from `pylabrobot.thermocycling.standard`) - - Contains `Stage` objects with `Step` objects - - Defines temperatures, hold times, and cycle repeats - - Hardware-agnostic (works with any thermocycler) - - Example: `Protocol(stages=[Stage(steps=[Step(temperature=95.0, hold_seconds=30.0)])])` +### Core types -### Method (ODTC Device) -- **`ODTCMethod`** or **`ODTCPreMethod`**: ODTC-specific XML-defined method stored on the device. In ODTC/SiLA, a **method** is the device's runnable protocol (thermocycling program). - - Contains ODTC-specific parameters (overshoot, slopes, PID settings) - - Stored on the device with a unique **method name** (string identifier; SiLA: `methodName`) - - Example: `"PCR_30cycles"` is a method name stored on the device +| Type | Role | +|------|------| +| **`ODTCStep`** | Extends `Step`. Single temperature step with ODTC fields (slope, overshoot, plateau_time, goto_number, loop_number). | +| **`ODTCStage`** | Extends `Stage`. Holds `steps: List[ODTCStep]` and optional `inner_stages` for nested loops. | +| **`ODTCProtocol`** | Extends `Protocol`. One type for both **methods** (cycling) and **premethods** (hold block/lid temp), distinguished by `kind='method'` or `kind='premethod'`. | -### Method Name (String) -- **Method name**: A string identifier for a method stored on the device - - Examples: `"PCR_30cycles"`, `"my_pcr"`, `"plr_currentProtocol"` - - Used to reference methods when executing: `await tc.run_protocol(method_name="PCR_30cycles")` - - Can be a Method or PreMethod name (both are stored on the device) +For **methods** (kind='method'): **`.steps`** is the main representation—a flat list of `ODTCStep` with step numbers and goto/loop. When built from a generic `Protocol` (e.g. `protocol_to_odtc_protocol`), we set `stages=[]`; the stage view is derived when needed via `odtc_protocol_to_protocol(odtc)` (which builds a `Protocol` with stages from the step list). Parsed XML with nested loops can produce an ODTCProtocol whose stage tree is built from steps for display or serialization. -**API:** Resource: `tc.run_protocol(protocol, block_max_volume)` or `tc.run_stored_protocol(name)`. Backend: `tc.backend.list_protocols()`, `get_protocol(name)` (runnable only; premethods → `None`), `upload_protocol(...)`, `set_block_temperature(...)`, `get_default_config()`, `execute_method(method_name)`. +### Generic types (PyLabRobot) + +- **`Protocol`** — `stages: List[Stage]`; hardware-agnostic. +- **`Stage`** — `steps: Sequence[Step]`, `repeats: int`. +- **`Step`** — `temperature: List[float]`, `hold_seconds: float`, optional `rate`. + +Example: `Protocol(stages=[Stage(steps=[Step(temperature=[95.0], hold_seconds=30.0)], repeats=1)])`. + +### Conversion + +- **Device → editable (Protocol + ODTCConfig):** + `get_protocol(name)` returns `Optional[ODTCProtocol]`. Use **`odtc_method_to_protocol(odtc)`** to get `(Protocol, ODTCConfig)` for modifying then re-uploading with the same thermal tuning. + +- **Protocol + ODTCConfig → ODTC (upload/run):** + Use **`protocol_to_odtc_protocol(protocol, config=config)`** to get an `ODTCProtocol` for upload or for passing to `run_protocol(odtc, block_max_volume)`. + +- **ODTCProtocol → Protocol view only:** + Use **`odtc_protocol_to_protocol(odtc)`** to get `(Protocol, ODTCProtocol)` when you need a generic Protocol view (e.g. stage tree) without a separate ODTCConfig. + +### Method name (string) + +The device identifies stored protocols by a **method name** (SiLA: `methodName`), e.g. `"PCR_30cycles"`, `"plr_currentProtocol"`. Use it with `run_stored_protocol(name)`, `get_protocol(name)`, and `list_protocols()`. + +**API:** `tc.run_protocol(protocol, block_max_volume)` or `tc.run_stored_protocol(name)`. Backend: `list_protocols()`, `get_protocol(name)` → `Optional[ODTCProtocol]` (runnable methods only; premethods → `None`), `upload_protocol(protocol, name=..., config=...)`, `set_block_temperature(...)`, `get_default_config()`, `execute_method(method_name)`. ## Connection and Setup @@ -106,17 +120,18 @@ await tc.run_stored_protocol("PCR_30cycles") **Use when:** You want to change an existing device protocol (e.g. cycle count) while keeping equivalent thermal performance. Preserving `ODTCConfig` keeps overshoot and other ODTC parameters from the original. ```python +from pylabrobot.thermocycling.inheco.odtc_model import odtc_method_to_protocol, protocol_to_odtc_protocol + # Get runnable protocol from device (returns None for premethods) -stored = await tc.backend.get_protocol("PCR_30cycles") -if not stored: +odtc = await tc.backend.get_protocol("PCR_30cycles") +if odtc is None: raise ValueError("Protocol not found") -protocol, config = stored.protocol, stored.config +protocol, config = odtc_method_to_protocol(odtc) # Modify only durations or cycle counts; keep temperatures unchanged # ODTCConfig is tuned for the original temperature setpoints—change temps and tuning may be wrong protocol.stages[0].repeats = 35 # Safe: cycle count -# protocol.stages[0].steps[0].duration = 120 # Safe: hold duration -# Do NOT change plateau_temperature / setpoints when reusing config +# Do NOT change temperature setpoints when reusing config # Upload with same config so overshoot/ODTC params are preserved await tc.backend.upload_protocol(protocol, name="PCR_35cycles", config=config) @@ -272,7 +287,7 @@ door_opening = await tc.open_lid(wait=False) await door_opening # These will queue/wait: -method2 = await tc.run_protocol(method_name="PCR_40cycles", wait=False) # Waits for method1 +method2 = await tc.run_stored_protocol("PCR_40cycles", wait=False) # Waits for method1 ``` ### CommandExecution vs MethodExecution @@ -319,11 +334,12 @@ methods, premethods = await tc.backend.list_methods() ### Get Runnable Protocol by Name ```python -# Get a runnable protocol by name (returns ODTCProtocol or None for premethods) +# get_protocol(name) returns Optional[ODTCProtocol] (None for premethods or missing name) odtc = await tc.backend.get_protocol("PCR_30cycles") -if odtc: - print(f"Protocol: {odtc.name}") - # odtc is ODTCProtocol (subclasses Protocol); use odtc_protocol_to_protocol(odtc) for (Protocol, ODTCProtocol) +if odtc is not None: + print(f"Method: {odtc.name}, steps: {len(odtc.steps)}") + # To edit and re-upload: protocol, config = odtc_method_to_protocol(odtc) + # To get Protocol view only: protocol, _ = odtc_protocol_to_protocol(odtc) ``` ### Get Full MethodSet (Advanced) @@ -343,11 +359,12 @@ for premethod in method_set.premethods: ### Inspect Stored Protocol ```python -# Get runnable protocol from device (ODTCProtocol subclasses Protocol; has name, stages, steps, config fields) +from pylabrobot.thermocycling.inheco.odtc_model import odtc_protocol_to_protocol + odtc = await tc.backend.get_protocol("PCR_30cycles") -if odtc: - protocol, _ = odtc_protocol_to_protocol(odtc) # from odtc_model - print(odtc) # Human-readable summary (name, stages, steps, config fields) +if odtc is not None: + protocol, _ = odtc_protocol_to_protocol(odtc) # Protocol view (stages derived from steps) + print(odtc) # Human-readable summary (name, steps, method-level fields) await tc.run_protocol(odtc, block_max_volume=50.0) # backend accepts ODTCProtocol ``` @@ -364,21 +381,24 @@ if odtc: - **In-memory (new protocol):** `await tc.run_protocol(protocol, block_max_volume=50.0)` (upload + execute). New protocols use default overshoot; for best thermal performance, prefer round-trip from an existing device protocol. - **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")`. -## XML to Protocol + Config Conversion +## ODTCProtocol and Protocol + ODTCConfig Conversion -### Lossless Round-Trip Conversion +### Lossless Round-Trip -The conversion system ensures **lossless round-trip** conversion between ODTC XML format and PyLabRobot's generic `Protocol` format. This is achieved through the `ODTCConfig` companion object that preserves all ODTC-specific parameters. +Conversion between ODTC (device/XML) and PyLabRobot's generic `Protocol` is **lossless** when you keep the `ODTCConfig` returned by `odtc_method_to_protocol(odtc)`. The config preserves method-level and per-step ODTC parameters (overshoot, slopes, PID, etc.). ### How It Works -#### 1. ODTC → Protocol + Config +#### 1. ODTCProtocol → Protocol + ODTCConfig ```python from pylabrobot.thermocycling.inheco.odtc_model import odtc_method_to_protocol -# Convert ODTC method to Protocol + Config -protocol, config = odtc_method_to_protocol(odtc_method) +# get_protocol(name) returns Optional[ODTCProtocol]; then convert for editing +odtc = await tc.backend.get_protocol("PCR_30cycles") +if odtc is None: + raise ValueError("Protocol not found") +protocol, config = odtc_method_to_protocol(odtc) ``` **What gets preserved in `ODTCConfig`:** @@ -406,19 +426,20 @@ protocol, config = odtc_method_to_protocol(odtc_method) - Stage structure (from loop analysis) - Repeat counts (from `loop_number`) -#### 2. Protocol + Config → ODTC +#### 2. Protocol + ODTCConfig → ODTCProtocol ```python -from pylabrobot.thermocycling.inheco.odtc_model import protocol_to_odtc_method +from pylabrobot.thermocycling.inheco.odtc_model import protocol_to_odtc_protocol -# Convert back to ODTC method (lossless if config preserved) -odtc_method = protocol_to_odtc_method(protocol, config=config) +# Convert back to ODTC (lossless if config preserved) +odtc = protocol_to_odtc_protocol(protocol, config=config) +# Then: await tc.backend.upload_protocol(protocol, name="...", config=config) ``` The conversion uses: -- `Protocol` for temperature/time structure +- `Protocol` for temperature/time and stage structure - `ODTCConfig.step_settings` for per-step overtemp parameters -- `ODTCConfig` defaults for method-level parameters +- `ODTCConfig` for method-level parameters ### Overtemp/Overshoot Parameter Preservation @@ -450,20 +471,20 @@ When converting existing ODTC XML protocols to PyLabRobot `Protocol` format, **p **Example of preservation:** ```python -# When converting ODTC → Protocol + Config -protocol, config = odtc_method_to_protocol(odtc_method) +# When converting ODTCProtocol → Protocol + ODTCConfig +odtc = await tc.backend.get_protocol("PCR_30cycles") +if odtc is None: + raise ValueError("Protocol not found") +protocol, config = odtc_method_to_protocol(odtc) # Overtemp params stored per step (preserved from original XML) step_0_overtemp = config.step_settings[0] print(step_0_overtemp.overshoot_temperature) # e.g., 100.0 (from original XML) print(step_0_overtemp.overshoot_time) # e.g., 5.0 (from original XML) -# When converting back Protocol + Config → ODTC -odtc_method_restored = protocol_to_odtc_method(protocol, config=config) - -# Overtemp params restored from config.step_settings -# This ensures equivalent thermal performance to original -assert odtc_method_restored.steps[0].overshoot_temperature == 100.0 +# When converting back Protocol + ODTCConfig → ODTCProtocol +odtc_restored = protocol_to_odtc_protocol(protocol, config=config) +assert odtc_restored.steps[0].overshoot_temperature == 100.0 ``` **Important:** Always preserve the `ODTCConfig` when modifying protocols converted from ODTC XML to maintain equivalent thermal performance. If you create a new protocol without a config, overshoot parameters will use defaults which may result in slower heating. @@ -471,23 +492,18 @@ assert odtc_method_restored.steps[0].overshoot_temperature == 100.0 ### Example: Round-Trip Conversion ```python -from pylabrobot.thermocycling.inheco.odtc_model import ( - odtc_method_to_protocol, - protocol_to_odtc_method, - method_set_to_xml, - parse_method_set -) +from pylabrobot.thermocycling.inheco.odtc_model import odtc_method_to_protocol, protocol_to_odtc_protocol -# 1. Get runnable protocol from device -stored = await tc.backend.get_protocol("PCR_30cycles") -if not stored: +# 1. Get ODTCProtocol from device; convert to Protocol + ODTCConfig for editing +odtc = await tc.backend.get_protocol("PCR_30cycles") +if odtc is None: raise ValueError("Protocol not found") -protocol, config = stored.protocol, stored.config +protocol, config = odtc_method_to_protocol(odtc) -# 2. Modify protocol (generic changes) -protocol.stages[0].repeats = 35 # Change cycle count +# 2. Modify protocol (durations, repeats; keep temperatures when reusing config) +protocol.stages[0].repeats = 35 -# 3. Upload modified protocol (preserves all ODTC-specific params via config) +# 3. Upload (backend calls protocol_to_odtc_protocol internally; config preserves ODTC params) await tc.backend.upload_protocol(protocol, name="PCR_35cycles", config=config) # 4. Execute @@ -497,20 +513,22 @@ await tc.run_stored_protocol("PCR_35cycles") ### Round-Trip from Device XML ```python -# Full round-trip: Device Method → Protocol+Config → Device Method +from pylabrobot.thermocycling.inheco.odtc_model import odtc_method_to_protocol + +# Full round-trip: Device → ODTCProtocol → Protocol+ODTCConfig → upload → Device # 1. Get from device -stored = await tc.backend.get_protocol("PCR_30cycles") -if not stored: +odtc = await tc.backend.get_protocol("PCR_30cycles") +if odtc is None: raise ValueError("Protocol not found") -protocol, config = stored.protocol, stored.config +protocol, config = odtc_method_to_protocol(odtc) -# 2. Upload back to device (preserves all ODTC-specific params via config) +# 2. Upload back (preserves all ODTC-specific params via config) await tc.backend.upload_protocol(protocol, name="PCR_30cycles_restored", config=config) -# 3. Verify round-trip by comparing protocols -stored_restored = await tc.backend.get_protocol("PCR_30cycles_restored") -# Protocols should be equivalent (XML formatting may differ, but content should match) +# 3. Verify round-trip +odtc_restored = await tc.backend.get_protocol("PCR_30cycles_restored") +# Content should match (XML formatting may differ) ``` ## DataEvent Collection @@ -570,22 +588,29 @@ State transitions are tracked automatically: ```python from pylabrobot.resources import Coordinate from pylabrobot.thermocycling.inheco import ODTCThermocycler +from pylabrobot.thermocycling.inheco.odtc_model import odtc_method_to_protocol from pylabrobot.thermocycling.standard import Protocol, Stage, Step tc = ODTCThermocycler(name="odtc", odtc_ip="192.168.1.100", variant=96, child_location=Coordinate(0, 0, 0)) await tc.setup() -# Get protocol from device, modify, run -stored = await tc.backend.get_protocol("PCR_30cycles") -if stored: - protocol, config = stored.protocol, stored.config +# Get ODTCProtocol from device; convert to Protocol + ODTCConfig; modify; upload; run +odtc = await tc.backend.get_protocol("PCR_30cycles") +if odtc is not None: + protocol, config = odtc_method_to_protocol(odtc) protocol.stages[0].repeats = 35 await tc.backend.upload_protocol(protocol, name="PCR_35cycles", config=config) execution = await tc.run_stored_protocol("PCR_35cycles", wait=False) await execution -# New protocol and run -protocol = Protocol(stages=[Stage(steps=[Step(95.0, 30.0), Step(60.0, 30.0), Step(72.0, 60.0)], repeats=30)]) +# New protocol (generic Protocol) and run (backend converts via protocol_to_odtc_protocol) +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) +]) await tc.run_protocol(protocol, block_max_volume=50.0) await tc.set_block_temperature([37.0]) diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index 6ce17f62ff3..dda1643f8f2 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -1138,9 +1138,8 @@ async def get_method_set(self) -> ODTCMethodSet: async def get_protocol(self, name: str) -> Optional[ODTCProtocol]: """Get a stored protocol by name (runnable methods only; premethods return None). - Resolves the stored method by name. If it is a runnable method (ODTCProtocol - with kind='method'), returns that ODTCProtocol. If it is a premethod or not - found, returns 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. @@ -1150,9 +1149,7 @@ async def get_protocol(self, name: str) -> Optional[ODTCProtocol]: """ method_set = await self.get_method_set() resolved = get_method_by_name(method_set, name) - if resolved is None: - return None - if resolved.kind == "premethod": + if resolved is None or resolved.kind == "premethod": return None return resolved @@ -1358,19 +1355,20 @@ async def upload_method_set( existing_method_set = await self.get_method_set() conflicts = [] + def _existing_item_type(existing: ODTCProtocol) -> str: + return "PreMethod" if existing.kind == "premethod" else "Method" + # Check all method names (unified search) for method in method_set.methods: existing_method = get_method_by_name(existing_method_set, method.name) if existing_method is not None: - method_type = "PreMethod" if existing_method.kind == "premethod" else "Method" - conflicts.append(f"Method '{method.name}' already exists as {method_type}") + conflicts.append(f"Method '{method.name}' already exists as {_existing_item_type(existing_method)}") # Check all premethod names (unified search) for premethod in method_set.premethods: existing_method = get_method_by_name(existing_method_set, premethod.name) if existing_method is not None: - method_type = "PreMethod" if existing_method.kind == "premethod" else "Method" - conflicts.append(f"Method '{premethod.name}' already exists as {method_type}") + 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) diff --git a/pylabrobot/thermocycling/inheco/odtc_model.py b/pylabrobot/thermocycling/inheco/odtc_model.py index 1f042a560f5..2bf363c5ad7 100644 --- a/pylabrobot/thermocycling/inheco/odtc_model.py +++ b/pylabrobot/thermocycling/inheco/odtc_model.py @@ -1,9 +1,10 @@ """ ODTC model: domain types, XML serialization, and Protocol conversion. -Defines ODTC dataclasses (ODTCMethod, ODTCPreMethod, ODTCConfig, etc.), -schema-driven XML serialization for MethodSet, and conversion between -PyLabRobot Protocol and ODTC method representation. +Defines ODTC dataclasses (ODTCProtocol, ODTCConfig, etc.), schema-driven +XML serialization for MethodSet, and conversion between PyLabRobot Protocol +and ODTC representation. Methods and premethods are consolidated as ODTCProtocol +(kind='method' | 'premethod'). """ from __future__ import annotations @@ -12,7 +13,7 @@ import logging from datetime import datetime import xml.etree.ElementTree as ET -from dataclasses import dataclass, field, fields +from dataclasses import dataclass, field, fields, replace from enum import Enum from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Literal, Optional, Tuple, Type, TypeVar, Union, cast, get_args, get_origin, get_type_hints @@ -231,9 +232,13 @@ def xml_child_list(tag: Optional[str] = None) -> Any: @dataclass -class ODTCStep: - """A single step in an ODTC method.""" +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) @@ -247,6 +252,37 @@ class ODTCStep: 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: @@ -263,50 +299,9 @@ class ODTCPID: i_lid: float = xml_field(tag="ILid", default=70.0) -@dataclass -class ODTCPreMethod: - """ODTC PreMethod for initial temperature conditioning.""" - - name: str = xml_attr(tag="methodName", default="") - target_block_temperature: float = xml_field(tag="TargetBlockTemperature", default=0.0) - target_lid_temperature: float = xml_field(tag="TargetLidTemp", default=0.0) - creator: Optional[str] = xml_attr(tag="creator", default=None) - description: Optional[str] = xml_attr(tag="description", default=None) - datetime: Optional[str] = xml_attr(tag="dateTime", default=None) - - -@dataclass -class ODTCMethod: - """Full ODTC Method with thermal cycling parameters.""" - - name: str = xml_attr(tag="methodName", default="") - variant: int = xml_field(tag="Variant", default=960000) - plate_type: int = xml_field(tag="PlateType", default=0) - fluid_quantity: int = xml_field(tag="FluidQuantity", default=0) - post_heating: bool = xml_field(tag="PostHeating", default=False) - start_block_temperature: float = xml_field(tag="StartBlockTemperature", default=0.0) - start_lid_temperature: float = xml_field(tag="StartLidTemperature", default=0.0) - steps: List[ODTCStep] = xml_child_list(tag="Step") - pid_set: List[ODTCPID] = xml_child_list(tag="PID") - creator: Optional[str] = xml_attr(tag="creator", default=None) - description: Optional[str] = xml_attr(tag="description", default=None) - datetime: Optional[str] = xml_attr(tag="dateTime", default=None) - - def get_loop_structure(self) -> List[tuple]: - """ - Analyze loop structure and return list of (loop_start_step, loop_end_step, repeat_count). - Step numbers are 1-indexed as in the XML. - """ - loops = [] - for step in self.steps: - if step.goto_number > 0 and step.loop_number > 0: - loops.append((step.goto_number, step.number, step.loop_number + 1)) - return loops - - @dataclass class ODTCMethodSet: - """Container for all methods and premethods (in-memory as ODTCProtocol).""" + """Container for all methods and premethods as ODTCProtocol (kind='method' | 'premethod').""" delete_all_methods: bool = False premethods: List[ODTCProtocol] = field(default_factory=list) @@ -454,10 +449,10 @@ def parse_data_event_payload(payload: Dict[str, Any]) -> Optional[ODTCDataEventS @dataclass class ODTCStepSettings: - """Per-step ODTC parameters for Protocol to ODTCMethod conversion. + """Per-step ODTC parameters for Protocol to ODTCProtocol conversion. - When converting ODTCMethod to Protocol, these capture the original values. - When converting Protocol to ODTCMethod, these override defaults. + When converting ODTCProtocol to Protocol, these capture the original values. + When converting Protocol to ODTCProtocol, these override defaults. """ slope: Optional[float] = None @@ -475,7 +470,7 @@ class ODTCConfig: This class serves two purposes: 1. When creating new protocols: Specify ODTC-specific parameters - 2. When extracting from ODTCMethod: Captures all params for lossless round-trip + 2. When extracting from ODTCProtocol: Captures all params for lossless round-trip Validation is performed on construction by default. Set _validate=False to skip validation (useful when reading data from a trusted source like the device). @@ -588,6 +583,24 @@ def validate(self) -> List[str]: return errors +# ============================================================================= +# ODTCStage (Stage with optional nested inner_stages for loop tree) +# ============================================================================= + + +@dataclass +class ODTCStage(Stage): + """Stage with optional inner_stages for nested loops. + + Execution: steps and inner_stages are interleaved (steps[0], inner_stages[0], + steps[1], inner_stages[1], ...); then the whole block repeats `repeats` times. + So for outer 1-5 with inner 2-4: steps=[step1, step5], inner_stages=[ODTCStage(2-4, 5)]. + At runtime steps are ODTCStep; we cast to List[Step] at construction so Stage.steps stays List[Step]. + """ + + inner_stages: Optional[List["ODTCStage"]] = None + + # ============================================================================= # ODTCProtocol (protocol + config; subclasses Protocol for resource API) # ============================================================================= @@ -622,78 +635,6 @@ class ODTCProtocol(Protocol): default_cooling_slope: float = 2.2 -def _odtc_method_to_odtc_protocol(method: ODTCMethod) -> ODTCProtocol: - """Build ODTCProtocol from ODTCMethod (for methods loaded from device).""" - protocol, config = odtc_method_to_protocol(method) - return ODTCProtocol( - stages=protocol.stages, - kind="method", - name=method.name, - creator=method.creator, - description=method.description, - datetime=method.datetime, - variant=method.variant, - plate_type=method.plate_type, - fluid_quantity=method.fluid_quantity, - post_heating=method.post_heating, - start_block_temperature=method.start_block_temperature, - start_lid_temperature=method.start_lid_temperature, - steps=list(method.steps), - pid_set=list(method.pid_set) if method.pid_set else [ODTCPID(number=1)], - step_settings=dict(config.step_settings), - default_heating_slope=config.default_heating_slope, - default_cooling_slope=config.default_cooling_slope, - ) - - -def _odtc_premethod_to_odtc_protocol(premethod: ODTCPreMethod) -> ODTCProtocol: - """Build ODTCProtocol from ODTCPreMethod (for premethods loaded from device).""" - return ODTCProtocol( - stages=[], - kind="premethod", - name=premethod.name, - creator=premethod.creator, - description=premethod.description, - datetime=premethod.datetime, - target_block_temperature=premethod.target_block_temperature, - target_lid_temperature=premethod.target_lid_temperature, - ) - - -def _odtc_protocol_to_method(odtc: ODTCProtocol) -> ODTCMethod: - """Build ODTCMethod from ODTCProtocol (kind must be 'method').""" - if odtc.kind != "method": - raise ValueError("ODTCProtocol must have kind='method' to convert to ODTCMethod") - return ODTCMethod( - name=odtc.name, - variant=odtc.variant, - plate_type=odtc.plate_type, - fluid_quantity=odtc.fluid_quantity, - post_heating=odtc.post_heating, - start_block_temperature=odtc.start_block_temperature, - start_lid_temperature=odtc.start_lid_temperature, - steps=list(odtc.steps), - pid_set=list(odtc.pid_set) if odtc.pid_set else [ODTCPID(number=1)], - creator=odtc.creator, - description=odtc.description, - datetime=odtc.datetime, - ) - - -def _odtc_protocol_to_premethod(odtc: ODTCProtocol) -> ODTCPreMethod: - """Build ODTCPreMethod from ODTCProtocol (kind must be 'premethod').""" - if odtc.kind != "premethod": - raise ValueError("ODTCProtocol must have kind='premethod' to convert to ODTCPreMethod") - return ODTCPreMethod( - name=odtc.name, - target_block_temperature=odtc.target_block_temperature, - target_lid_temperature=odtc.target_lid_temperature, - creator=odtc.creator, - description=odtc.description, - datetime=odtc.datetime, - ) - - def protocol_to_odtc_protocol( protocol: "Protocol", config: Optional[ODTCConfig] = None, @@ -705,10 +646,100 @@ def protocol_to_odtc_protocol( config: Optional ODTC config; if None, defaults are used. Returns: - ODTCProtocol ready for upload or run. + ODTCProtocol (kind='method') ready for upload or run. Steps are authoritative; + stages=[] so the stage view is derived via odtc_method_to_protocol(odtc) when needed. """ - method = protocol_to_odtc_method(protocol, config) - return _odtc_method_to_odtc_protocol(method) + if config is None: + config = ODTCConfig() + + odtc_steps: List[ODTCStep] = [] + step_number = 1 + + # Track previous temperature for slope calculation + # Start from room temperature - first step needs to ramp from ambient + prev_temp = 25.0 + + for stage_idx, stage in enumerate(protocol.stages): + stage_start_step = step_number + + for step_idx, step in enumerate(stage.steps): + # Get the target temperature (use first zone for ODTC single-zone) + target_temp = step.temperature[0] if step.temperature else 25.0 + + # Calculate slope + slope = _calculate_slope(prev_temp, target_temp, step.rate, config) + + # Get step settings overrides if any + # Use global step index (across all stages) + global_step_idx = step_number - 1 + step_setting = config.step_settings.get(global_step_idx, ODTCStepSettings()) + + # Create ODTC step with defaults or overrides + odtc_step = ODTCStep( + number=step_number, + slope=step_setting.slope if step_setting.slope is not None else slope, + plateau_temperature=target_temp, + plateau_time=step.hold_seconds, + overshoot_slope1=( + step_setting.overshoot_slope1 if step_setting.overshoot_slope1 is not None else 0.1 + ), + overshoot_temperature=( + step_setting.overshoot_temperature if step_setting.overshoot_temperature is not None else 0.0 + ), + overshoot_time=( + step_setting.overshoot_time if step_setting.overshoot_time is not None else 0.0 + ), + overshoot_slope2=( + step_setting.overshoot_slope2 if step_setting.overshoot_slope2 is not None else 0.1 + ), + goto_number=0, # Will be set below for loops + loop_number=0, # Will be set below for loops + pid_number=step_setting.pid_number if step_setting.pid_number is not None else 1, + lid_temp=( + step_setting.lid_temp if step_setting.lid_temp is not None else config.lid_temperature + ), + ) + + odtc_steps.append(odtc_step) + prev_temp = target_temp + step_number += 1 + + # If stage has repeats > 1, add loop on the last step of the stage + if stage.repeats > 1 and odtc_steps: + last_step = odtc_steps[-1] + last_step.goto_number = stage_start_step + last_step.loop_number = stage.repeats # LoopNumber = actual repeat count (per loaded_set.xml) + + # Determine start temperatures + start_block_temp = protocol.stages[0].steps[0].temperature[0] if protocol.stages else 25.0 + start_lid_temp = ( + config.start_lid_temperature + if config.start_lid_temperature is not None + else config.lid_temperature + ) + + # Resolve method name (use scratch name if not provided) + resolved_name = resolve_protocol_name(config.name) + + # Generate timestamp if not already set + resolved_datetime = config.datetime if config.datetime else generate_odtc_timestamp() + + return ODTCProtocol( + kind="method", + name=resolved_name, + variant=config.variant, + plate_type=config.plate_type, + fluid_quantity=config.fluid_quantity, + post_heating=config.post_heating, + start_block_temperature=start_block_temp, + start_lid_temperature=start_lid_temp, + steps=odtc_steps, + pid_set=list(config.pid_set), + creator=config.creator, + description=config.description, + datetime=resolved_datetime, + stages=[], # Steps are authoritative; stage view via odtc_method_to_protocol(odtc) + ) def odtc_protocol_to_protocol(odtc: ODTCProtocol) -> Tuple["Protocol", ODTCProtocol]: @@ -722,8 +753,7 @@ def odtc_protocol_to_protocol(odtc: ODTCProtocol) -> Tuple["Protocol", ODTCProto for convenience (e.g. config fields). """ if odtc.kind == "method": - method = _odtc_protocol_to_method(odtc) - protocol, _ = odtc_method_to_protocol(method) + protocol, _ = odtc_method_to_protocol(odtc) return (protocol, odtc) return (Protocol(stages=[]), odtc) @@ -739,7 +769,7 @@ def estimate_odtc_protocol_duration_seconds(odtc: ODTCProtocol) -> float: """ if odtc.kind == "premethod": return PREMETHOD_ESTIMATED_DURATION_SECONDS - return estimate_method_duration_seconds(_odtc_protocol_to_method(odtc)) + return estimate_method_duration_seconds(odtc) @dataclass @@ -870,8 +900,13 @@ def from_xml(elem: ET.Element, cls: Type[T]) -> T: # 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) @@ -905,6 +940,11 @@ def from_xml(elem: ET.Element, cls: Type[T]) -> T: 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) @@ -927,7 +967,12 @@ def to_xml(obj: Any, tag_name: Optional[str] = None, parent: Optional[ET.Element 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) @@ -952,58 +997,151 @@ def to_xml(obj: Any, tag_name: Optional[str] = None, parent: Optional[ET.Element # ============================================================================= -# MethodSet-specific parsing (handles PIDSet wrapper) +# MethodSet-specific parsing: XML <-> ODTCProtocol (no ODTCMethod/ODTCPreMethod) # ============================================================================= -def _parse_method(elem: ET.Element) -> ODTCMethod: - """Parse a Method element, handling the PIDSet wrapper for PID elements.""" - # First parse the basic fields - method = from_xml(elem, ODTCMethod) +def _read_opt_attr(elem: ET.Element, key: str, default: Optional[str] = None) -> Optional[str]: + """Read optional attribute from element.""" + return elem.attrib.get(key, default) + + +def _read_opt_elem( + elem: ET.Element, tag: str, default: Any = None, parse_float: bool = False +) -> Any: + """Read optional child element text. If parse_float, return float; else str or default.""" + child = elem.find(tag) + if child is None or child.text is None: + return default + text = child.text.strip() + if not text: + return default + if parse_float: + return float(text) + return text - # Handle PIDSet wrapper specially + +def _parse_method_element_to_odtc_protocol(elem: ET.Element) -> ODTCProtocol: + """Parse a element into ODTCProtocol (kind='method', stages=[]). No nested-loop validation.""" + name = _read_opt_attr(elem, "methodName") or "" + creator = _read_opt_attr(elem, "creator") + description = _read_opt_attr(elem, "description") + datetime_ = _read_opt_attr(elem, "dateTime") + variant = int(float(_read_opt_elem(elem, "Variant") or 960000)) + plate_type = int(float(_read_opt_elem(elem, "PlateType") or 0)) + fluid_quantity = int(float(_read_opt_elem(elem, "FluidQuantity") or 0)) + post_heating = (_read_opt_elem(elem, "PostHeating") or "false").lower() == "true" + start_block_temperature = float(_read_opt_elem(elem, "StartBlockTemperature") or 0.0) + start_lid_temperature = float(_read_opt_elem(elem, "StartLidTemperature") or 0.0) + steps = [from_xml(step_elem, ODTCStep) for step_elem in elem.findall("Step")] + pid_set: List[ODTCPID] = [] pid_set_elem = elem.find("PIDSet") if pid_set_elem is not None: - pids = [] - for pid_elem in pid_set_elem.findall("PID"): - pids.append(from_xml(pid_elem, ODTCPID)) - method.pid_set = pids + pid_set = [from_xml(pid_elem, ODTCPID) for pid_elem in pid_set_elem.findall("PID")] + if not pid_set: + pid_set = [ODTCPID(number=1)] + return ODTCProtocol( + kind="method", + name=name, + creator=creator, + description=description, + datetime=datetime_, + variant=variant, + plate_type=plate_type, + fluid_quantity=fluid_quantity, + post_heating=post_heating, + start_block_temperature=start_block_temperature, + start_lid_temperature=start_lid_temperature, + steps=steps, + pid_set=pid_set, + stages=[], # Not built on parse; built on demand in odtc_protocol_to_protocol + ) - return method +def _parse_premethod_element_to_odtc_protocol(elem: ET.Element) -> ODTCProtocol: + """Parse a element into ODTCProtocol (kind='premethod').""" + name = _read_opt_attr(elem, "methodName") or "" + creator = _read_opt_attr(elem, "creator") + description = _read_opt_attr(elem, "description") + datetime_ = _read_opt_attr(elem, "dateTime") + target_block_temperature = float(_read_opt_elem(elem, "TargetBlockTemperature") or 0.0) + target_lid_temperature = float(_read_opt_elem(elem, "TargetLidTemp") or 0.0) + return ODTCProtocol( + kind="premethod", + name=name, + creator=creator, + description=description, + datetime=datetime_, + target_block_temperature=target_block_temperature, + target_lid_temperature=target_lid_temperature, + stages=[], + ) -def _method_to_xml(method: ODTCMethod, parent: ET.Element) -> ET.Element: - """Serialize a Method to XML, handling the PIDSet wrapper.""" - elem = ET.SubElement(parent, "Method") - # Set attributes - elem.set("methodName", method.name) - if method.creator: - elem.set("creator", method.creator) - if method.description: - elem.set("description", method.description) - if method.datetime: - elem.set("dateTime", method.datetime) - - # Add child elements - ET.SubElement(elem, "Variant").text = str(method.variant) - ET.SubElement(elem, "PlateType").text = str(method.plate_type) - ET.SubElement(elem, "FluidQuantity").text = str(method.fluid_quantity) - ET.SubElement(elem, "PostHeating").text = "true" if method.post_heating else "false" - # Use _format_value to ensure integers are formatted without .0 - ET.SubElement(elem, "StartBlockTemperature").text = _format_value(method.start_block_temperature) - ET.SubElement(elem, "StartLidTemperature").text = _format_value(method.start_lid_temperature) - - # Add steps - for step in method.steps: - to_xml(step, "Step", elem) +def _get_steps_for_serialization(odtc: ODTCProtocol) -> List[ODTCStep]: + """Return canonical ODTCStep list for serializing an ODTCProtocol (kind='method'). + + Uses odtc.steps when present; otherwise builds from odtc.stages via _odtc_stages_to_steps. + """ + if odtc.steps: + return odtc.steps + if odtc.stages: + stages_as_odtc = [] + for s in odtc.stages: + if isinstance(s, ODTCStage): + stages_as_odtc.append(s) + else: + steps_odtc = [ + st if isinstance(st, ODTCStep) else ODTCStep.from_step(st) + for st in s.steps + ] + stages_as_odtc.append(ODTCStage(steps=cast(List[Step], steps_odtc), repeats=s.repeats, inner_stages=None)) + return _odtc_stages_to_steps(stages_as_odtc) + return [] - # Add PIDSet wrapper if there are PIDs - if method.pid_set: + +def _odtc_protocol_to_method_xml(odtc: ODTCProtocol, parent: ET.Element) -> ET.Element: + """Serialize ODTCProtocol (kind='method') to XML.""" + if odtc.kind != "method": + raise ValueError("ODTCProtocol must have kind='method' to serialize as Method") + steps_to_serialize = _get_steps_for_serialization(odtc) + elem = ET.SubElement(parent, "Method") + elem.set("methodName", odtc.name) + if odtc.creator: + elem.set("creator", odtc.creator) + if odtc.description: + elem.set("description", odtc.description) + if odtc.datetime: + elem.set("dateTime", odtc.datetime) + ET.SubElement(elem, "Variant").text = str(odtc.variant) + ET.SubElement(elem, "PlateType").text = str(odtc.plate_type) + ET.SubElement(elem, "FluidQuantity").text = str(odtc.fluid_quantity) + ET.SubElement(elem, "PostHeating").text = "true" if odtc.post_heating else "false" + ET.SubElement(elem, "StartBlockTemperature").text = _format_value(odtc.start_block_temperature) + ET.SubElement(elem, "StartLidTemperature").text = _format_value(odtc.start_lid_temperature) + for step in steps_to_serialize: + to_xml(step, "Step", elem) + if odtc.pid_set: pid_set_elem = ET.SubElement(elem, "PIDSet") - for pid in method.pid_set: + for pid in odtc.pid_set: to_xml(pid, "PID", pid_set_elem) + return elem + +def _odtc_protocol_to_premethod_xml(odtc: ODTCProtocol, parent: ET.Element) -> ET.Element: + """Serialize ODTCProtocol (kind='premethod') to XML.""" + if odtc.kind != "premethod": + raise ValueError("ODTCProtocol must have kind='premethod' to serialize as PreMethod") + elem = ET.SubElement(parent, "PreMethod") + elem.set("methodName", odtc.name) + if odtc.creator: + elem.set("creator", odtc.creator) + if odtc.description: + elem.set("description", odtc.description) + if odtc.datetime: + elem.set("dateTime", odtc.datetime) + ET.SubElement(elem, "TargetBlockTemperature").text = _format_value(odtc.target_block_temperature) + ET.SubElement(elem, "TargetLidTemp").text = _format_value(odtc.target_lid_temperature) return elem @@ -1013,27 +1151,21 @@ def _method_to_xml(method: ODTCMethod, parent: ET.Element) -> ET.Element: def parse_method_set_from_root(root: ET.Element) -> ODTCMethodSet: - """Parse a MethodSet from an XML root element. + """Parse a MethodSet from an XML root element into ODTCProtocol only. - Parses Method/PreMethod into ODTCMethod/ODTCPreMethod, then converts - each to ODTCProtocol for the in-memory set. + 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" - premethod_protos = [ - _odtc_premethod_to_odtc_protocol(from_xml(pm, ODTCPreMethod)) - for pm in root.findall("PreMethod") - ] - method_protos = [ - _odtc_method_to_odtc_protocol(_parse_method(m)) - for m in root.findall("Method") - ] + 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=premethod_protos, - methods=method_protos, + premethods=premethods, + methods=methods, ) @@ -1050,23 +1182,13 @@ def parse_method_set_file(filepath: str) -> ODTCMethodSet: def method_set_to_xml(method_set: ODTCMethodSet) -> str: - """Serialize a MethodSet to XML string. - - Converts each ODTCProtocol to ODTCMethod/ODTCPreMethod for serialization. - """ + """Serialize a MethodSet to XML string (ODTCProtocol -> Method/PreMethod elements).""" root = ET.Element("MethodSet") - - # Add DeleteAllMethods ET.SubElement(root, "DeleteAllMethods").text = "true" if method_set.delete_all_methods else "false" - - # Add PreMethods - for odtc in method_set.premethods: - to_xml(_odtc_protocol_to_premethod(odtc), "PreMethod", root) - - # Add Methods (with special PIDSet handling) - for odtc in method_set.methods: - _method_to_xml(_odtc_protocol_to_method(odtc), root) - + 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) @@ -1084,7 +1206,7 @@ def parse_sensor_values(xml_str: str) -> ODTCSensorValues: def get_premethod_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCProtocol]: - """Find a premethod by name (returns ODTCProtocol).""" + """Find a premethod by name.""" return next((pm for pm in method_set.premethods if pm.name == name), None) @@ -1094,20 +1216,11 @@ def _get_method_only_by_name(method_set: ODTCMethodSet, name: str) -> Optional[O def get_method_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCProtocol]: - """Find a method or premethod by name. - - Args: - method_set: ODTCMethodSet to search. - name: Method name to find. - - Returns: - ODTCProtocol if found (method or premethod), None otherwise. - """ + """Find a method or premethod by name. Returns ODTCProtocol or None.""" m = _get_method_only_by_name(method_set, name) if m is not None: return m return get_premethod_by_name(method_set, name) - return None def list_method_names_only(method_set: ODTCMethodSet) -> List[str]: @@ -1227,155 +1340,6 @@ def _calculate_slope( return default_slope -def protocol_to_odtc_method( - protocol: "Protocol", - config: Optional[ODTCConfig] = None, -) -> ODTCMethod: - """Convert a standard Protocol to an ODTCMethod. - - Args: - protocol: Standard Protocol object with stages and steps. - config: Optional ODTC config for device-specific parameters. - If None, defaults are used. - - Returns: - ODTCMethod ready for upload to ODTC device. - - Note: - This function handles sequential stages with repeats. Each stage with - repeats > 1 is converted to an ODTC loop using GotoNumber/LoopNumber. - """ - if config is None: - config = ODTCConfig() - - odtc_steps: List[ODTCStep] = [] - step_number = 1 - - # Track previous temperature for slope calculation - # Start from room temperature - first step needs to ramp from ambient - prev_temp = 25.0 - - for stage_idx, stage in enumerate(protocol.stages): - stage_start_step = step_number - - for step_idx, step in enumerate(stage.steps): - # Get the target temperature (use first zone for ODTC single-zone) - target_temp = step.temperature[0] if step.temperature else 25.0 - - # Calculate slope - slope = _calculate_slope(prev_temp, target_temp, step.rate, config) - - # Get step settings overrides if any - # Use global step index (across all stages) - global_step_idx = step_number - 1 - step_setting = config.step_settings.get(global_step_idx, ODTCStepSettings()) - - # Create ODTC step with defaults or overrides - odtc_step = ODTCStep( - number=step_number, - slope=step_setting.slope if step_setting.slope is not None else slope, - plateau_temperature=target_temp, - plateau_time=step.hold_seconds, - overshoot_slope1=( - step_setting.overshoot_slope1 if step_setting.overshoot_slope1 is not None else 0.1 - ), - overshoot_temperature=( - step_setting.overshoot_temperature if step_setting.overshoot_temperature is not None else 0.0 - ), - overshoot_time=( - step_setting.overshoot_time if step_setting.overshoot_time is not None else 0.0 - ), - overshoot_slope2=( - step_setting.overshoot_slope2 if step_setting.overshoot_slope2 is not None else 0.1 - ), - goto_number=0, # Will be set below for loops - loop_number=0, # Will be set below for loops - pid_number=step_setting.pid_number if step_setting.pid_number is not None else 1, - lid_temp=( - step_setting.lid_temp if step_setting.lid_temp is not None else config.lid_temperature - ), - ) - - odtc_steps.append(odtc_step) - prev_temp = target_temp - step_number += 1 - - # If stage has repeats > 1, add loop on the last step of the stage - if stage.repeats > 1 and odtc_steps: - last_step = odtc_steps[-1] - last_step.goto_number = stage_start_step - last_step.loop_number = stage.repeats - 1 # ODTC uses 0-based loop count - - # Determine start temperatures - start_block_temp = protocol.stages[0].steps[0].temperature[0] if protocol.stages else 25.0 - start_lid_temp = ( - config.start_lid_temperature - if config.start_lid_temperature is not None - else config.lid_temperature - ) - - # Resolve method name (use scratch name if not provided) - resolved_name = resolve_protocol_name(config.name) - - # Generate timestamp if not already set - resolved_datetime = config.datetime if config.datetime else generate_odtc_timestamp() - - return ODTCMethod( - name=resolved_name, - variant=config.variant, - plate_type=config.plate_type, - fluid_quantity=config.fluid_quantity, - post_heating=config.post_heating, - start_block_temperature=start_block_temp, - start_lid_temperature=start_lid_temp, - steps=odtc_steps, - pid_set=list(config.pid_set), # Copy the list - creator=config.creator, - description=config.description, - datetime=resolved_datetime, - ) - - -def _validate_no_nested_loops(method: ODTCMethod) -> None: - """Validate that an ODTCMethod has no nested loops. - - Args: - method: The ODTCMethod to validate. - - Raises: - ValueError: If the method contains nested/overlapping loops. - """ - loops = [] - for step in method.steps: - if step.goto_number > 0: - # (start_step, end_step, repeat_count) - using 1-based step numbers - loops.append((step.goto_number, step.number, step.loop_number + 1)) - - # Check all pairs for nesting/overlap - for i, (start1, end1, _) in enumerate(loops): - for j, (start2, end2, _) in enumerate(loops): - if i >= j: - continue # Only check each pair once - - # Check for any kind of nesting or overlap: - # 1. Loop 2 fully contained in loop 1: start1 <= start2 AND end2 <= end1 - # (and they're not identical) - # 2. Loop 1 fully contained in loop 2: start2 <= start1 AND end1 <= end2 - # 3. Partial overlap: start1 < start2 < end1 < end2 - # 4. Partial overlap: start2 < start1 < end2 < end1 - - # For sequential loops, ranges don't overlap: end1 < start2 OR end2 < start1 - ranges_overlap = not (end1 < start2 or end2 < start1) - - # If ranges overlap and they're not identical, that's a problem - if ranges_overlap and not (start1 == start2 and end1 == end2): - raise ValueError( - f"ODTCMethod '{method.name}' contains nested loops " - f"(steps {start1}-{end1} and {start2}-{end2}) which cannot be " - "converted to Protocol. Use ODTCMethod directly." - ) - - def _analyze_loop_structure( steps: List[ODTCStep], ) -> List[Tuple[int, int, int]]: @@ -1391,10 +1355,157 @@ def _analyze_loop_structure( loops = [] for step in steps: if step.goto_number > 0: - loops.append((step.goto_number, step.number, step.loop_number + 1)) + # 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]], @@ -1423,28 +1534,27 @@ def _expand_step_sequence( return expanded -def estimate_method_duration_seconds(method: ODTCMethod) -> float: +def estimate_method_duration_seconds(odtc: ODTCProtocol) -> float: """Estimate total method duration from steps (ramp + plateau + overshoot, with loops). Per ODTC Firmware Command Set: duration is slope time + overshoot time + plateau - time per step in consideration of the loops. Ramp time = |delta T| / slope; - slope is clamped to avoid division by zero. + time per step in consideration of the loops. Args: - method: ODTCMethod with steps and start_block_temperature. + odtc: ODTCProtocol (kind='method') with steps and start_block_temperature. Returns: Estimated duration in seconds. """ - if not method.steps: + if not odtc.steps: return 0.0 - loops = _analyze_loop_structure(method.steps) - step_nums = _expand_step_sequence(method.steps, loops) - steps_by_num = {s.number: s for s in method.steps} + loops = _analyze_loop_structure(odtc.steps) + step_nums = _expand_step_sequence(odtc.steps, loops) + steps_by_num = {s.number: s for s in odtc.steps} total = 0.0 - prev_temp = method.start_block_temperature - min_slope = 0.1 # Avoid division by zero + prev_temp = odtc.start_block_temperature + min_slope = 0.1 for step_num in step_nums: step = steps_by_num[step_num] @@ -1456,38 +1566,20 @@ def estimate_method_duration_seconds(method: ODTCMethod) -> float: return total -def odtc_method_to_protocol( - method: ODTCMethod, -) -> Tuple["Protocol", ODTCConfig]: - """Convert an ODTCMethod to a Protocol with companion config for lossless round-trip. +def odtc_method_to_protocol(odtc: ODTCProtocol) -> Tuple["Protocol", ODTCConfig]: + """Convert an ODTCProtocol (kind='method') to a Protocol with companion config. Args: - method: The ODTCMethod to convert. + odtc: The ODTCProtocol to convert. Returns: Tuple of (Protocol, ODTCConfig) where the config captures all ODTC-specific parameters needed to reconstruct the original method. - - Raises: - ValueError: If method contains nested loops that can't be expressed in Protocol. - - Note: - For methods without nested loops, the conversion is lossless. The returned - config captures all ODTC-specific parameters (slopes, overshoot, PID, etc.) - so that protocol_to_odtc_method(protocol, config) produces an equivalent method. """ - # Import here to avoid circular imports - from pylabrobot.thermocycling.standard import Protocol, Stage, Step + from pylabrobot.thermocycling.standard import Protocol - # Validate no nested loops - _validate_no_nested_loops(method) - - # Analyze loop structure - loops = _analyze_loop_structure(method.steps) - - # Build step settings for all ODTC-specific parameters step_settings: Dict[int, ODTCStepSettings] = {} - for i, step in enumerate(method.steps): + for i, step in enumerate(odtc.steps): step_settings[i] = ODTCStepSettings( slope=step.slope, overshoot_slope1=step.overshoot_slope1, @@ -1498,77 +1590,21 @@ def odtc_method_to_protocol( pid_number=step.pid_number, ) - # Build the config capturing all method-level parameters config = ODTCConfig( - name=method.name, - creator=method.creator, - description=method.description, - datetime=method.datetime, - fluid_quantity=method.fluid_quantity, - variant=method.variant, - plate_type=method.plate_type, - lid_temperature=method.start_lid_temperature, - start_lid_temperature=method.start_lid_temperature, - post_heating=method.post_heating, - pid_set=list(method.pid_set) if method.pid_set else [ODTCPID(number=1)], + name=odtc.name, + creator=odtc.creator, + description=odtc.description, + datetime=odtc.datetime, + fluid_quantity=odtc.fluid_quantity, + variant=odtc.variant, + plate_type=odtc.plate_type, + lid_temperature=odtc.start_lid_temperature, + start_lid_temperature=odtc.start_lid_temperature, + post_heating=odtc.post_heating, + pid_set=list(odtc.pid_set) if odtc.pid_set else [ODTCPID(number=1)], step_settings=step_settings, - _validate=False, # Skip validation for data read from device + _validate=False, ) - # Group steps into stages based on loop boundaries - # Create a map of which step ends a loop and what its repeat count is - loop_ends: Dict[int, int] = {} # step_number -> repeat_count - loop_starts: Dict[int, int] = {} # end_step_number -> start_step_number - - for start, end, repeats in loops: - loop_ends[end] = repeats - loop_starts[end] = start - - # Build stages - stages: List[Stage] = [] - current_stage_steps: List[Step] = [] - current_stage_start = 1 # 1-based step number where current stage starts - - for i, odtc_step in enumerate(method.steps): - step_number = odtc_step.number # 1-based - - # Create Protocol Step - protocol_step = Step( - temperature=[odtc_step.plateau_temperature], - hold_seconds=odtc_step.plateau_time, - rate=odtc_step.slope, - ) - current_stage_steps.append(protocol_step) - - # Check if this step ends a loop - if step_number in loop_ends: - loop_start = loop_starts[step_number] - repeats = loop_ends[step_number] - - # If the loop starts at the beginning of current stage, this is a repeating stage - if loop_start == current_stage_start: - # This entire stage repeats - stages.append(Stage(steps=current_stage_steps, repeats=repeats)) - current_stage_steps = [] - current_stage_start = step_number + 1 - else: - # Loop doesn't start at stage beginning - need to split - # Steps before loop_start form a non-repeating stage - # Steps from loop_start to here form a repeating stage - pre_loop_count = loop_start - current_stage_start - if pre_loop_count > 0: - pre_loop_steps = current_stage_steps[:pre_loop_count] - stages.append(Stage(steps=pre_loop_steps, repeats=1)) - - loop_steps = current_stage_steps[pre_loop_count:] - stages.append(Stage(steps=loop_steps, repeats=repeats)) - - current_stage_steps = [] - current_stage_start = step_number + 1 - - # Add any remaining steps as a final stage - if current_stage_steps: - stages.append(Stage(steps=current_stage_steps, repeats=1)) - - protocol = Protocol(stages=stages) - return protocol, config + stages = _build_odtc_stages_from_steps(odtc.steps) + return Protocol(stages=cast(List[Stage], stages)), config diff --git a/pylabrobot/thermocycling/inheco/odtc_tests.py b/pylabrobot/thermocycling/inheco/odtc_tests.py index f17918afc65..99b4169d935 100644 --- a/pylabrobot/thermocycling/inheco/odtc_tests.py +++ b/pylabrobot/thermocycling/inheco/odtc_tests.py @@ -8,18 +8,17 @@ from pylabrobot.thermocycling.inheco.odtc_backend import CommandExecution, MethodExecution, ODTCBackend from pylabrobot.thermocycling.inheco.odtc_model import ( - ODTCMethod, ODTCMethodSet, ODTC_DIMENSIONS, ODTCProtocol, - ODTCPreMethod, + ODTCStage, ODTCStep, PREMETHOD_ESTIMATED_DURATION_SECONDS, - _odtc_method_to_odtc_protocol, - _odtc_premethod_to_odtc_protocol, estimate_method_duration_seconds, + method_set_to_xml, normalize_variant, odtc_protocol_to_protocol, + parse_method_set, ) from pylabrobot.thermocycling.inheco.odtc_thermocycler import ODTCThermocycler from pylabrobot.resources import Coordinate @@ -58,12 +57,13 @@ def test_premethod_constant(self): def test_empty_method_returns_zero(self): """Method with no steps has zero duration.""" - method = ODTCMethod(name="empty", start_block_temperature=20.0, steps=[]) - self.assertEqual(estimate_method_duration_seconds(method), 0.0) + 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.""" - method = ODTCMethod( + odtc = ODTCProtocol( + kind="method", name="single", start_block_temperature=20.0, steps=[ @@ -77,14 +77,16 @@ def test_single_step_no_loop(self): loop_number=0, ), ], + stages=[], ) # Ramp: 75 / 4.4 ≈ 17.045; plateau: 30; overshoot: 5 - got = estimate_method_duration_seconds(method) + 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.""" - method = ODTCMethod( + odtc = ODTCProtocol( + kind="method", name="zero_slope", start_block_temperature=20.0, steps=[ @@ -98,14 +100,16 @@ def test_single_step_zero_slope_clamped(self): loop_number=0, ), ], + stages=[], ) # Ramp: 75 / 0.1 = 750 s (clamped); plateau: 10 - got = estimate_method_duration_seconds(method) + 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.""" - method = ODTCMethod( + odtc = ODTCProtocol( + kind="method", name="loop", start_block_temperature=20.0, steps=[ @@ -128,10 +132,10 @@ def test_two_steps_with_loop(self): loop_number=1, # repeat_count = 2 ), ], + stages=[], ) # Execution: step1, step2, step1, step2 - # Step1: ramp 75/4.4 + 10; step2: ramp 35/2.2 + 5; step1 again: 35/4.4 + 10; step2 again: 35/2.2 + 5 - got = estimate_method_duration_seconds(method) + got = estimate_method_duration_seconds(odtc) self.assertGreater(got, 0) self.assertLess(got, 1000) @@ -805,23 +809,23 @@ async def test_get_data_events(self): async def test_list_protocols(self): """Test list_protocols returns method and premethod names.""" method_set = ODTCMethodSet( - methods=[_odtc_method_to_odtc_protocol(ODTCMethod(name="PCR_30"))], - premethods=[_odtc_premethod_to_odtc_protocol(ODTCPreMethod(name="Pre25"))], + methods=[ODTCProtocol(kind="method", name="PCR_30", stages=[])], + premethods=[ODTCProtocol(kind="premethod", name="Pre25", stages=[])], ) self.backend.get_method_set = AsyncMock(return_value=method_set) # type: ignore[method-assign] names = await self.backend.list_protocols() - self.assertEqual(names, ["PCR_30", "Pre25"]) + self.assertEqual(names.all, ["PCR_30", "Pre25"]) async def test_list_methods(self): """Test list_methods returns (method_names, premethod_names) and matches list_protocols.""" method_set = ODTCMethodSet( methods=[ - _odtc_method_to_odtc_protocol(ODTCMethod(name="PCR_30")), - _odtc_method_to_odtc_protocol(ODTCMethod(name="PCR_35")), + ODTCProtocol(kind="method", name="PCR_30", stages=[]), + ODTCProtocol(kind="method", name="PCR_35", stages=[]), ], premethods=[ - _odtc_premethod_to_odtc_protocol(ODTCPreMethod(name="Pre25")), - _odtc_premethod_to_odtc_protocol(ODTCPreMethod(name="Pre37")), + ODTCProtocol(kind="premethod", name="Pre25", stages=[]), + ODTCProtocol(kind="premethod", name="Pre37", stages=[]), ], ) self.backend.get_method_set = AsyncMock(return_value=method_set) # type: ignore[method-assign] @@ -841,7 +845,7 @@ 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=[_odtc_premethod_to_odtc_protocol(ODTCPreMethod(name="Pre25"))], + 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") @@ -851,11 +855,11 @@ async def test_get_protocol_returns_stored_for_method(self): """Test get_protocol returns ODTCProtocol for runnable method.""" method_set = ODTCMethodSet( methods=[ - _odtc_method_to_odtc_protocol( - ODTCMethod( - name="PCR_30", - steps=[ODTCStep(number=1, plateau_temperature=95.0, plateau_time=30.0)], - ) + ODTCProtocol( + kind="method", + name="PCR_30", + steps=[ODTCStep(number=1, plateau_temperature=95.0, plateau_time=30.0)], + stages=[], ) ], premethods=[], @@ -1047,5 +1051,124 @@ def test_data_event_storage_logic(self): self.assertEqual(len(data_events_by_request_id[12345]), 2) +def _minimal_method_xml_with_nested_loops() -> str: + """Method XML: 5 steps, inner loop 2-4 x 5, outer loop 1-5 x 30 (LoopNumber = actual count).""" + return """ + + false + + 960000 + 0 + 0 + false + 25 + 110 + 14.495100.1000.1001110 + 22.255100.1000.1001110 + 34.472100.1000.1001110 + 44.495100.1000.1251110 + 52.250200.1000.11301110 + 6080250100101010070 + +""" + + +def _minimal_method_xml_flat_loop() -> str: + """Method XML: 2 steps, single loop 1-2 x 3 (flat, no nesting).""" + return """ + + false + + 960000 + 0 + 0 + false + 25 + 110 + 14.495100.1000.1001110 + 22.255100.1000.1131110 + 6080250100101010070 + +""" + + +class TestODTCStageAndRoundTrip(unittest.TestCase): + """Tests for ODTCStage tree, nested loops, and round-trip (steps and stages).""" + + def test_parse_nested_loops_produces_odtc_stage_tree(self): + """Parse Method XML with nested loops; odtc_protocol_to_protocol returns Protocol with ODTCStage tree.""" + method_set = parse_method_set(_minimal_method_xml_with_nested_loops()) + self.assertEqual(len(method_set.methods), 1) + odtc = method_set.methods[0] + protocol, _ = odtc_protocol_to_protocol(odtc) + stages = protocol.stages + self.assertGreater(len(stages), 0) + # Top level: we expect at least one ODTCStage with inner_stages (outer 1-5, inner 2-4) + outer = next((s for s in stages if isinstance(s, ODTCStage) and s.inner_stages), None) + self.assertIsNotNone(outer, "Expected at least one ODTCStage with inner_stages") + assert outer is not None # narrow for type checker + self.assertEqual(outer.repeats, 30) + assert outer.inner_stages is not None # we selected for inner_stages above + self.assertEqual(len(outer.inner_stages), 1) + self.assertEqual(outer.inner_stages[0].repeats, 5) + + def test_round_trip_via_steps_preserves_structure(self): + """Serialize ODTCProtocol (from parsed XML) back to XML and re-parse; structure preserved.""" + method_set = parse_method_set(_minimal_method_xml_with_nested_loops()) + odtc = method_set.methods[0] + xml_out = method_set_to_xml(ODTCMethodSet(delete_all_methods=False, premethods=[], methods=[odtc])) + method_set2 = parse_method_set(xml_out) + self.assertEqual(len(method_set2.methods), 1) + odtc2 = method_set2.methods[0] + self.assertEqual(len(odtc2.steps), len(odtc.steps)) + for i, (a, b) in enumerate(zip(odtc.steps, odtc2.steps)): + self.assertEqual(a.number, b.number, f"step {i} number") + self.assertEqual(a.goto_number, b.goto_number, f"step {i} goto_number") + self.assertEqual(a.loop_number, b.loop_number, f"step {i} loop_number") + + def test_round_trip_via_stages_serializes_and_reparses(self): + """Build ODTCProtocol from ODTCStage tree only (no .steps); serialize uses _odtc_stages_to_steps; re-parse matches.""" + # Build tree with ODTCStep (ODTC-native, lossless): outer 1 and 5, inner 2-4 x 5; outer repeats=30 + step1 = ODTCStep(slope=4.4, plateau_temperature=95.0, plateau_time=10.0) + step2 = ODTCStep(slope=2.2, plateau_temperature=55.0, plateau_time=10.0) + step3 = ODTCStep(slope=4.4, plateau_temperature=72.0, plateau_time=10.0) + step4 = ODTCStep(slope=4.4, plateau_temperature=95.0, plateau_time=10.0) + step5 = ODTCStep(slope=2.2, plateau_temperature=50.0, plateau_time=20.0) + inner = ODTCStage(steps=[step2, step3, step4], repeats=5, inner_stages=None) + outer = ODTCStage(steps=[step1, step5], repeats=30, inner_stages=[inner]) + odtc = ODTCProtocol( + kind="method", + name="FromStages", + variant=960000, + start_block_temperature=25.0, + start_lid_temperature=110.0, + steps=[], # No steps; serialization will use stages + stages=[outer], + ) + xml_str = method_set_to_xml(ODTCMethodSet(delete_all_methods=False, premethods=[], methods=[odtc])) + method_set = parse_method_set(xml_str) + self.assertEqual(len(method_set.methods), 1) + reparsed = method_set.methods[0] + self.assertEqual(len(reparsed.steps), 5) + # Check loop structure: step 4 goto 2 loop 5, step 5 goto 1 loop 30 + by_num = {s.number: s for s in reparsed.steps} + self.assertEqual(by_num[4].goto_number, 2) + self.assertEqual(by_num[4].loop_number, 5) + self.assertEqual(by_num[5].goto_number, 1) + self.assertEqual(by_num[5].loop_number, 30) + + def test_flat_method_produces_flat_stage_list(self): + """Flat method (single loop 1-2 x 3) produces flat list of stages (regression).""" + method_set = parse_method_set(_minimal_method_xml_flat_loop()) + odtc = method_set.methods[0] + protocol, _ = odtc_protocol_to_protocol(odtc) + stages = protocol.stages + self.assertEqual(len(stages), 1) + self.assertEqual(len(stages[0].steps), 2) + self.assertEqual(stages[0].repeats, 3) + if isinstance(stages[0], ODTCStage): + self.assertFalse(stages[0].inner_stages) + + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/thermocycling/standard.py b/pylabrobot/thermocycling/standard.py index 0f97edb1b97..23c474505b2 100644 --- a/pylabrobot/thermocycling/standard.py +++ b/pylabrobot/thermocycling/standard.py @@ -1,6 +1,6 @@ import enum from dataclasses import dataclass -from typing import List, Optional +from typing import List, Optional, Sequence @dataclass From 8d7ed5246d09ca4821bb98a3c46528173ad5b476 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:47:15 -0800 Subject: [PATCH 19/28] Replaced StoredProtocol with ODTCProtocol --- pylabrobot/thermocycling/inheco/odtc_model.py | 71 +++++++------------ 1 file changed, 24 insertions(+), 47 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/odtc_model.py b/pylabrobot/thermocycling/inheco/odtc_model.py index 2bf363c5ad7..e16b0d80d22 100644 --- a/pylabrobot/thermocycling/inheco/odtc_model.py +++ b/pylabrobot/thermocycling/inheco/odtc_model.py @@ -634,6 +634,30 @@ class ODTCProtocol(Protocol): default_heating_slope: float = 4.4 default_cooling_slope: float = 2.2 + def __str__(self) -> str: + """Human-readable summary: name, kind, steps or target temps, key config.""" + lines: List[str] = [f"ODTCProtocol(name={self.name!r}, kind={self.kind!r})"] + if self.kind == "premethod": + lines.append(f" target_block_temperature={self.target_block_temperature:.1f}°C") + lines.append(f" target_lid_temperature={self.target_lid_temperature:.1f}°C") + else: + steps = self.steps + if not steps: + lines.append(" 0 steps") + else: + lines.append(f" {len(steps)} step(s)") + for s in steps: + hold_str = f"{s.plateau_time:.1f}s" if s.plateau_time != float("inf") else "∞" + loop_str = f" goto={s.goto_number} loop={s.loop_number}" if s.goto_number or s.loop_number else "" + lines.append( + f" step {s.number}: {s.plateau_temperature:.1f}°C hold {hold_str}{loop_str}" + ) + lines.append(f" start_block_temperature={self.start_block_temperature:.1f}°C") + lines.append(f" start_lid_temperature={self.start_lid_temperature:.1f}°C") + if self.variant is not None: + lines.append(f" variant={self.variant}") + return "\n".join(lines) + def protocol_to_odtc_protocol( protocol: "Protocol", @@ -772,53 +796,6 @@ def estimate_odtc_protocol_duration_seconds(odtc: ODTCProtocol) -> float: return estimate_method_duration_seconds(odtc) -@dataclass -class StoredProtocol: - """A protocol stored on the device, with instrument config for running it. - - Returned by backend get_protocol(name). Use stored.protocol and stored.config - to inspect or run via run_protocol(stored.protocol, block_max_volume, config=stored.config). - """ - - name: str - protocol: "Protocol" - config: ODTCConfig - - def __str__(self) -> str: - """Human-readable summary: name, stage/step counts, steps, optional config (variant, lid temp).""" - lines: List[str] = [f"StoredProtocol(name={self.name!r})"] - stages = self.protocol.stages - if not stages: - lines.append(" protocol: 0 stages") - else: - lines.append(f" protocol: {len(stages)} stage(s)") - for i, stage in enumerate(stages): - step_count = len(stage.steps) - first_temp = "" - if stage.steps: - temps = stage.steps[0].temperature - first_temp = f", first step temp={temps[0]:.1f}°C" if temps else "" - lines.append( - f" stage {i + 1}: {stage.repeats} repeat(s), {step_count} step(s){first_temp}" - ) - # Step-by-step instruction set - for j, step in enumerate(stage.steps): - temps = step.temperature - t_str = f"{temps[0]:.1f}°C" if temps else "—" - hold = step.hold_seconds - hold_str = f"{hold:.1f}s" if hold != float("inf") else "∞" - rate_str = f" @ {step.rate:.1f}°C/s" if step.rate is not None else "" - lines.append(f" step {j + 1}: {t_str} hold {hold_str}{rate_str}") - c = self.config - if c.variant is not None or c.lid_temperature is not None: - variant_str = f"variant={c.variant}" if c.variant is not None else "" - lid_str = f"lid_temperature={c.lid_temperature}°C" if c.lid_temperature is not None else "" - config_parts = [x for x in (variant_str, lid_str) if x] - if config_parts: - lines.append(" config: " + ", ".join(config_parts)) - return "\n".join(lines) - - # ============================================================================= # Generic XML Serialization/Deserialization # ============================================================================= From 4c5a61be6bcea0f3170b2dbd1d1ffb73f0e2928c Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:21:06 -0800 Subject: [PATCH 20/28] Progress Polling --- pylabrobot/thermocycling/inheco/README.md | 36 ++-- .../thermocycling/inheco/odtc_backend.py | 156 +++++++++++++----- pylabrobot/thermocycling/inheco/odtc_model.py | 9 +- .../thermocycling/inheco/odtc_tutorial.ipynb | 31 ++-- 4 files changed, 152 insertions(+), 80 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/README.md b/pylabrobot/thermocycling/inheco/README.md index 454e92bd7f8..00f205427fb 100644 --- a/pylabrobot/thermocycling/inheco/README.md +++ b/pylabrobot/thermocycling/inheco/README.md @@ -30,20 +30,20 @@ For **methods** (kind='method'): **`.steps`** is the main representation—a fla ### Generic types (PyLabRobot) - **`Protocol`** — `stages: List[Stage]`; hardware-agnostic. -- **`Stage`** — `steps: Sequence[Step]`, `repeats: int`. +- **`Stage`** — `steps: List[Step]`, `repeats: int`. - **`Step`** — `temperature: List[float]`, `hold_seconds: float`, optional `rate`. Example: `Protocol(stages=[Stage(steps=[Step(temperature=[95.0], hold_seconds=30.0)], repeats=1)])`. ### Conversion -- **Device → editable (Protocol + ODTCConfig):** +- **Device → editable (Protocol + ODTCConfig):** `get_protocol(name)` returns `Optional[ODTCProtocol]`. Use **`odtc_method_to_protocol(odtc)`** to get `(Protocol, ODTCConfig)` for modifying then re-uploading with the same thermal tuning. -- **Protocol + ODTCConfig → ODTC (upload/run):** +- **Protocol + ODTCConfig → ODTC (upload/run):** Use **`protocol_to_odtc_protocol(protocol, config=config)`** to get an `ODTCProtocol` for upload or for passing to `run_protocol(odtc, block_max_volume)`. -- **ODTCProtocol → Protocol view only:** +- **ODTCProtocol → Protocol view only:** Use **`odtc_protocol_to_protocol(odtc)`** to get `(Protocol, ODTCProtocol)` when you need a generic Protocol view (e.g. stage tree) without a separate ODTCConfig. ### Method name (string) @@ -107,12 +107,13 @@ Use these patterns for the best balance of simplicity and thermal performance. **Use when:** The protocol (method) is already on the device. Single instrument call; no upload. ```python -# List names: methods and premethods -names = await tc.backend.list_protocols() # e.g. ["PCR_30cycles", "my_pcr", ...] +# List names: methods and premethods (ProtocolList with .methods, .premethods, .all) +protocol_list = await tc.backend.list_protocols() # Run by name (blocking or non-blocking) await tc.run_stored_protocol("PCR_30cycles") # Or with handle: execution = await tc.run_stored_protocol("PCR_30cycles", wait=False); await execution +# When awaiting a handle, progress is logged every progress_log_interval (default 150 s) for method runs. ``` ### 2. Get → modify → upload with config → run (round-trip for thermal performance) @@ -315,12 +316,12 @@ if await method_exec.is_running(): ### List All Protocol Names (Recommended) ```python -# List all protocol names (both Methods and PreMethods) -protocol_names = await tc.backend.list_protocols() -# Returns: ["PCR_30cycles", "my_pcr", "PRE25", "plr_currentProtocol", ...] +# List all protocol names (ProtocolList: .methods, .premethods, .all, and iterable) +protocol_list = await tc.backend.list_protocols() -for name in protocol_names: +for name in protocol_list: print(f"Protocol: {name}") +# Or: protocol_list.all for flat list; protocol_list.methods / protocol_list.premethods for split ``` ### List Methods and PreMethods Separately @@ -328,7 +329,7 @@ for name in protocol_names: ```python # Returns (method_names, premethod_names); methods are runnable, premethods are setup-only methods, premethods = await tc.backend.list_methods() -# methods + premethods equals list_protocols() +# methods + premethods equals protocol_list.all (from list_protocols()) ``` ### Get Runnable Protocol by Name @@ -531,24 +532,27 @@ odtc_restored = await tc.backend.get_protocol("PCR_30cycles_restored") # Content should match (XML formatting may differ) ``` -## DataEvent Collection +## DataEvent Collection and Progress -During method execution, the ODTC sends `DataEvent` messages containing experimental data. These are automatically collected: +During method execution, the ODTC sends **DataEvent** messages; the backend stores them and derives progress (elapsed time, step/cycle, temperatures). When you **await** an execution handle (`await execution` or `await execution.wait()`), progress is reported every **progress_log_interval** (default 150 s) via log lines or **progress_callback**. Same behavior when using **wait_resumable()** (polling-based wait). ```python # Start method execution = await tc.run_stored_protocol("PCR_30cycles", wait=False) -# Get DataEvents for this execution +# Progress is logged every progress_log_interval (default 150 s) while you await +await execution # or await execution.wait() + +# Get DataEvents for this execution (raw payloads) events = await execution.get_data_events() -# Returns: List of DataEvent objects +# Returns: List of DataEvent payload dicts # Get all collected events (backend-level) all_events = await tc.backend.get_data_events() # Returns: {request_id1: [...], request_id2: [...]} ``` -**Note:** DataEvent parsing and progress tracking are planned for future implementation. Currently, raw event payloads are stored for later analysis. +**Backend option:** `ODTCBackend(..., progress_log_interval=150.0, progress_callback=...)`. Set `progress_log_interval` to `None` or `0` to disable progress reporting during wait. ## Error Handling diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index dda1643f8f2..a96d405073e 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -6,7 +6,7 @@ import logging import xml.etree.ElementTree as ET from dataclasses import dataclass, replace -from typing import Any, Callable, Dict, List, Literal, NamedTuple, Optional, Tuple, Union +from typing import Any, Awaitable, Callable, Dict, List, Literal, NamedTuple, Optional, Tuple, Union from pylabrobot.thermocycling.backend import ThermocyclerBackend from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol @@ -30,7 +30,7 @@ generate_odtc_timestamp, get_constraints, get_method_by_name, - list_method_names_only, + list_method_names, list_premethod_names, method_set_to_xml, normalize_variant, @@ -403,14 +403,39 @@ def _log_wait_info(self) -> None: lines.append(f" Remaining: {remaining:.0f}s") self.backend.logger.info("\n".join(lines)) + async def _is_done(self) -> bool: + """True when the command has completed (Future resolved). Used by progress loop.""" + return self._future.done() + async def wait(self) -> None: """Wait for command completion. Equivalent to ``await self`` (the handle is awaitable via __await__). + When backend.progress_log_interval is set, reports progress (from latest DataEvent) + every progress_log_interval seconds until the command completes. """ if not self._future.done(): self._log_wait_info() - await self._future + interval = self.backend.progress_log_interval + if interval and interval > 0: + task = asyncio.create_task( + self.backend._run_progress_loop_until( + self.request_id, + interval, + self._is_done, + self.backend.progress_callback, + ) + ) + try: + await self._future + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + else: + await self._future async def wait_resumable(self, poll_interval: float = 5.0) -> None: """Wait for completion using only GetStatus and handle timing (resumable after restart). @@ -504,7 +529,7 @@ def __init__( poll_interval: float = 5.0, lifetime_of_execution: Optional[float] = None, on_response_event_missing: Literal["warn_and_continue", "error"] = "warn_and_continue", - progress_log_interval: Optional[float] = 30.0, + progress_log_interval: Optional[float] = 150.0, progress_callback: Optional[Callable[..., None]] = None, ): """Initialize ODTC backend. @@ -519,7 +544,7 @@ def __init__( poll_interval: Seconds between GetStatus calls in the async completion polling fallback (SiLA2 subscribe_by_polling style). Default 5.0. lifetime_of_execution: Max seconds to wait for async command completion (SiLA2 deadline). If None, uses 3 hours. Protocol execution is always bounded. on_response_event_missing: When completion is detected via polling but ResponseEvent was not received: "warn_and_continue" (resolve with None, log warning) or "error" (set exception). Default "warn_and_continue". - progress_log_interval: Seconds between progress log lines during wait. None or 0 to disable. Default 30.0. + progress_log_interval: Seconds between progress log lines during wait. None or 0 to disable. Default 150.0 (2.5 min); suitable for protocols from minutes to 1–2+ hours. progress_callback: Optional callback(progress) called each progress_log_interval during wait. """ super().__init__() @@ -1056,7 +1081,6 @@ async def wait_for_completion_by_time( interval = progress_log_interval if progress_log_interval is not None else self.progress_log_interval callback = progress_callback if progress_callback is not None else self.progress_callback - last_progress_log = 0.0 buffer = POLLING_START_BUFFER eta = estimated_remaining_time or 0.0 while True: @@ -1071,40 +1095,32 @@ async def wait_for_completion_by_time( if remaining_wait > 0: await asyncio.sleep(min(remaining_wait, poll_interval)) continue - # Progress reporting: fetch events, parse latest, log and/or callback - if interval and interval > 0 and now - last_progress_log >= interval: - last_progress_log = now - events_dict = await self.get_data_events(request_id) - events = events_dict.get(request_id, []) - if events: - snapshot = parse_data_event_payload(events[-1]) - if snapshot is not None: - self._last_snapshot_by_request_id[request_id] = snapshot - progress = await self._get_progress(request_id) - if progress is not None: - if callback is not None: - try: - callback(progress) - except Exception: # noqa: S110 - pass - else: - self.logger.info( - "ODTC progress: elapsed %.0fs, block %.1f°C (target %.1f°C), lid %.1f°C, " - "step %d/%d, cycle %d/%d, hold remaining ~%.0fs", - progress.elapsed_s, - progress.current_temp_c or 0.0, - progress.target_temp_c or 0.0, - progress.lid_temp_c or 0.0, - progress.current_step_index + 1, - progress.total_step_count, - progress.current_cycle_index + 1, - progress.total_cycle_count, - progress.remaining_hold_s, + # From here: poll until terminal_state or timeout; progress via same loop as wait() + async def _is_done() -> bool: + status = await self.get_status() + return status == terminal_state or (time.time() - started_at) >= lifetime + progress_task: Optional[asyncio.Task[None]] = None + if interval and interval > 0: + progress_task = asyncio.create_task( + self._run_progress_loop_until(request_id, interval, _is_done, callback) + ) + try: + while True: + status = await self.get_status() + if status == terminal_state: + return + if (time.time() - started_at) >= lifetime: + raise TimeoutError( + f"Command (request_id={request_id}) did not complete within {lifetime}s" ) - status = await self.get_status() - if status == terminal_state: - return - await asyncio.sleep(poll_interval) + await asyncio.sleep(poll_interval) + finally: + if progress_task is not None: + progress_task.cancel() + try: + await progress_task + except asyncio.CancelledError: + pass async def get_data_events(self, request_id: Optional[int] = None) -> Dict[int, List[Dict[str, Any]]]: """Get collected DataEvents. @@ -1163,7 +1179,7 @@ async def list_protocols(self) -> ProtocolList: """ method_set = await self.get_method_set() return ProtocolList( - methods=list_method_names_only(method_set), + methods=list_method_names(method_set), premethods=list_premethod_names(method_set), ) @@ -1175,7 +1191,7 @@ async def list_methods(self) -> Tuple[List[str], List[str]]: premethods are setup-only (e.g. set block/lid temperature). """ method_set = await self.get_method_set() - return (list_method_names_only(method_set), list_premethod_names(method_set)) + return (list_method_names(method_set), list_premethod_names(method_set)) def get_default_config(self, **kwargs) -> ODTCConfig: """Get a default ODTCConfig with variant set to this backend's variant. @@ -1693,6 +1709,64 @@ async def get_block_status(self) -> BlockStatus: except Exception: return BlockStatus.IDLE + async def _report_progress_once( + self, + request_id: int, + callback: Optional[Callable[..., None]] = None, + ) -> None: + """Fetch latest DataEvent for request_id, update snapshot, and log or invoke progress_callback. + + Used by wait_for_completion_by_time and by CommandExecution.wait() (background task). + No-op if _get_progress returns None (e.g. non-method command). + callback: Override for this call; if None, uses self.progress_callback. + """ + events_dict = await self.get_data_events(request_id) + events = events_dict.get(request_id, []) + if events: + snapshot = parse_data_event_payload(events[-1]) + if snapshot is not None: + self._last_snapshot_by_request_id[request_id] = snapshot + progress = await self._get_progress(request_id) + if progress is None: + return + cb = callback if callback is not None else self.progress_callback + if cb is not None: + try: + cb(progress) + except Exception: # noqa: S110 + pass + else: + self.logger.info( + "ODTC progress: elapsed %.0fs, block %.1f°C (target %.1f°C), lid %.1f°C, " + "step %d/%d, cycle %d/%d, hold remaining ~%.0fs", + progress.elapsed_s, + progress.current_temp_c or 0.0, + progress.target_temp_c or 0.0, + progress.lid_temp_c or 0.0, + progress.current_step_index + 1, + progress.total_step_count, + progress.current_cycle_index + 1, + progress.total_cycle_count, + progress.remaining_hold_s, + ) + + async def _run_progress_loop_until( + self, + request_id: int, + interval: float, + done_async: Callable[[], Awaitable[bool]], + callback: Optional[Callable[..., None]] = None, + ) -> None: + """Run progress reporting every interval until done_async() returns True. + + Single definition dispatched by both Future-based wait() and polling-based + wait_for_completion_by_time. Stops when done_async() returns True (e.g. + future.done() or status == terminal_state or timeout). + """ + while not (await done_async()): + await self._report_progress_once(request_id, callback=callback) + await asyncio.sleep(interval) + async def _get_progress(self, request_id: int) -> Optional[ODTCProgress]: """Get progress for a run: protocol position + temps from latest DataEvent. Returns None if no protocol.""" protocol = self._protocol_by_request_id.get(request_id) diff --git a/pylabrobot/thermocycling/inheco/odtc_model.py b/pylabrobot/thermocycling/inheco/odtc_model.py index e16b0d80d22..5d75e7b3f3c 100644 --- a/pylabrobot/thermocycling/inheco/odtc_model.py +++ b/pylabrobot/thermocycling/inheco/odtc_model.py @@ -1200,7 +1200,7 @@ def get_method_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCPro return get_premethod_by_name(method_set, name) -def list_method_names_only(method_set: ODTCMethodSet) -> List[str]: +def list_method_names(method_set: ODTCMethodSet) -> List[str]: """Get all method names (methods only, not premethods).""" return [m.name for m in method_set.methods] @@ -1210,13 +1210,6 @@ def list_premethod_names(method_set: ODTCMethodSet) -> List[str]: return [pm.name for pm in method_set.premethods] -def list_method_names(method_set: ODTCMethodSet) -> List[str]: - """Get all method names (both methods and premethods).""" - method_names = [m.name for m in method_set.methods] - premethod_names = [pm.name for pm in method_set.premethods] - return method_names + premethod_names - - class ProtocolList: """Result of list_protocols(): methods and premethods with nice __str__ and backward-compat .all / iteration.""" diff --git a/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb b/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb index a8c3104eaa3..07354d9caa6 100644 --- a/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb +++ b/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb @@ -27,7 +27,7 @@ "import logging\n", "from pylabrobot.resources import Coordinate\n", "from pylabrobot.thermocycling.inheco import ODTCThermocycler\n", - "# Preferred: ODTCThermocycler (dimensions from ODTC_DIMENSIONS; variant 96 or 384)\n", + "# Preferred: ODTCThermocycler (built-in ODTC dimensions; variant 96 or 384)\n", "tc = ODTCThermocycler(name=\"odtc_test\", odtc_ip=\"192.168.1.50\", variant=96, child_location=Coordinate.zero())\n", "# Override: tc = Thermocycler(..., backend=ODTCBackend(odtc_ip=..., variant=96, logger=...), ...) for custom backend" ] @@ -36,9 +36,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 2. Connect and list device methods\n", + "## 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 `list_protocols()` to get a `ProtocolList` (methods and premethods in clear sections); `get_protocol(name)` returns an **ODTCProtocol** for methods (None for premethods)." + "`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)." ] }, { @@ -55,11 +55,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "**After `get_protocol(name)`** you get an **ODTCProtocol** (subclasses Protocol; has `.stages`, `.name`, overshoot/PID fields):\n", + "**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:** `run_protocol(odtc, block_max_volume)` — backend accepts ODTCProtocol; same device-calculated config.\n", - "- **Run by name (recommended for PCR):** `run_stored_protocol(\"MethodName\")` — device runs its Script Editor method; optimal thermal (overshoots utilized, device-tuned ramps).\n", - "- **Weaker option:** Uploading a custom protocol via `run_protocol(protocol, block_max_volume)` **without** a corresponding calculated config — no device overshoot/PID, so thermal performance is not optimized." + "- **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." ] }, { @@ -77,10 +77,10 @@ "# 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", - " odtc = await tc.backend.get_protocol(first_name)\n", - " if odtc:\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(odtc)\n", + " print(fetched_protocol)\n", " # Roundtrip: run with same ODTC config via run_protocol(odtc, block_max_volume)\n" ] }, @@ -90,7 +90,7 @@ "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. Omit `wait=False` to block until the command finishes." + "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." ] }, { @@ -117,7 +117,7 @@ "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. Use **`config=tc.backend.get_default_config(post_heating=True)`** to hold temperatures after the method ends." + "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 handle, progress (from DataEvents) is reported every **progress_log_interval** (default 150 s). Use **`config=tc.backend.get_default_config(post_heating=True)`** to hold temperatures after the method ends." ] }, { @@ -183,7 +183,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "**Status and progress during a protocol run** — The ODTC sends **DataEvent** messages while a method is running; the backend parses them into progress (elapsed time, cycle/step indices, temperatures). Use these status APIs to monitor a run: **`is_profile_running()`**, **`get_hold_time()`**, **`get_current_cycle_index()`** / **`get_total_cycle_count()`**, **`get_current_step_index()`** / **`get_total_step_count()`**. Run the cell below after starting the protocol to poll and print status." + "**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 (elapsed time, step/cycle, temperatures). You can also poll status: **`is_profile_running()`**, **`get_hold_time()`**, **`get_current_cycle_index()`** / **`get_total_cycle_count()`**, **`get_current_step_index()`** / **`get_total_step_count()`**. Run the cell below after starting the protocol to poll and print status manually." ] }, { @@ -208,7 +208,7 @@ " block = await tc.get_block_current_temperature()\n", " lid = await tc.get_lid_current_temperature()\n", " print(f\"Poll {poll + 1}: cycle {cycle + 1}/{total_cycles}, step {step + 1}/{total_steps}, hold_remaining={hold_s:.1f}s, block={block[0]:.1f}°C, lid={lid[0]:.1f}°C\")\n", - " await asyncio.sleep(5)\n", + " await asyncio.sleep(10)\n", "else:\n", " print(\"Still running after 6 polls; use await execution.wait() or tc.wait_for_profile_completion() to block until done.\")" ] @@ -228,7 +228,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Block until protocol done (alternatively: await tc.wait_for_profile_completion(poll_interval=..., timeout=...))\n", + "# 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", From dc91515a6d478b343273fb2ffbf82a3d6bc0cf4d Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:40:25 -0800 Subject: [PATCH 21/28] Register execution ids for tracking --- pylabrobot/thermocycling/inheco/odtc_backend.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index a96d405073e..d1e7fe59146 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -1256,7 +1256,13 @@ async def _upload_odtc_protocol( ) if execute: - return await self.execute_method(resolved_name, wait=wait) + eta = estimate_odtc_protocol_duration_seconds(odtc_copy) + handle = await self.execute_method( + resolved_name, wait=wait, estimated_duration_seconds=eta + ) + protocol_view = odtc_protocol_to_protocol(odtc_copy)[0] + self._protocol_by_request_id[handle.request_id] = protocol_view + return handle return None async def upload_protocol( @@ -1523,11 +1529,16 @@ async def set_block_temperature( debug_xml=debug_xml, xml_output_path=xml_output_path, ) - return await self.execute_method( + handle = await self.execute_method( resolved_name, wait=wait, estimated_duration_seconds=PREMETHOD_ESTIMATED_DURATION_SECONDS, ) + # Register protocol view so progress (DataEvent parsing) is reported every + # progress_log_interval while awaiting the handle (e.g. await mount_handle). + protocol_view = odtc_protocol_to_protocol(odtc)[0] + self._protocol_by_request_id[handle.request_id] = protocol_view + return handle async def set_lid_temperature(self, temperature: List[float]) -> None: """Set lid temperature. From ed389eccb0d8b9a5242ed83ea0dbca0711832a2e Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:46:34 -0800 Subject: [PATCH 22/28] Fix --- .../thermocycling/inheco/odtc_backend.py | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index d1e7fe59146..b1303383644 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -401,7 +401,10 @@ def _log_wait_info(self) -> None: ] if remaining is not None: lines.append(f" Remaining: {remaining:.0f}s") - self.backend.logger.info("\n".join(lines)) + msg = "\n".join(lines) + self.backend.logger.info("%s", msg) + # Also print so visible in notebooks/consoles without logging config + print(msg) async def _is_done(self) -> bool: """True when the command has completed (Future resolved). Used by progress loop.""" @@ -1747,19 +1750,17 @@ async def _report_progress_once( except Exception: # noqa: S110 pass else: - self.logger.info( - "ODTC progress: elapsed %.0fs, block %.1f°C (target %.1f°C), lid %.1f°C, " - "step %d/%d, cycle %d/%d, hold remaining ~%.0fs", - progress.elapsed_s, - progress.current_temp_c or 0.0, - progress.target_temp_c or 0.0, - progress.lid_temp_c or 0.0, - progress.current_step_index + 1, - progress.total_step_count, - progress.current_cycle_index + 1, - progress.total_cycle_count, - progress.remaining_hold_s, + msg = ( + f"ODTC progress: elapsed {progress.elapsed_s:.0f}s, " + f"block {progress.current_temp_c or 0.0:.1f}°C (target {progress.target_temp_c or 0.0:.1f}°C), " + f"lid {progress.lid_temp_c or 0.0:.1f}°C, " + f"step {progress.current_step_index + 1}/{progress.total_step_count}, " + f"cycle {progress.current_cycle_index + 1}/{progress.total_cycle_count}, " + f"hold remaining ~{progress.remaining_hold_s:.0f}s" ) + self.logger.info("%s", msg) + # Also print so progress is visible in notebooks/consoles without logging config + print(msg) async def _run_progress_loop_until( self, From b35db2a9856281d8b081336aa40aca8a7972b2b2 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:51:35 -0800 Subject: [PATCH 23/28] wait is __await__ --- pylabrobot/thermocycling/inheco/odtc_backend.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index b1303383644..217d4b1718a 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -351,10 +351,8 @@ class CommandExecution: lifetime: Optional[float] = None # max wait seconds (for resumable wait) def __await__(self): - """Make this awaitable like a Task.""" - if not self._future.done(): - self._log_wait_info() - return self._future.__await__() + """Make this awaitable; delegates to wait() so progress loop runs during await.""" + return self.wait().__await__() @property def done(self) -> bool: From 30a3d64dabdec9157196bbda5bf308b626ac71ec Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:08:20 -0800 Subject: [PATCH 24/28] Remove prints --- .../thermocycling/inheco/odtc_backend.py | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index 217d4b1718a..3dcbc3d099b 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -399,10 +399,7 @@ def _log_wait_info(self) -> None: ] if remaining is not None: lines.append(f" Remaining: {remaining:.0f}s") - msg = "\n".join(lines) - self.backend.logger.info("%s", msg) - # Also print so visible in notebooks/consoles without logging config - print(msg) + self.backend.logger.info("\n".join(lines)) async def _is_done(self) -> bool: """True when the command has completed (Future resolved). Used by progress loop.""" @@ -1748,17 +1745,19 @@ async def _report_progress_once( except Exception: # noqa: S110 pass else: - msg = ( - f"ODTC progress: elapsed {progress.elapsed_s:.0f}s, " - f"block {progress.current_temp_c or 0.0:.1f}°C (target {progress.target_temp_c or 0.0:.1f}°C), " - f"lid {progress.lid_temp_c or 0.0:.1f}°C, " - f"step {progress.current_step_index + 1}/{progress.total_step_count}, " - f"cycle {progress.current_cycle_index + 1}/{progress.total_cycle_count}, " - f"hold remaining ~{progress.remaining_hold_s:.0f}s" + self.logger.info( + "ODTC progress: elapsed %.0fs, block %.1f°C (target %.1f°C), lid %.1f°C, " + "step %d/%d, cycle %d/%d, hold remaining ~%.0fs", + progress.elapsed_s, + progress.current_temp_c or 0.0, + progress.target_temp_c or 0.0, + progress.lid_temp_c or 0.0, + progress.current_step_index + 1, + progress.total_step_count, + progress.current_cycle_index + 1, + progress.total_cycle_count, + progress.remaining_hold_s, ) - self.logger.info("%s", msg) - # Also print so progress is visible in notebooks/consoles without logging config - print(msg) async def _run_progress_loop_until( self, From 3dda9fef9741cc95821230674498cdd958e72998 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sun, 15 Feb 2026 00:57:55 -0800 Subject: [PATCH 25/28] Centralize ODTCProtocol Lifecycle and Progress Reporting to ODTCProgress. Implement Get_Step,Cycle,Remaining methods. Cleanup. --- pylabrobot/thermocycling/inheco/README.md | 609 +++----------- pylabrobot/thermocycling/inheco/__init__.py | 10 +- .../thermocycling/inheco/odtc_backend.py | 792 +++++++----------- pylabrobot/thermocycling/inheco/odtc_model.py | 409 ++++++++- .../inheco/odtc_sila_interface.py | 223 +++-- pylabrobot/thermocycling/inheco/odtc_tests.py | 607 +++++++++++++- .../thermocycling/inheco/odtc_thermocycler.py | 9 + .../thermocycling/inheco/odtc_tutorial.ipynb | 332 +++++++- 8 files changed, 1815 insertions(+), 1176 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/README.md b/pylabrobot/thermocycling/inheco/README.md index 00f205427fb..884d8ac96c3 100644 --- a/pylabrobot/thermocycling/inheco/README.md +++ b/pylabrobot/thermocycling/inheco/README.md @@ -2,57 +2,18 @@ ## Overview -Interface for Inheco ODTC thermocyclers via SiLA (SOAP over HTTP). Supports asynchronous method execution (blocking and non-blocking), round-trip protocol conversion (ODTC XML ↔ PyLabRobot `Protocol` with lossless ODTC parameters), parallel commands (e.g. read temperatures during run), and DataEvent collection. +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**. -**New users:** Start with **Connection and Setup**, then **ODTC Model** (types and conversion), then **Recommended Workflows** (run by name, round-trip for thermal performance, set block/lid temp). A step-by-step tutorial notebook is in **`odtc_tutorial.ipynb`**. Use **Running Commands** and **Getting Protocols** for async handles; **ODTCProtocol and Protocol + ODTCConfig Conversion** for conversion detail. +- **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 +**Architecture:** `ODTCSiLAInterface` (SiLA SOAP, state machine; stores raw DataEvent payloads) → `ODTCBackend` (method execution, protocol conversion; builds **ODTCProgress** from latest payload + protocol) → `ODTCThermocycler` (resource; preferred) or generic `Thermocycler` with `ODTCBackend`. Types in `odtc_model.py`. -- **`ODTCSiLAInterface`** (`odtc_sila_interface.py`) — SiLA SOAP layer: `send_command` / `start_command`, parallelism rules, state machine (Startup → Standby → Idle → Busy), lockId, DataEvents. -- **`ODTCBackend`** (`odtc_backend.py`) — Implements `ThermocyclerBackend`: method execution, protocol conversion, upload/download, status. -- **`ODTCThermocycler`** (`odtc_thermocycler.py`) — Preferred resource: takes `odtc_ip`, `variant` (96/384 or 960000/384000), uses ODTC dimensions (147×298×130 mm). Alternative: generic `Thermocycler` with `ODTCBackend` for custom sizing. -- **`odtc_model.py`** — MethodSet XML (de)serialization, `ODTCProtocol` ↔ `Protocol` conversion, `ODTCConfig` for ODTC-specific parameters. +**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. -## ODTC Model: Types and Conversion +**Tutorial:** `odtc_tutorial.ipynb`. Sections: **Setup** → **Workflows** → **Types and conversion** → **Commands** → **Device protocols** → **DataEvents and progress** → **Error handling** → **Best practices** → **Complete example**. -The ODTC implementation is built around **ODTCProtocol**, **ODTCStage**, and **ODTCStep**, which extend PyLabRobot’s generic **Protocol**, **Stage**, and **Step**. The device stores protocols by a **method name** (string); conversion functions map between ODTC types and the generic types for editing and round-trip. - -### Core types - -| Type | Role | -|------|------| -| **`ODTCStep`** | Extends `Step`. Single temperature step with ODTC fields (slope, overshoot, plateau_time, goto_number, loop_number). | -| **`ODTCStage`** | Extends `Stage`. Holds `steps: List[ODTCStep]` and optional `inner_stages` for nested loops. | -| **`ODTCProtocol`** | Extends `Protocol`. One type for both **methods** (cycling) and **premethods** (hold block/lid temp), distinguished by `kind='method'` or `kind='premethod'`. | - -For **methods** (kind='method'): **`.steps`** is the main representation—a flat list of `ODTCStep` with step numbers and goto/loop. When built from a generic `Protocol` (e.g. `protocol_to_odtc_protocol`), we set `stages=[]`; the stage view is derived when needed via `odtc_protocol_to_protocol(odtc)` (which builds a `Protocol` with stages from the step list). Parsed XML with nested loops can produce an ODTCProtocol whose stage tree is built from steps for display or serialization. - -### Generic types (PyLabRobot) - -- **`Protocol`** — `stages: List[Stage]`; hardware-agnostic. -- **`Stage`** — `steps: List[Step]`, `repeats: int`. -- **`Step`** — `temperature: List[float]`, `hold_seconds: float`, optional `rate`. - -Example: `Protocol(stages=[Stage(steps=[Step(temperature=[95.0], hold_seconds=30.0)], repeats=1)])`. - -### Conversion - -- **Device → editable (Protocol + ODTCConfig):** - `get_protocol(name)` returns `Optional[ODTCProtocol]`. Use **`odtc_method_to_protocol(odtc)`** to get `(Protocol, ODTCConfig)` for modifying then re-uploading with the same thermal tuning. - -- **Protocol + ODTCConfig → ODTC (upload/run):** - Use **`protocol_to_odtc_protocol(protocol, config=config)`** to get an `ODTCProtocol` for upload or for passing to `run_protocol(odtc, block_max_volume)`. - -- **ODTCProtocol → Protocol view only:** - Use **`odtc_protocol_to_protocol(odtc)`** to get `(Protocol, ODTCProtocol)` when you need a generic Protocol view (e.g. stage tree) without a separate ODTCConfig. - -### Method name (string) - -The device identifies stored protocols by a **method name** (SiLA: `methodName`), e.g. `"PCR_30cycles"`, `"plr_currentProtocol"`. Use it with `run_stored_protocol(name)`, `get_protocol(name)`, and `list_protocols()`. - -**API:** `tc.run_protocol(protocol, block_max_volume)` or `tc.run_stored_protocol(name)`. Backend: `list_protocols()`, `get_protocol(name)` → `Optional[ODTCProtocol]` (runnable methods only; premethods → `None`), `upload_protocol(protocol, name=..., config=...)`, `set_block_temperature(...)`, `get_default_config()`, `execute_method(method_name)`. - -## Connection and Setup +## Setup **Preferred: ODTCThermocycler** (owns dimensions and backend): @@ -69,545 +30,206 @@ tc = ODTCThermocycler( await tc.setup() # HTTP event receiver + Reset + Initialize → idle ``` -**Alternative:** Generic `Thermocycler` with `ODTCBackend` (e.g. custom dimensions): - -```python -from pylabrobot.thermocycling.inheco import ODTCBackend -from pylabrobot.thermocycling.thermocycler import Thermocycler - -backend = ODTCBackend(odtc_ip="192.168.1.100", variant=960000) -tc = Thermocycler(name="odtc", size_x=147, size_y=298, size_z=130, backend=backend, child_location=Coordinate(0, 0, 0)) -await tc.setup() -``` - -**Estimated duration:** The device does not return duration in the async response. We compute it: PreMethod = 10 min; Method = from steps (ramp + plateau + overshoot, with loops). This estimate is used for `handle.estimated_remaining_time`, when to start polling, and a tighter timeout cap. +**Alternative:** Generic `Thermocycler` with `ODTCBackend(odtc_ip=..., variant=...)` for custom dimensions. -**Setup options:** `setup(full=True, simulation_mode=False, max_attempts=3, retry_backoff_base_seconds=1.0)`. When `full=True` (default), the full path runs up to `max_attempts` times with exponential backoff on failure (e.g. flaky network). Use `max_attempts=1` to disable retry. Use `full=False` to only start the event receiver without resetting the device (see **Reconnecting after session loss** below). +**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. -### Simulation mode +**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**). -Enter simulation mode: `await tc.backend.reset(simulation_mode=True)`. Exit: `await tc.backend.reset(simulation_mode=False)`. In simulation mode, commands return immediately with estimated duration; valid until the next Reset. Check state without resetting: `tc.backend.simulation_mode` reflects the last `reset(simulation_mode=...)` call. To bring the device up in simulation: `await tc.setup(simulation_mode=True)` (full path with simulation enabled). +**Simulation:** `await tc.backend.reset(simulation_mode=True)`; exit with `simulation_mode=False`. Commands return immediately with estimated duration. -### Reconnecting after session loss +**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. -If the session or connection was lost while a method is running, you can reconnect without aborting the method. Create a new backend (or thermocycler), then call `await tc.backend.setup(full=False)` to only start the event receiver—do **not** call full setup (that would Reset and abort the method). Then use `wait_for_completion_by_time(...)` or a persisted handle's `wait_resumable()` to wait for the in-flight method to complete. After the method is done, call `setup(full=True)` if you need a full session for subsequent commands. +**Cleanup:** `await tc.stop()`. -### Cleanup +## Workflows -```python -await tc.stop() # Closes HTTP server and connections -``` - -## Recommended Workflows +### 1. Run stored protocol by name (primary) -Use these patterns for the best balance of simplicity and thermal performance. +Protocol is already on the device; single call, no upload. Preferred usage. -### 1. Run stored protocol by name - -**Use when:** The protocol (method) is already on the device. Single instrument call; no upload. +**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 -# List names: methods and premethods (ProtocolList with .methods, .premethods, .all) protocol_list = await tc.backend.list_protocols() - -# Run by name (blocking or non-blocking) -await tc.run_stored_protocol("PCR_30cycles") -# Or with handle: execution = await tc.run_stored_protocol("PCR_30cycles", wait=False); await execution -# When awaiting a handle, progress is logged every progress_log_interval (default 150 s) for method runs. +# 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. Get → modify → upload with config → run (round-trip for thermal performance) +### 2. Edited ODTCProtocol (secondary) -**Use when:** You want to change an existing device protocol (e.g. cycle count) while keeping equivalent thermal performance. Preserving `ODTCConfig` keeps overshoot and other ODTC parameters from the original. +Get from device → modify **only hold times and cycle count** → upload → run. Preserves ODTC parameters (overshoot, slopes) because temperatures are unchanged. -```python -from pylabrobot.thermocycling.inheco.odtc_model import odtc_method_to_protocol, protocol_to_odtc_protocol +**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. -# Get runnable protocol from device (returns None for premethods) +```python odtc = await tc.backend.get_protocol("PCR_30cycles") if odtc is None: raise ValueError("Protocol not found") -protocol, config = odtc_method_to_protocol(odtc) - -# Modify only durations or cycle counts; keep temperatures unchanged -# ODTCConfig is tuned for the original temperature setpoints—change temps and tuning may be wrong -protocol.stages[0].repeats = 35 # Safe: cycle count -# Do NOT change temperature setpoints when reusing config - -# Upload with same config so overshoot/ODTC params are preserved -await tc.backend.upload_protocol(protocol, name="PCR_35cycles", config=config) - -# Run by name +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") ``` -**Why:** New protocols created without a config use default overshoot parameters and can heat more slowly. Using `get_protocol` + `upload_protocol(..., config=config)` preserves the device’s thermal tuning. - -**Important:** When reusing an ODTC-specific config, **preserve temperature setpoints** (plateau temperatures, lid, etc.). The config's overshoot and ramp parameters are calibrated for those temperatures. Only **durations** (hold times) and **cycle/repeat counts** are safe to change—they don't affect thermal tuning. Changing target temperatures while keeping the same config can give suboptimal or inconsistent thermal performance. +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 equivalent) +### 3. Set block and lid temperature (preMethod) -**Use when:** You want to hold the block (and lid) at a set temperature without running a full cycling method. ODTC implements this by uploading and running a 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 -# Block to 95°C with default lid (110°C for 96-well, 115°C for 384-well) -await tc.set_block_temperature([95.0]) - -# Custom lid temperature +# 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) - -# Non-blocking -execution = await tc.set_block_temperature([95.0], lid_temperature=110.0, wait=False) -await execution -``` - -ODTC has no direct SetBlockTemperature command; `set_block_temperature()` creates and runs a PreMethod internally. Estimated duration for this path is 10 minutes (see Connection and Setup). - -## Running Commands - -### Synchronous Commands - -Some commands are synchronous and return immediately: - -```python -# Get device status -status = await tc.get_status() # Returns "idle", "busy", "standby", etc. - -# Get device identification -device_info = await tc.get_device_identification() -``` - -### Asynchronous Commands - -Most ODTC commands are asynchronous and support both blocking and non-blocking execution: - -#### Blocking Execution (Default) - -```python -# Block until command completes -await tc.open_lid() # Returns None when complete -await tc.close_lid() -await tc.initialize() -await tc.reset() -``` - -#### Non-Blocking Execution with Handle - -```python -# Start command and get execution handle -door_opening = await tc.open_lid(wait=False) -# Returns CommandExecution handle immediately - -# Do other work while command runs -temps = await tc.read_temperatures() # Can run in parallel if allowed - -# Wait for completion -await door_opening # Await the handle directly -# OR -await door_opening.wait() # Explicit wait method -``` - -#### CommandExecution Handle - -- **`request_id`**, **`command_name`**, **`estimated_remaining_time`** (seconds; from our computed estimate when available) -- **Awaitable** (`await handle`) and **`wait()`** -- **`get_data_events()`** — DataEvents for this execution - -```python -# Non-blocking door operation -door_opening = await tc.open_lid(wait=False) - -# Get DataEvents for this execution -events = await door_opening.get_data_events() - -# Wait for completion -await door_opening -``` - -### Method Execution - -- **Blocking:** `await tc.run_stored_protocol("PCR_30cycles")` or `await tc.run_protocol(protocol, block_max_volume=50.0)` (upload + execute). -- **Non-blocking:** `execution = await tc.run_stored_protocol("PCR_30cycles", wait=False)`; then `await execution` or `await execution.wait()` or `await tc.wait_for_method_completion()`. -- While a method runs you can call `read_temperatures()`, `open_lid(wait=False)`, etc. (parallel where allowed). - -#### MethodExecution Handle - -Extends `CommandExecution` with **`method_name`**, **`is_running()`** (device busy state), **`stop()`** (StopMethod). - -```python -execution = await tc.run_stored_protocol("PCR_30cycles", wait=False) - -# Check status -if await execution.is_running(): - print(f"Method {execution.method_name} still running (ID: {execution.request_id})") - -# Get DataEvents for this execution -events = await execution.get_data_events() - -# Wait for completion -await execution -``` - -### State Checking - -```python -# Check if method is running -is_running = await tc.is_method_running() # Returns True if state is "busy" - -# Wait for method completion with polling -await tc.wait_for_method_completion( - poll_interval=5.0, # Check every 5 seconds - timeout=3600.0 # Timeout after 1 hour -) -``` - -### Temperature Control - -See **Recommended Workflows → Set block and lid temperature** for the main usage. Summary: `await tc.set_block_temperature([temp])` or with `lid_temperature=..., wait=False`. ODTC implements this via a PreMethod (no direct SetBlockTemperature command); default lid is 110°C (96-well) or 115°C (384-well). - -### Parallel Operations - -Per ODTC SiLA spec, certain commands can run in parallel with `ExecuteMethod`: - -- ✅ `ReadActualTemperature` - Read temperatures during execution -- ✅ `OpenDoor` / `CloseDoor` - Door operations -- ✅ `StopMethod` - Stop current method -- ❌ `SetParameters` / `GetParameters` - Sequential -- ❌ `GetLastData` - Sequential -- ❌ Another `ExecuteMethod` - Only one method at a time - -```python -# Start method -execution = await tc.run_stored_protocol("PCR_30cycles", wait=False) - -# These can run in parallel: -temps = await tc.read_temperatures() -door_opening = await tc.open_lid(wait=False) - -# Wait for lid to complete -await door_opening - -# These will queue/wait: -method2 = await tc.run_stored_protocol("PCR_40cycles", wait=False) # Waits for method1 -``` - -### CommandExecution vs MethodExecution - -- **`CommandExecution`**: Base class for all async commands (door operations, initialize, reset, etc.) -- **`MethodExecution`**: Subclass of `CommandExecution` for method execution with additional features: - - `is_running()`: Checks if device is in "busy" state - - `stop()`: Stops the currently running method - - `method_name`: More semantic than `command_name` for methods - -```python -# CommandExecution example -door_opening = await tc.open_lid(wait=False) -await door_opening # Wait for lid to open - -# MethodExecution example (has additional features) -method_exec = await tc.run_stored_protocol("PCR_30cycles", wait=False) -if await method_exec.is_running(): - print(f"Method {method_exec.method_name} is running") - await method_exec.stop() # Stop the method -``` - -## Getting Protocols from Device - -### List All Protocol Names (Recommended) - -```python -# List all protocol names (ProtocolList: .methods, .premethods, .all, and iterable) -protocol_list = await tc.backend.list_protocols() - -for name in protocol_list: - print(f"Protocol: {name}") -# Or: protocol_list.all for flat list; protocol_list.methods / protocol_list.premethods for split -``` - -### List Methods and PreMethods Separately - -```python -# Returns (method_names, premethod_names); methods are runnable, premethods are setup-only -methods, premethods = await tc.backend.list_methods() -# methods + premethods equals protocol_list.all (from list_protocols()) +# Block on call: await tc.set_block_temperature([95.0], wait=True) ``` -### Get Runnable Protocol by Name +Estimated duration for this path is 10 minutes. -```python -# get_protocol(name) returns Optional[ODTCProtocol] (None for premethods or missing name) -odtc = await tc.backend.get_protocol("PCR_30cycles") -if odtc is not None: - print(f"Method: {odtc.name}, steps: {len(odtc.steps)}") - # To edit and re-upload: protocol, config = odtc_method_to_protocol(odtc) - # To get Protocol view only: protocol, _ = odtc_protocol_to_protocol(odtc) -``` +### 4. Custom run (Protocol + generic ODTCConfig) (secondary) -### Get Full MethodSet (Advanced) - -```python -# Download all methods and premethods from device -method_set = await tc.backend.get_method_set() # Returns ODTCMethodSet - -# Access methods -for method in method_set.methods: - print(f"Method: {method.name}, Steps: {len(method.steps)}") - -for premethod in method_set.premethods: - print(f"PreMethod: {premethod.name}") -``` - -### Inspect Stored Protocol - -```python -from pylabrobot.thermocycling.inheco.odtc_model import odtc_protocol_to_protocol - -odtc = await tc.backend.get_protocol("PCR_30cycles") -if odtc is not None: - protocol, _ = odtc_protocol_to_protocol(odtc) # Protocol view (stages derived from steps) - print(odtc) # Human-readable summary (name, steps, method-level fields) - await tc.run_protocol(odtc, block_max_volume=50.0) # backend accepts ODTCProtocol -``` - -### Display and logging - -- **ODTCProtocol** and **ODTCSensorValues**: `print(odtc)` and `print(await tc.backend.read_temperatures())` show labeled summaries. ODTCSensorValues `__str__` is multi-line for display; use `format_compact()` for single-line logs. -- **Wait messages**: When you `await handle`, `handle.wait()`, or `handle.wait_resumable()`, the message logged at INFO is multi-line (command, duration, remaining time) for clear console/notebook display. - -## Running Protocols (reference) - -- **By name:** See **Recommended Workflows → Run stored protocol by name**. `await tc.run_stored_protocol(name)` or `wait=False` for a handle. -- **Round-trip (modify with thermal performance):** See **Recommended Workflows → Get → modify → upload with config → run**. -- **Block + lid temp:** See **Recommended Workflows → Set block and lid temperature**. -- **In-memory (new protocol):** `await tc.run_protocol(protocol, block_max_volume=50.0)` (upload + execute). New protocols use default overshoot; for best thermal performance, prefer round-trip from an existing device protocol. -- **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")`. - -## ODTCProtocol and Protocol + ODTCConfig Conversion - -### Lossless Round-Trip - -Conversion between ODTC (device/XML) and PyLabRobot's generic `Protocol` is **lossless** when you keep the `ODTCConfig` returned by `odtc_method_to_protocol(odtc)`. The config preserves method-level and per-step ODTC parameters (overshoot, slopes, PID, etc.). - -### How It Works - -#### 1. ODTCProtocol → Protocol + ODTCConfig - -```python -from pylabrobot.thermocycling.inheco.odtc_model import odtc_method_to_protocol - -# get_protocol(name) returns Optional[ODTCProtocol]; then convert for editing -odtc = await tc.backend.get_protocol("PCR_30cycles") -if odtc is None: - raise ValueError("Protocol not found") -protocol, config = odtc_method_to_protocol(odtc) -``` - -**What gets preserved in `ODTCConfig`:** - -- **Method-level parameters:** - - `name`, `creator`, `description`, `datetime` - - `fluid_quantity`, `variant`, `plate_type` - - `lid_temperature`, `start_lid_temperature` - - `post_heating` - - `pid_set` (PID controller parameters) - -- **Per-step parameters** (stored in `config.step_settings[step_index]`): - - `slope` - Temperature ramp rate (°C/s) - - `overshoot_slope1` - First overshoot ramp rate - - `overshoot_temperature` - Overshoot target temperature - - `overshoot_time` - Overshoot hold time - - `overshoot_slope2` - Second overshoot ramp rate - - `lid_temp` - Lid temperature for this step - - `pid_number` - PID controller to use - -**What goes into `Protocol`:** - -- Temperature targets (from `plateau_temperature`) -- Hold times (from `plateau_time`) -- Stage structure (from loop analysis) -- Repeat counts (from `loop_number`) - -#### 2. Protocol + ODTCConfig → ODTCProtocol +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 -# Convert back to ODTC (lossless if config preserved) +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) -# Then: await tc.backend.upload_protocol(protocol, name="...", config=config) +await tc.run_protocol(odtc, block_max_volume=50.0) ``` -The conversion uses: -- `Protocol` for temperature/time and stage structure -- `ODTCConfig.step_settings` for per-step overtemp parameters -- `ODTCConfig` for method-level parameters +### 5. From XML file -### Overtemp/Overshoot Parameter Preservation +`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")`. -**Overtemp parameters** (overshoot settings) are ODTC-specific features that allow temperature overshooting for faster heating and improved thermal performance: +## Types and conversion -- **`overshoot_temperature`**: Target temperature to overshoot to -- **`overshoot_time`**: How long to hold at overshoot temperature -- **`overshoot_slope1`**: Ramp rate to overshoot temperature -- **`overshoot_slope2`**: Ramp rate back to target temperature +### ODTC types -These parameters are **not part of the generic Protocol** (which only has target temperature and hold time), so they are preserved in `ODTCConfig.step_settings`. +| 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**. | -**Why preservation matters:** +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. -When converting existing ODTC XML protocols to PyLabRobot `Protocol` format, **preserving overshoot parameters is critical for maintaining equivalent thermal performance**. Without these parameters, the converted protocol may have different heating characteristics, potentially affecting PCR efficiency or other temperature-sensitive reactions. +### Protocol + ODTCConfig (custom runs only) -**Current behavior:** -- ✅ **Preserved from XML**: When converting ODTC XML → Protocol+Config, all overshoot parameters are captured in `ODTCConfig.step_settings` -- ✅ **Restored to XML**: When converting Protocol+Config → ODTC XML, overshoot parameters are restored from `ODTCConfig.step_settings` -- ⚠️ **Not generated**: When creating new protocols in PyLabRobot, overshoot parameters default to minimal values (0.0 for temperature/time, 0.1 for slopes) +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`. -**Future work:** -- 🔮 **Automatic derivation**: Future enhancements will automatically derive optimal overshoot parameters for PyLabRobot-created protocols based on: - - Temperature transitions (large jumps benefit more from overshoot) - - Hardware constraints (variant-specific limits) - - Thermal characteristics (fluid quantity, plate type) -- 🔮 **Performance optimization**: This will enable PyLabRobot-created protocols to achieve equivalent or improved thermal performance compared to manually-tuned ODTC protocols +**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. -**Example of preservation:** +**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. -```python -# When converting ODTCProtocol → Protocol + ODTCConfig -odtc = await tc.backend.get_protocol("PCR_30cycles") -if odtc is None: - raise ValueError("Protocol not found") -protocol, config = odtc_method_to_protocol(odtc) +**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)`. -# Overtemp params stored per step (preserved from original XML) -step_0_overtemp = config.step_settings[0] -print(step_0_overtemp.overshoot_temperature) # e.g., 100.0 (from original XML) -print(step_0_overtemp.overshoot_time) # e.g., 5.0 (from original XML) +**Method name:** Device identifies protocols by string (e.g. `"PCR_30cycles"`, `"plr_currentProtocol"`). Use with `run_stored_protocol(name)`, `get_protocol(name)`, `list_protocols()`. -# When converting back Protocol + ODTCConfig → ODTCProtocol -odtc_restored = protocol_to_odtc_protocol(protocol, config=config) -assert odtc_restored.steps[0].overshoot_temperature == 100.0 -``` +**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)`. -**Important:** Always preserve the `ODTCConfig` when modifying protocols converted from ODTC XML to maintain equivalent thermal performance. If you create a new protocol without a config, overshoot parameters will use defaults which may result in slower heating. +## Commands and execution -### Example: Round-Trip Conversion +**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. -```python -from pylabrobot.thermocycling.inheco.odtc_model import odtc_method_to_protocol, protocol_to_odtc_protocol +**Synchronous** (no wait parameter; complete before returning): **setup()**, **get_status()**, **get_device_identification()**, **read_temperatures()**, **list_protocols()**, **get_protocol()**, and other informational calls. -# 1. Get ODTCProtocol from device; convert to Protocol + ODTCConfig for editing -odtc = await tc.backend.get_protocol("PCR_30cycles") -if odtc is None: - raise ValueError("Protocol not found") -protocol, config = odtc_method_to_protocol(odtc) +**Lid/door:** **open_lid** and **close_lid** default to **wait=True** (block); pass **wait=False** to get a handle. -# 2. Modify protocol (durations, repeats; keep temperatures when reusing config) -protocol.stages[0].repeats = 35 +**Example:** -# 3. Upload (backend calls protocol_to_odtc_protocol internally; config preserves ODTC params) -await tc.backend.upload_protocol(protocol, name="PCR_35cycles", config=config) +```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 -# 4. Execute -await tc.run_stored_protocol("PCR_35cycles") +# 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 ``` -### Round-Trip from Device XML +**State:** `await tc.is_profile_running()`. `await tc.wait_for_profile_completion(poll_interval=5.0, timeout=3600.0)`. -```python -from pylabrobot.thermocycling.inheco.odtc_model import odtc_method_to_protocol +**Temperature:** `await tc.set_block_temperature([temp])` or with `lid_temperature=...`; returns handle by default (wait=False). Implemented via PreMethod (no direct SetBlockTemperature). -# Full round-trip: Device → ODTCProtocol → Protocol+ODTCConfig → upload → Device +**Parallel with ExecuteMethod:** ✅ ReadActualTemperature, OpenDoor/CloseDoor, StopMethod. ❌ SetParameters/GetParameters, GetLastData, another ExecuteMethod. -# 1. Get from device -odtc = await tc.backend.get_protocol("PCR_30cycles") -if odtc is None: - raise ValueError("Protocol not found") -protocol, config = odtc_method_to_protocol(odtc) +**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). -# 2. Upload back (preserves all ODTC-specific params via config) -await tc.backend.upload_protocol(protocol, name="PCR_30cycles_restored", config=config) - -# 3. Verify round-trip -odtc_restored = await tc.backend.get_protocol("PCR_30cycles_restored") -# Content should match (XML formatting may differ) -``` +**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()`. -## DataEvent Collection and Progress +## Device protocols -During method execution, the ODTC sends **DataEvent** messages; the backend stores them and derives progress (elapsed time, step/cycle, temperatures). When you **await** an execution handle (`await execution` or `await execution.wait()`), progress is reported every **progress_log_interval** (default 150 s) via log lines or **progress_callback**. Same behavior when using **wait_resumable()** (polling-based wait). +**List:** `protocol_list = await tc.backend.list_protocols()` (ProtocolList: `.methods`, `.premethods`, `.all`). Or `methods, premethods = await tc.backend.list_methods()`. -```python -# Start method -execution = await tc.run_stored_protocol("PCR_30cycles", wait=False) +**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. -# Progress is logged every progress_log_interval (default 150 s) while you await -await execution # or await execution.wait() +**Full MethodSet (advanced):** `method_set = await tc.backend.get_method_set()` → ODTCMethodSet; iterate `method_set.methods` and `method_set.premethods`. -# Get DataEvents for this execution (raw payloads) -events = await execution.get_data_events() -# Returns: List of DataEvent payload dicts +**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. -# Get all collected events (backend-level) -all_events = await tc.backend.get_data_events() -# Returns: {request_id1: [...], request_id2: [...]} -``` +## DataEvents and progress -**Backend option:** `ODTCBackend(..., progress_log_interval=150.0, progress_callback=...)`. Set `progress_log_interval` to `None` or `0` to disable progress reporting during wait. +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. -## Error Handling +**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. -The implementation handles SiLA return codes and state transitions: +**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. -- **Return code 1**: Synchronous success (GetStatus, GetDeviceIdentification) -- **Return code 2**: Asynchronous command accepted (ExecuteMethod, OpenDoor, etc.) -- **Return code 3**: Asynchronous command completed successfully (in ResponseEvent) -- **Return code 4**: Device busy (command rejected due to parallelism) -- **Return code 5**: LockId mismatch -- **Return code 6**: Invalid/duplicate requestId -- **Return code 9**: Command not allowed in current state +**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. -State transitions are tracked automatically: -- `startup` → `standby` (via Reset) -- `standby` → `idle` (via Initialize) -- `idle` → `busy` (when async command starts) -- `busy` → `idle` (when all commands complete) +## Error handling -## Best Practices +**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. -1. **Always call `setup()`** before using the device -2. **Use `wait=False`** for long-running methods to enable parallel operations -3. **Check state** with `is_method_running()` before starting new methods -4. **Preserve `ODTCConfig`** when converting protocols to maintain ODTC-specific parameters (especially overshoot parameters for equivalent thermal performance) -5. **Handle timeouts** when waiting for method completion -6. **Clean up** with `stop()` when done +**State transitions:** `startup` → `standby` (Reset) → `idle` (Initialize) → `busy` (async command) → `idle` (completion). -### Protocol Conversion Best Practices +## Best practices -- **When converting from ODTC XML**: Always preserve the returned `ODTCConfig` alongside the `Protocol` to maintain overshoot parameters and ensure equivalent thermal performance -- **When modifying converted protocols**: Keep the original `ODTCConfig` and only modify the `Protocol` structure (temperatures, times, repeats) -- **When creating new protocols**: Be aware that overshoot parameters will use defaults until automatic derivation is implemented (future work) +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 +## Complete example ```python from pylabrobot.resources import Coordinate from pylabrobot.thermocycling.inheco import ODTCThermocycler -from pylabrobot.thermocycling.inheco.odtc_model import odtc_method_to_protocol +from pylabrobot.thermocycling.inheco.odtc_model import protocol_to_odtc_protocol from pylabrobot.thermocycling.standard import Protocol, Stage, Step tc = ODTCThermocycler(name="odtc", odtc_ip="192.168.1.100", variant=96, child_location=Coordinate(0, 0, 0)) await tc.setup() -# Get ODTCProtocol from device; convert to Protocol + ODTCConfig; modify; upload; run +# 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: - protocol, config = odtc_method_to_protocol(odtc) - protocol.stages[0].repeats = 35 - await tc.backend.upload_protocol(protocol, name="PCR_35cycles", config=config) - execution = await tc.run_stored_protocol("PCR_35cycles", wait=False) + odtc.steps[1].plateau_time = 45.0 + execution = await tc.run_protocol(odtc, block_max_volume=50.0) await execution -# New protocol (generic Protocol) and run (backend converts via protocol_to_odtc_protocol) +# 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), @@ -615,7 +237,8 @@ protocol = Protocol(stages=[ Step(temperature=[72.0], hold_seconds=60.0), ], repeats=30) ]) -await tc.run_protocol(protocol, block_max_volume=50.0) +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 4a807f3ab75..76ce50cda53 100644 --- a/pylabrobot/thermocycling/inheco/__init__.py +++ b/pylabrobot/thermocycling/inheco/__init__.py @@ -26,15 +26,21 @@ stored-by-name (ODTC only). """ -from .odtc_backend import CommandExecution, MethodExecution, ODTCBackend -from .odtc_model import ODTC_DIMENSIONS, ODTCProtocol, ProtocolList, normalize_variant +from .odtc_backend import ODTCBackend, ODTCExecution +from .odtc_model import ODTC_DIMENSIONS, ODTCProgress, ODTCProtocol, ProtocolList, normalize_variant from .odtc_thermocycler import ODTCThermocycler +# Backward-compat aliases (single execution handle type) +CommandExecution = ODTCExecution +MethodExecution = ODTCExecution + __all__ = [ "CommandExecution", "MethodExecution", "ODTCBackend", "ODTC_DIMENSIONS", + "ODTCExecution", + "ODTCProgress", "ODTCProtocol", "ODTCThermocycler", "ProtocolList", diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index 3dcbc3d099b..42d5688fa0c 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -6,13 +6,16 @@ import logging import xml.etree.ElementTree as ET from dataclasses import dataclass, replace -from typing import Any, Awaitable, Callable, Dict, List, Literal, NamedTuple, Optional, Tuple, Union +from enum import Enum +from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Tuple, Union from pylabrobot.thermocycling.backend import ThermocyclerBackend from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol from .odtc_sila_interface import ( + DEFAULT_FIRST_EVENT_TIMEOUT_SECONDS, DEFAULT_LIFETIME_OF_EXECUTION, + FirstEventType, ODTCSiLAInterface, POLLING_START_BUFFER, SiLAState, @@ -20,12 +23,12 @@ from .odtc_model import ( ODTCConfig, ODTCMethodSet, - ODTCDataEventSnapshot, + ODTCProgress, ODTCProtocol, ODTCSensorValues, ODTCHardwareConstraints, - ProtocolList, PREMETHOD_ESTIMATED_DURATION_SECONDS, + ProtocolList, estimate_odtc_protocol_duration_seconds, generate_odtc_timestamp, get_constraints, @@ -35,18 +38,31 @@ method_set_to_xml, normalize_variant, odtc_protocol_to_protocol, - parse_data_event_payload, parse_method_set, parse_method_set_file, parse_sensor_values, protocol_to_odtc_protocol, resolve_protocol_name, + validate_volume_fluid_quantity, + volume_to_fluid_quantity, ) -# Buffer (seconds) added to estimated duration for timeout cap (fail faster than full lifetime). +# Buffer (seconds) added to device remaining duration (ExecuteMethod) or first_event_timeout (status commands) for timeout cap (fail faster than full lifetime). LIFETIME_BUFFER_SECONDS: float = 60.0 +class ODTCCommand(str, Enum): + """SiLA async command identifier for execute().""" + INITIALIZE = "Initialize" + RESET = "Reset" + LOCK_DEVICE = "LockDevice" + UNLOCK_DEVICE = "UnlockDevice" + OPEN_DOOR = "OpenDoor" + CLOSE_DOOR = "CloseDoor" + STOP_METHOD = "StopMethod" + EXECUTE_METHOD = "ExecuteMethod" + + # ============================================================================= # SiLA Response Normalization (single abstraction for dict or ET responses) # ============================================================================= @@ -196,172 +212,32 @@ def raw(self) -> Union[Dict[str, Any], ET.Element]: return {} -class ProtocolProgress(NamedTuple): - """Position in a protocol at a given elapsed time (ODTC-specific). All indices zero-based.""" - - current_cycle_index: int - current_step_index: int - total_cycle_count: int - total_step_count: int - remaining_hold_s: float - - -class ODTCProgress(NamedTuple): - """Full progress for one request: protocol position plus optional temps (for logging/callback).""" - - elapsed_s: float - current_cycle_index: int - current_step_index: int - total_cycle_count: int - total_step_count: int - remaining_hold_s: float - target_temp_c: Optional[float] - current_temp_c: Optional[float] - lid_temp_c: Optional[float] - - -def _protocol_progress(protocol: Protocol, elapsed_s: float) -> ProtocolProgress: - """Walk protocol (stages → repeats → steps) and return position at elapsed_s. - - Uses hold_seconds only. If elapsed_s <= 0 returns first step; if beyond end - returns last step with remaining_hold_s = 0. - """ - if not protocol.stages: - return ProtocolProgress(0, 0, 0, 0, 0.0) - t = 0.0 - last_progress = ProtocolProgress(0, 0, 0, 0, 0.0) - for _stage_idx, stage in enumerate(protocol.stages): - total_cycles = stage.repeats - total_steps = len(stage.steps) - if total_steps == 0: - continue - for cycle_idx in range(stage.repeats): - for step_idx, step in enumerate(stage.steps): - segment_start = t - t += step.hold_seconds - if elapsed_s < t: - remaining = min( - step.hold_seconds, - max(0.0, step.hold_seconds - (elapsed_s - segment_start)), - ) - return ProtocolProgress( - current_cycle_index=cycle_idx, - current_step_index=step_idx, - total_cycle_count=total_cycles, - total_step_count=total_steps, - remaining_hold_s=remaining, - ) - last_progress = ProtocolProgress( - current_cycle_index=cycle_idx, - current_step_index=step_idx, - total_cycle_count=total_cycles, - total_step_count=total_steps, - remaining_hold_s=0.0, - ) - return last_progress._replace(remaining_hold_s=0.0) - - -def _volume_to_fluid_quantity(volume_ul: float) -> int: - """Map volume in µL to ODTC fluid_quantity code. - - Args: - volume_ul: Volume in microliters. - - Returns: - fluid_quantity code: 0 (10-29ul), 1 (30-74ul), or 2 (75-100ul). - - Raises: - ValueError: If volume > 100 µL. - """ - if volume_ul > 100: - raise ValueError( - f"Volume {volume_ul} µL exceeds ODTC maximum of 100 µL. " - "Please use a volume between 0-100 µL." - ) - elif volume_ul <= 29: - return 0 # 10-29ul - elif volume_ul <= 74: - return 1 # 30-74ul - else: # 75 <= volume_ul <= 100 - return 2 # 75-100ul - - -def _validate_volume_fluid_quantity( - volume_ul: float, - fluid_quantity: int, - is_premethod: bool = False, - logger: Optional[logging.Logger] = None, -) -> None: - """Validate that volume matches fluid_quantity and warn if mismatch. - - Args: - volume_ul: Volume in microliters. - fluid_quantity: ODTC fluid_quantity code (0, 1, or 2). - is_premethod: If True, suppress warnings for volume=0 (premethods don't need volume). - logger: Logger for warnings (uses module logger if None). - """ - log = logger or logging.getLogger(__name__) - if volume_ul <= 0: - if not is_premethod: - log.warning( - f"block_max_volume={volume_ul} µL is invalid. Using default fluid_quantity=1 (30-74ul). " - "Please provide a valid volume for accurate thermal calibration." - ) - return - - if volume_ul > 100: - raise ValueError( - f"Volume {volume_ul} µL exceeds ODTC maximum of 100 µL. " - "Please use a volume between 0-100 µL." - ) - - expected_fluid_quantity = _volume_to_fluid_quantity(volume_ul) - if fluid_quantity != expected_fluid_quantity: - volume_ranges = { - 0: "10-29 µL", - 1: "30-74 µL", - 2: "75-100 µL", - } - log.warning( - f"Volume mismatch: block_max_volume={volume_ul} µL suggests fluid_quantity={expected_fluid_quantity} " - f"({volume_ranges[expected_fluid_quantity]}), but config has fluid_quantity={fluid_quantity} " - f"({volume_ranges.get(fluid_quantity, 'unknown')}). This may affect thermal calibration accuracy." - ) - - @dataclass -class CommandExecution: - """Handle for an executing async command (SiLA return_code 2). - - Sometimes called a job or task handle in other automation systems. - Returned from async commands when wait=False. Provides: - - Awaitable interface (can be awaited like a Task); ``await handle`` and - ``await handle.wait()`` are equivalent. - - Request ID access for DataEvent tracking - - Command completion waiting - - done, status, estimated_remaining_time, started_at, lifetime for ETA and resumable wait +class ODTCExecution: + """Handle for an executing async command (SiLA return_code 2). Returned when wait=False. + + Provides: awaitable interface, request_id, done/status, wait/wait_resumable, + get_data_events. For ExecuteMethod: method_name, is_running(), stop(). """ request_id: int command_name: str _future: asyncio.Future[Any] backend: "ODTCBackend" - estimated_remaining_time: Optional[float] = None # seconds from device duration - started_at: Optional[float] = None # time.time() when command was sent - lifetime: Optional[float] = None # max wait seconds (for resumable wait) + estimated_remaining_time: Optional[float] = None + started_at: Optional[float] = None + lifetime: Optional[float] = None + method_name: Optional[str] = None # set for ExecuteMethod def __await__(self): - """Make this awaitable; delegates to wait() so progress loop runs during await.""" return self.wait().__await__() @property def done(self) -> bool: - """True if the command has finished (success or error).""" return self._future.done() @property def status(self) -> str: - """'running', 'success', or 'error'.""" if not self._future.done(): return "running" try: @@ -371,57 +247,28 @@ def status(self) -> str: return "error" def _log_wait_info(self) -> None: - """Log command/method name, duration (lifetime), and remaining time (computed at call time). - - Multi-line format for clear display in console/notebook. Includes a timestamp - so log history gives a clear sense of when each wait was logged and what - remaining time was at that moment, without re-querying. - """ import time - - method_name = getattr(self, "method_name", None) - if isinstance(self, MethodExecution) and method_name: - name = f"{method_name} ({self.command_name})" - else: - name = self.command_name - + name = f"{self.method_name} ({self.command_name})" if self.method_name else self.command_name lifetime = self.lifetime if self.lifetime is not None else self.backend._get_effective_lifetime() started_at = self.started_at if self.started_at is not None else time.time() - now = time.time() - elapsed = now - started_at - remaining = max(0.0, lifetime - elapsed) if lifetime is not None else None - - ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now)) - lines = [ - f"[{ts}] Waiting for command", - f" Command: {name}", - f" Duration (timeout): {lifetime}s", - ] + remaining = max(0.0, lifetime - (time.time() - started_at)) if lifetime is not None else None + ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + lines = [f"[{ts}] Waiting for command", f" Command: {name}", f" Duration (timeout): {lifetime}s"] if remaining is not None: lines.append(f" Remaining: {remaining:.0f}s") self.backend.logger.info("\n".join(lines)) async def _is_done(self) -> bool: - """True when the command has completed (Future resolved). Used by progress loop.""" return self._future.done() async def wait(self) -> None: - """Wait for command completion. - - Equivalent to ``await self`` (the handle is awaitable via __await__). - When backend.progress_log_interval is set, reports progress (from latest DataEvent) - every progress_log_interval seconds until the command completes. - """ if not self._future.done(): self._log_wait_info() interval = self.backend.progress_log_interval if interval and interval > 0: task = asyncio.create_task( self.backend._run_progress_loop_until( - self.request_id, - interval, - self._is_done, - self.backend.progress_callback, + self.request_id, interval, self._is_done, self.backend.progress_callback, ) ) try: @@ -436,22 +283,7 @@ async def wait(self) -> None: await self._future async def wait_resumable(self, poll_interval: float = 5.0) -> None: - """Wait for completion using only GetStatus and handle timing (resumable after restart). - - Use when the in-memory Future is not available (e.g. after process restart). - Persist the handle (request_id, started_at, estimated_remaining_time, lifetime), - reconnect the backend, then call this. Uses backend.wait_for_completion_by_time. - Terminal state is 'idle' for most commands. Uses backend progress_log_interval - and progress_callback for progress reporting during wait. - - Args: - poll_interval: Seconds between GetStatus calls. - - Raises: - TimeoutError: If lifetime exceeded before device reached terminal state. - """ import time - self._log_wait_info() started_at = self.started_at if self.started_at is not None else time.time() lifetime = self.lifetime if self.lifetime is not None else self.backend._get_effective_lifetime() @@ -467,43 +299,19 @@ async def wait_resumable(self, poll_interval: float = 5.0) -> None: ) async def get_data_events(self) -> List[Dict[str, Any]]: - """Get DataEvents for this command execution. - - Returns: - List of DataEvent payloads for this request_id. - """ events_dict = await self.backend.get_data_events(self.request_id) return events_dict.get(self.request_id, []) - -@dataclass -class MethodExecution(CommandExecution): - """Handle for an executing method (SiLA ExecuteMethod; method = runnable protocol). - - Returned from execute_method(wait=False). Provides: - - All features from CommandExecution (awaitable, request_id, DataEvents) - - Method-specific status checking - - Method stopping capability (SiLA: StopMethod) - """ - - method_name: str = "" # default required after parent's optional fields - - def __post_init__(self): - """Set command_name to ExecuteMethod for parent class.""" - # Override command_name from parent to be ExecuteMethod - object.__setattr__(self, 'command_name', "ExecuteMethod") - async def is_running(self) -> bool: - """Check if method is still running (checks device busy state). - - Returns: - True if device state is 'busy', False otherwise. - """ + """True if device is busy (only meaningful when command_name == 'ExecuteMethod').""" + if self.command_name != "ExecuteMethod": + return not self._future.done() return await self.backend.is_method_running() async def stop(self) -> None: - """Stop the currently running method.""" - await self.backend.stop_method() + """Stop the running method (no-op unless command_name == 'ExecuteMethod').""" + if self.command_name == "ExecuteMethod": + await self.backend.stop_method() class ODTCBackend(ThermocyclerBackend): @@ -528,7 +336,9 @@ def __init__( lifetime_of_execution: Optional[float] = None, on_response_event_missing: Literal["warn_and_continue", "error"] = "warn_and_continue", progress_log_interval: Optional[float] = 150.0, - progress_callback: Optional[Callable[..., None]] = None, + progress_callback: Optional[Callable[..., None]] = None, + data_event_log_path: Optional[str] = None, + first_event_timeout_seconds: float = DEFAULT_FIRST_EVENT_TIMEOUT_SECONDS, ): """Initialize ODTC backend. @@ -543,14 +353,15 @@ def __init__( lifetime_of_execution: Max seconds to wait for async command completion (SiLA2 deadline). If None, uses 3 hours. Protocol execution is always bounded. on_response_event_missing: When completion is detected via polling but ResponseEvent was not received: "warn_and_continue" (resolve with None, log warning) or "error" (set exception). Default "warn_and_continue". progress_log_interval: Seconds between progress log lines during wait. None or 0 to disable. Default 150.0 (2.5 min); suitable for protocols from minutes to 1–2+ hours. - progress_callback: Optional callback(progress) called each progress_log_interval during wait. + progress_callback: Optional callback(ODTCProgress) called each progress_log_interval during wait. + data_event_log_path: Optional path to append full DataEvent payloads (one JSON line per event) for debugging and API discovery. + first_event_timeout_seconds: Timeout for waiting for first DataEvent (ExecuteMethod) and default lifetime/eta for status-driven commands (e.g. OpenDoor). Default 60 s. """ super().__init__() self._variant = normalize_variant(variant) - self._current_execution: Optional[MethodExecution] = None + self._current_execution: Optional[ODTCExecution] = None self._simulation_mode: bool = False - self._protocol_by_request_id: Dict[int, Protocol] = {} - self._last_snapshot_by_request_id: Dict[int, ODTCDataEventSnapshot] = {} + self._protocol_by_request_id: Dict[int, Union[Protocol, ODTCProtocol]] = {} self.progress_log_interval: Optional[float] = progress_log_interval self.progress_callback: Optional[Callable[..., None]] = progress_callback self._sila = ODTCSiLAInterface( @@ -561,6 +372,8 @@ def __init__( lifetime_of_execution=lifetime_of_execution, on_response_event_missing=on_response_event_missing, ) + self._sila.data_event_log_path = data_event_log_path + self._first_event_timeout_seconds = first_event_timeout_seconds self.logger = logger or logging.getLogger(__name__) @property @@ -568,13 +381,22 @@ def odtc_ip(self) -> str: """IP address of the ODTC device.""" return self._sila._machine_ip + @property + def data_event_log_path(self) -> Optional[str]: + """Path where full DataEvent payloads are appended (one JSON line per event); None to disable.""" + return self._sila.data_event_log_path + + @data_event_log_path.setter + def data_event_log_path(self, path: Optional[str]) -> None: + self._sila.data_event_log_path = path + @property def variant(self) -> int: """ODTC variant code (960000 or 384000).""" return self._variant @property - def current_execution(self) -> Optional[MethodExecution]: + def current_execution(self) -> Optional[ODTCExecution]: """Current method execution handle (set when a method is started with wait=False or wait=True).""" return self._current_execution @@ -588,16 +410,15 @@ def simulation_mode(self) -> bool: """ return self._simulation_mode - def _clear_current_execution_if(self, handle: MethodExecution) -> None: + def _clear_current_execution_if(self, handle: ODTCExecution) -> None: """Clear _current_execution only if it still refers to the given handle.""" if self._current_execution is handle: self._current_execution = None - def _clear_execution_state_for_handle(self, handle: MethodExecution) -> None: - """Clear current execution, protocol, and snapshot cache for this handle.""" + def _clear_execution_state_for_handle(self, handle: ODTCExecution) -> None: + """Clear current execution and protocol cache for this handle.""" self._clear_current_execution_if(handle) self._protocol_by_request_id.pop(handle.request_id, None) - self._last_snapshot_by_request_id.pop(handle.request_id, None) async def setup( self, @@ -708,30 +529,35 @@ async def _run_async_command( self, command_name: str, wait: bool, - execution_class: type, method_name: Optional[str] = None, - estimated_duration_seconds: Optional[float] = None, + estimated_duration_s: Optional[float] = None, **send_kwargs: Any, - ) -> Optional[Union[CommandExecution, MethodExecution]]: + ) -> Optional[ODTCExecution]: """Run an async SiLA command; return None if wait else execution handle.""" if wait: await self._sila.send_command(command_name, **send_kwargs) return None - fut, request_id, eta, started_at = await self._sila.start_command( - command_name, estimated_duration_seconds=estimated_duration_seconds, **send_kwargs + fut, request_id, started_at = await self._sila.start_command( + command_name, **send_kwargs ) effective = self._get_effective_lifetime() - if estimated_duration_seconds is not None and estimated_duration_seconds > 0: - lifetime = min( - estimated_duration_seconds + LIFETIME_BUFFER_SECONDS, - effective, + event_type = self._sila.get_first_event_type_for_command(command_name) + + if event_type == FirstEventType.DATA_EVENT: + first_payload = await self._sila.wait_for_first_event( + request_id, FirstEventType.DATA_EVENT, self._first_event_timeout_seconds ) - else: - lifetime = effective - if execution_class is MethodExecution: - return MethodExecution( + progress = ODTCProgress.from_data_event(first_payload, None) + if estimated_duration_s is not None and estimated_duration_s > 0: + eta = max(0.0, estimated_duration_s - progress.elapsed_s) + lifetime = min(eta + LIFETIME_BUFFER_SECONDS, effective) + else: + eta = effective + lifetime = effective + self._sila.set_estimated_remaining_time(request_id, eta) + return ODTCExecution( request_id=request_id, - command_name="ExecuteMethod", + command_name=command_name, _future=fut, backend=self, estimated_remaining_time=eta, @@ -739,7 +565,14 @@ async def _run_async_command( lifetime=lifetime, method_name=method_name or "", ) - return CommandExecution( + + eta = self._first_event_timeout_seconds + lifetime = min( + self._first_event_timeout_seconds + LIFETIME_BUFFER_SECONDS, + effective, + ) + self._sila.set_estimated_remaining_time(request_id, eta) + return ODTCExecution( request_id=request_id, command_name=command_name, _future=fut, @@ -749,6 +582,93 @@ async def _run_async_command( lifetime=lifetime, ) + async def execute( + self, + command: ODTCCommand, + wait: bool = True, + **kwargs: Any, + ) -> Optional[ODTCExecution]: + """Run an async SiLA command. All commands are fire-and-forget; wait controls whether we block or return a handle. + + Args: + command: ODTCCommand (INITIALIZE, RESET, LOCK_DEVICE, UNLOCK_DEVICE, OPEN_DOOR, CLOSE_DOOR, STOP_METHOD, EXECUTE_METHOD). + wait: If True, block until completion and return None. If False, return execution handle. + **kwargs: Command-specific params. RESET: device_id, event_receiver_uri, simulation_mode. + LOCK_DEVICE: lock_id (required), lock_timeout. EXECUTE_METHOD: method_name (required), priority, protocol. + + Returns: + If wait=True: None. If wait=False: execution handle (awaitable). EXECUTE_METHOD always returns handle (never None). + """ + if command == ODTCCommand.RESET: + self._simulation_mode = kwargs.get("simulation_mode", False) + event_receiver_uri = kwargs.get("event_receiver_uri") + if event_receiver_uri is None: + event_receiver_uri = f"http://{self._sila._client_ip}:{self._sila.bound_port}/" + return await self._run_async_command( + "Reset", wait, + deviceId=kwargs.get("device_id", "ODTC"), + eventReceiverURI=event_receiver_uri, + simulationMode=self._simulation_mode, + ) + if command == ODTCCommand.LOCK_DEVICE: + lock_id = kwargs.get("lock_id") + if lock_id is None: + raise ValueError("lock_id required for LOCK_DEVICE") + params: dict = {"lockId": lock_id, "PMSId": "PyLabRobot"} + if kwargs.get("lock_timeout") is not None: + params["lockTimeout"] = kwargs["lock_timeout"] + return await self._run_async_command("LockDevice", wait, lock_id=lock_id, **params) + if command == ODTCCommand.UNLOCK_DEVICE: + if self._sila._lock_id is None: + raise RuntimeError("Device is not locked") + return await self._run_async_command("UnlockDevice", wait, lock_id=self._sila._lock_id) + if command == ODTCCommand.EXECUTE_METHOD: + method_name = kwargs.get("method_name") + if not method_name: + raise ValueError("method_name required for EXECUTE_METHOD") + self._current_execution = None + params = {"methodName": method_name} + if kwargs.get("priority") is not None: + params["priority"] = kwargs["priority"] + protocol_to_register: Optional[Union[Protocol, ODTCProtocol]] = None + _, premethods = await self.list_methods() + if method_name in premethods: + estimated_duration_s = PREMETHOD_ESTIMATED_DURATION_SECONDS + method_set = await self.get_method_set() + resolved = get_method_by_name(method_set, method_name) + if resolved is not None: + protocol_to_register = resolved + elif kwargs.get("protocol") is not None: + protocol = kwargs["protocol"] + config = self.get_default_config() + odtc = protocol_to_odtc_protocol(protocol, config=config) + estimated_duration_s = estimate_odtc_protocol_duration_seconds(odtc) + protocol_to_register = protocol + else: + fetched = await self.get_protocol(method_name) + if fetched is not None: + estimated_duration_s = estimate_odtc_protocol_duration_seconds(fetched) + protocol_to_register = fetched + else: + estimated_duration_s = self._get_effective_lifetime() + handle = await self._run_async_command( + "ExecuteMethod", False, + method_name=method_name, + estimated_duration_s=estimated_duration_s, + **params, + ) + assert handle is not None + handle._future.add_done_callback( + lambda _: self._clear_execution_state_for_handle(handle) + ) + if protocol_to_register is not None: + self._protocol_by_request_id[handle.request_id] = protocol_to_register + self._current_execution = handle + if wait: + await handle.wait() + return handle + return await self._run_async_command(command.value, wait) + # ============================================================================ # Request + normalized response # ============================================================================ @@ -780,20 +700,9 @@ async def get_status(self) -> str: state = resp.get_value("GetStatusResponse", "state") return str(state) - async def initialize(self, wait: bool = True) -> Optional[CommandExecution]: - """Initialize the device (SiLA command: standby -> idle). - - Call when device is in standby; setup() performs the full lifecycle - including Reset and Initialize. SiLA command: Initialize. - - Args: - wait: If True, block until completion. If False, return an execution - handle (CommandExecution). - - Returns: - If wait=True: None. If wait=False: execution handle (awaitable). - """ - return await self._run_async_command("Initialize", wait, CommandExecution) + async def initialize(self, wait: bool = True) -> Optional[ODTCExecution]: + """Initialize the device (SiLA command: standby -> idle). See execute(ODTCCommand.INITIALIZE).""" + return await self.execute(ODTCCommand.INITIALIZE, wait=wait) async def reset( self, @@ -801,33 +710,14 @@ async def reset( event_receiver_uri: Optional[str] = None, simulation_mode: bool = False, wait: bool = True, - ) -> Optional[CommandExecution]: - """Reset the device (SiLA command: startup -> standby, register event receiver). - - The simulation_mode attribute on this backend is updated to the value passed - here; it reflects the last reset() call and is valid once that Reset has - completed (or immediately if wait=True). - - Args: - device_id: Device identifier (SiLA: deviceId). - event_receiver_uri: Event receiver URI (SiLA: eventReceiverURI; auto-detected if None). - simulation_mode: Enable simulation mode (SiLA: simulationMode). - wait: If True, block until completion. If False, return an execution - handle (CommandExecution). - - Returns: - If wait=True: None. If wait=False: execution handle (awaitable). - """ - self._simulation_mode = simulation_mode - if event_receiver_uri is None: - event_receiver_uri = f"http://{self._sila._client_ip}:{self._sila.bound_port}/" - return await self._run_async_command( - "Reset", - wait, - CommandExecution, - deviceId=device_id, - eventReceiverURI=event_receiver_uri, - simulationMode=simulation_mode, + ) -> Optional[ODTCExecution]: + """Reset the device (SiLA: startup -> standby, register event receiver). See execute(ODTCCommand.RESET).""" + return await self.execute( + ODTCCommand.RESET, + wait=wait, + device_id=device_id, + event_receiver_uri=event_receiver_uri, + simulation_mode=simulation_mode, ) async def get_device_identification(self) -> dict: @@ -844,70 +734,24 @@ async def get_device_identification(self) -> dict: ) return result if isinstance(result, dict) else {} - async def lock_device(self, lock_id: str, lock_timeout: Optional[float] = None, wait: bool = True) -> Optional[CommandExecution]: - """Lock the device for exclusive access (SiLA: LockDevice). - - Args: - lock_id: Unique lock identifier (SiLA: lockId). - lock_timeout: Lock timeout in seconds (optional; SiLA: lockTimeout). - wait: If True, block until completion. If False, return an execution - handle (CommandExecution). - - Returns: - If wait=True: None. If wait=False: execution handle (awaitable). - """ - params: dict = {"lockId": lock_id, "PMSId": "PyLabRobot"} - if lock_timeout is not None: - params["lockTimeout"] = lock_timeout - return await self._run_async_command( - "LockDevice", wait, CommandExecution, lock_id=lock_id, **params + async def lock_device(self, lock_id: str, lock_timeout: Optional[float] = None, wait: bool = True) -> Optional[ODTCExecution]: + """Lock the device for exclusive access (SiLA: LockDevice). See execute(ODTCCommand.LOCK_DEVICE).""" + return await self.execute( + ODTCCommand.LOCK_DEVICE, wait=wait, lock_id=lock_id, lock_timeout=lock_timeout ) - async def unlock_device(self, wait: bool = True) -> Optional[CommandExecution]: - """Unlock the device (SiLA: UnlockDevice). - - Args: - wait: If True, block until completion. If False, return an execution - handle (CommandExecution). - - Returns: - If wait=True: None. If wait=False: execution handle (awaitable). - """ - # Must provide the lockId that was used to lock it - if self._sila._lock_id is None: - raise RuntimeError("Device is not locked") - return await self._run_async_command( - "UnlockDevice", wait, CommandExecution, lock_id=self._sila._lock_id - ) + async def unlock_device(self, wait: bool = True) -> Optional[ODTCExecution]: + """Unlock the device (SiLA: UnlockDevice). See execute(ODTCCommand.UNLOCK_DEVICE).""" + return await self.execute(ODTCCommand.UNLOCK_DEVICE, wait=wait) # Door control commands (SiLA: OpenDoor, CloseDoor; thermocycler: lid) - async def open_door(self, wait: bool = True) -> Optional[CommandExecution]: - """Open the door (thermocycler lid). SiLA: OpenDoor. + async def open_door(self, wait: bool = True) -> Optional[ODTCExecution]: + """Open the door (thermocycler lid). SiLA: OpenDoor. See execute(ODTCCommand.OPEN_DOOR).""" + return await self.execute(ODTCCommand.OPEN_DOOR, wait=wait) - Args: - wait: If True, block until completion. If False, return an execution - handle (CommandExecution). - - Returns: - If wait=True: None. If wait=False: execution handle (awaitable). - """ - return await self._run_async_command( - "OpenDoor", wait, CommandExecution, estimated_duration_seconds=60.0 - ) - - async def close_door(self, wait: bool = True) -> Optional[CommandExecution]: - """Close the door (thermocycler lid). SiLA: CloseDoor. - - Args: - wait: If True, block until completion. If False, return an execution - handle (CommandExecution). - - Returns: - If wait=True: None. If wait=False: execution handle (awaitable). - """ - return await self._run_async_command( - "CloseDoor", wait, CommandExecution, estimated_duration_seconds=60.0 - ) + async def close_door(self, wait: bool = True) -> Optional[ODTCExecution]: + """Close the door (thermocycler lid). SiLA: CloseDoor. See execute(ODTCCommand.CLOSE_DOOR).""" + return await self.execute(ODTCCommand.CLOSE_DOOR, wait=wait) async def read_temperatures(self) -> ODTCSensorValues: @@ -937,61 +781,24 @@ async def execute_method( method_name: str, priority: Optional[int] = None, wait: bool = False, - estimated_duration_seconds: Optional[float] = None, protocol: Optional[Protocol] = None, - ) -> MethodExecution: - """Execute a method or premethod by name (SiLA: ExecuteMethod; methodName). - - In ODTC/SiLA, a method is a runnable protocol (thermocycling program). - Always starts the method and returns an execution handle; wait only - controls whether we await completion before returning. - - Args: - method_name: Name of the method or premethod to execute (SiLA: methodName). - priority: Priority (SiLA spec; not used by ODTC). - wait: If False (default), return handle immediately. If True, block until - completion then return the (completed) handle. - estimated_duration_seconds: Optional estimated duration in seconds (used for - polling timing and timeout; not sent to device). - protocol: Optional Protocol to associate with this run (for progress/get_*). - - Returns: - MethodExecution handle (completed if wait=True). - """ - self._current_execution = None - params: dict = {"methodName": method_name} - if priority is not None: - params["priority"] = priority - handle = await self._run_async_command( - "ExecuteMethod", - False, - MethodExecution, + ) -> ODTCExecution: + """Execute a method or premethod by name (SiLA: ExecuteMethod). See execute(ODTCCommand.EXECUTE_METHOD).""" + result = await self.execute( + ODTCCommand.EXECUTE_METHOD, + wait=wait, method_name=method_name, - estimated_duration_seconds=estimated_duration_seconds, - **params, - ) - assert handle is not None and isinstance(handle, MethodExecution) - handle._future.add_done_callback( - lambda _: self._clear_execution_state_for_handle(handle) + priority=priority, + protocol=protocol, ) - if protocol is not None: - self._protocol_by_request_id[handle.request_id] = protocol - self._current_execution = handle - if wait: - await handle.wait() - return handle - - async def stop_method(self, wait: bool = True) -> Optional[CommandExecution]: - """Stop the currently running method (SiLA: StopMethod). + assert result is not None + return result - Args: - wait: If True, block until completion. If False, return an execution - handle (CommandExecution). + async def stop_method(self, wait: bool = True) -> Optional[ODTCExecution]: + """Stop the currently running method (SiLA: StopMethod). See execute(ODTCCommand.STOP_METHOD).""" + return await self.execute(ODTCCommand.STOP_METHOD, wait=wait) - Returns: - If wait=True: None. If wait=False: execution handle (awaitable). - """ - return await self._run_async_command("StopMethod", wait, CommandExecution) + # --- Method running and completion --- async def is_method_running(self) -> bool: """Check if a method is currently running. @@ -1210,6 +1017,8 @@ def get_constraints(self) -> ODTCHardwareConstraints: """ return get_constraints(self._variant) + # --- Protocol upload and run --- + async def _upload_odtc_protocol( self, odtc: ODTCProtocol, @@ -1218,14 +1027,14 @@ async def _upload_odtc_protocol( wait: bool = True, debug_xml: bool = False, xml_output_path: Optional[str] = None, - ) -> Optional[MethodExecution]: + ) -> Optional[ODTCExecution]: """Upload a single ODTCProtocol (method or premethod) and optionally execute. Internal single entrypoint: builds one-item ODTCMethodSet and calls upload_method_set, then execute_method if execute=True. Returns: - MethodExecution if execute=True and wait=False; None otherwise. + ODTCExecution if execute=True and wait=False; None otherwise. """ resolved_name = resolve_protocol_name(odtc.name) is_scratch = not odtc.name or odtc.name == "" @@ -1254,10 +1063,7 @@ async def _upload_odtc_protocol( ) if execute: - eta = estimate_odtc_protocol_duration_seconds(odtc_copy) - handle = await self.execute_method( - resolved_name, wait=wait, estimated_duration_seconds=eta - ) + handle = await self.execute_method(resolved_name, wait=wait) protocol_view = odtc_protocol_to_protocol(odtc_copy)[0] self._protocol_by_request_id[handle.request_id] = protocol_view return handle @@ -1300,12 +1106,12 @@ async def upload_protocol( else: if config is None: if block_max_volume is not None and block_max_volume > 0 and block_max_volume <= 100: - fluid_quantity = _volume_to_fluid_quantity(block_max_volume) + fluid_quantity = volume_to_fluid_quantity(block_max_volume) config = self.get_default_config(fluid_quantity=fluid_quantity) else: config = self.get_default_config() elif block_max_volume is not None and block_max_volume > 0: - _validate_volume_fluid_quantity( + validate_volume_fluid_quantity( block_max_volume, config.fluid_quantity, is_premethod=False, logger=self.logger ) if name is not None: @@ -1321,11 +1127,11 @@ async def upload_protocol( ) return resolve_protocol_name(odtc.name) - async def run_stored_protocol(self, name: str, wait: bool = False, **kwargs) -> MethodExecution: + async def run_stored_protocol(self, name: str, wait: bool = False, **kwargs) -> ODTCExecution: """Execute a stored protocol by name (single SiLA ExecuteMethod call). No fetch or round-trip; calls the instrument execute-by-name directly. - Resolves estimated duration from stored method/premethod when available. + Handle lifetime/ETA are event-driven (first DataEvent). Args: name: Name of the stored protocol (method) to run. @@ -1334,18 +1140,12 @@ async def run_stored_protocol(self, name: str, wait: bool = False, **kwargs) -> **kwargs: Ignored (for API compatibility with base backend). Returns: - MethodExecution handle (completed if wait=True). + Execution handle (completed if wait=True). """ method_set = await self.get_method_set() resolved = get_method_by_name(method_set, name) - eta = estimate_odtc_protocol_duration_seconds(resolved) if resolved else None protocol_view = odtc_protocol_to_protocol(resolved)[0] if resolved else None - return await self.execute_method( - name, - wait=wait, - estimated_duration_seconds=eta, - protocol=protocol_view, - ) + return await self.execute_method(name, wait=wait, protocol=protocol_view) async def upload_method_set( self, @@ -1495,13 +1295,13 @@ async def set_block_temperature( Args: temperature: Target block temperature(s) in °C (ODTC single zone: use temperature[0]). lid_temperature: Optional lid temperature in °C. If None, uses hardware max_lid_temp. - wait: If True, block until set. If False (default), return MethodExecution handle. + wait: If True, block until set. If False (default), return execution handle. debug_xml: If True, log generated XML at DEBUG. xml_output_path: Optional path to save MethodSet XML. **kwargs: Ignored (for API compatibility). Returns: - If wait=True: None. If wait=False: MethodExecution handle. + If wait=True: None. If wait=False: execution handle. """ if not temperature: raise ValueError("At least one block temperature required") @@ -1527,15 +1327,10 @@ async def set_block_temperature( debug_xml=debug_xml, xml_output_path=xml_output_path, ) - handle = await self.execute_method( - resolved_name, - wait=wait, - estimated_duration_seconds=PREMETHOD_ESTIMATED_DURATION_SECONDS, - ) - # Register protocol view so progress (DataEvent parsing) is reported every - # progress_log_interval while awaiting the handle (e.g. await mount_handle). - protocol_view = odtc_protocol_to_protocol(odtc)[0] - self._protocol_by_request_id[handle.request_id] = protocol_view + handle = await self.execute_method(resolved_name, wait=wait) + # Register ODTCProtocol so progress shows correct target_block_temperature + # (device DataEvent often reports current setpoint during premethod ramp, not final target). + self._protocol_by_request_id[handle.request_id] = odtc return handle async def set_lid_temperature(self, temperature: List[float]) -> None: @@ -1570,12 +1365,12 @@ async def run_protocol( protocol: Union[Protocol, ODTCProtocol], block_max_volume: float, **kwargs: Any, - ) -> MethodExecution: + ) -> ODTCExecution: """Execute thermocycler protocol (convert if needed, upload, execute). Accepts Protocol or ODTCProtocol. Converts Protocol to ODTCProtocol when needed, uploads, then executes by name. Always returns immediately with a - MethodExecution handle; to block until completion, await handle.wait() or + Execution handle; to block until completion, await handle.wait() or use wait_for_profile_completion(). Config is derived from block_max_volume and backend variant when protocol is Protocol and config is not provided. @@ -1587,7 +1382,7 @@ async def run_protocol( optional); used only when protocol is Protocol. Returns: - MethodExecution handle. Caller can await handle.wait() or + Execution handle. Caller can await handle.wait() or wait_for_profile_completion() to block until done. """ if isinstance(protocol, ODTCProtocol): @@ -1598,31 +1393,30 @@ async def run_protocol( config = kwargs.pop("config", None) if config is None: if block_max_volume > 0 and block_max_volume <= 100: - fluid_quantity = _volume_to_fluid_quantity(block_max_volume) + fluid_quantity = volume_to_fluid_quantity(block_max_volume) config = self.get_default_config(fluid_quantity=fluid_quantity) else: config = self.get_default_config() if block_max_volume > 0 and block_max_volume <= 100: - _validate_volume_fluid_quantity( + validate_volume_fluid_quantity( block_max_volume, config.fluid_quantity, is_premethod=False, logger=self.logger ) else: if block_max_volume > 0: - _validate_volume_fluid_quantity( + validate_volume_fluid_quantity( block_max_volume, config.fluid_quantity, is_premethod=False, logger=self.logger ) odtc = protocol_to_odtc_protocol(protocol, config=config) await self._upload_odtc_protocol(odtc, allow_overwrite=True, execute=False) resolved_name = resolve_protocol_name(odtc.name) - eta = estimate_odtc_protocol_duration_seconds(odtc) - handle = await self.execute_method( - resolved_name, wait=False, estimated_duration_seconds=eta - ) + handle = await self.execute_method(resolved_name, wait=False) protocol_view = odtc_protocol_to_protocol(odtc)[0] self._protocol_by_request_id[handle.request_id] = protocol_view return handle + # --- Temperatures and lid/block status --- + async def get_block_current_temperature(self) -> List[float]: """Get current block temperature. @@ -1718,6 +1512,8 @@ async def get_block_status(self) -> BlockStatus: except Exception: return BlockStatus.IDLE + # --- Progress and step/cycle (DataEvent) --- + async def _report_progress_once( self, request_id: int, @@ -1725,16 +1521,10 @@ async def _report_progress_once( ) -> None: """Fetch latest DataEvent for request_id, update snapshot, and log or invoke progress_callback. - Used by wait_for_completion_by_time and by CommandExecution.wait() (background task). + Used by wait_for_completion_by_time and by ODTCExecution.wait() (background task). No-op if _get_progress returns None (e.g. non-method command). callback: Override for this call; if None, uses self.progress_callback. """ - events_dict = await self.get_data_events(request_id) - events = events_dict.get(request_id, []) - if events: - snapshot = parse_data_event_payload(events[-1]) - if snapshot is not None: - self._last_snapshot_by_request_id[request_id] = snapshot progress = await self._get_progress(request_id) if progress is None: return @@ -1745,19 +1535,7 @@ async def _report_progress_once( except Exception: # noqa: S110 pass else: - self.logger.info( - "ODTC progress: elapsed %.0fs, block %.1f°C (target %.1f°C), lid %.1f°C, " - "step %d/%d, cycle %d/%d, hold remaining ~%.0fs", - progress.elapsed_s, - progress.current_temp_c or 0.0, - progress.target_temp_c or 0.0, - progress.lid_temp_c or 0.0, - progress.current_step_index + 1, - progress.total_step_count, - progress.current_cycle_index + 1, - progress.total_cycle_count, - progress.remaining_hold_s, - ) + self.logger.info(progress.format_progress_log_message()) async def _run_progress_loop_until( self, @@ -1776,33 +1554,30 @@ async def _run_progress_loop_until( await self._report_progress_once(request_id, callback=callback) await asyncio.sleep(interval) + def _protocol_total_step_count(self, protocol: Protocol) -> int: + """Total expanded step count from Protocol (for display when device does not send it).""" + return sum(len(stage.steps) * stage.repeats for stage in protocol.stages) + + def _stored_to_odtc_protocol( + self, stored: Union[Protocol, ODTCProtocol] + ) -> Optional[ODTCProtocol]: + """Normalize stored protocol to ODTCProtocol for position lookup.""" + if isinstance(stored, ODTCProtocol): + return stored + if isinstance(stored, Protocol): + return protocol_to_odtc_protocol(stored, self.get_default_config()) + return None + async def _get_progress(self, request_id: int) -> Optional[ODTCProgress]: - """Get progress for a run: protocol position + temps from latest DataEvent. Returns None if no protocol.""" - protocol = self._protocol_by_request_id.get(request_id) - if protocol is None: + """Get progress from latest DataEvent (elapsed, temps, step/cycle/hold). Returns None if no protocol registered.""" + stored = self._protocol_by_request_id.get(request_id) + if stored is None: return None - snapshot = self._last_snapshot_by_request_id.get(request_id) - if snapshot is None: - events_dict = await self.get_data_events(request_id) - events = events_dict.get(request_id, []) - if events: - snapshot = parse_data_event_payload(events[-1]) - if snapshot is not None: - self._last_snapshot_by_request_id[request_id] = snapshot - if snapshot is None: - snapshot = ODTCDataEventSnapshot(elapsed_s=0.0) - proto = _protocol_progress(protocol, snapshot.elapsed_s) - return ODTCProgress( - elapsed_s=snapshot.elapsed_s, - current_cycle_index=proto.current_cycle_index, - current_step_index=proto.current_step_index, - total_cycle_count=proto.total_cycle_count, - total_step_count=proto.total_step_count, - remaining_hold_s=proto.remaining_hold_s, - target_temp_c=snapshot.target_temp_c, - current_temp_c=snapshot.current_temp_c, - lid_temp_c=snapshot.lid_temp_c, - ) + events_dict = await self.get_data_events(request_id) + events = events_dict.get(request_id, []) + payload = events[-1] if events else None + odtc = self._stored_to_odtc_protocol(stored) + return ODTCProgress.from_data_event(payload, odtc=odtc) def _request_id_for_get_progress(self) -> Optional[int]: """Request ID of current execution for get_* methods; None if none or done.""" @@ -1811,6 +1586,17 @@ def _request_id_for_get_progress(self) -> Optional[int]: return None return ex.request_id + async def get_progress_snapshot(self) -> Optional[ODTCProgress]: + """Get progress from the latest DataEvent for the current run (elapsed, temperatures, step/cycle/hold). + + Returns None if no protocol is running. Returns ODTCProgress: elapsed, temperatures, + and step/cycle/hold derived from elapsed time and the run's protocol when registered. + """ + request_id = self._request_id_for_get_progress() + if request_id is None: + return None + return await self._get_progress(request_id) + async def get_hold_time(self) -> float: """Get remaining hold time in seconds for the current step.""" request_id = self._request_id_for_get_progress() @@ -1820,10 +1606,10 @@ async def get_hold_time(self) -> float: ) progress = await self._get_progress(request_id) if progress is None: - raise NotImplementedError( - "ODTC does not report remaining hold time; no protocol associated with this run." + raise RuntimeError( + "No protocol associated with this run; get_hold_time requires a registered protocol." ) - return progress.remaining_hold_s + return progress.remaining_hold_s if progress.remaining_hold_s is not None else 0.0 async def get_current_cycle_index(self) -> int: """Get zero-based current cycle index.""" @@ -1834,10 +1620,10 @@ async def get_current_cycle_index(self) -> int: ) progress = await self._get_progress(request_id) if progress is None: - raise NotImplementedError( - "ODTC does not report current cycle index; no protocol associated with this run." + raise RuntimeError( + "No protocol associated with this run; get_current_cycle_index requires a registered protocol." ) - return progress.current_cycle_index + return progress.current_cycle_index if progress.current_cycle_index is not None else 0 async def get_total_cycle_count(self) -> int: """Get total cycle count for the current stage.""" @@ -1851,7 +1637,7 @@ async def get_total_cycle_count(self) -> int: raise NotImplementedError( "ODTC does not report total cycle count; no protocol associated with this run." ) - return progress.total_cycle_count + return progress.total_cycle_count if progress.total_cycle_count is not None else 0 async def get_current_step_index(self) -> int: """Get zero-based current step index within the cycle.""" @@ -1862,10 +1648,10 @@ async def get_current_step_index(self) -> int: ) progress = await self._get_progress(request_id) if progress is None: - raise NotImplementedError( - "ODTC does not report current step index; no protocol associated with this run." + raise RuntimeError( + "No protocol associated with this run; get_current_step_index requires a registered protocol." ) - return progress.current_step_index + return progress.current_step_index if progress.current_step_index is not None else 0 async def get_total_step_count(self) -> int: """Get total number of steps in the current cycle.""" @@ -1879,4 +1665,4 @@ async def get_total_step_count(self) -> int: raise NotImplementedError( "ODTC does not report total step count; no protocol associated with this run." ) - return progress.total_step_count + 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 index 5d75e7b3f3c..46fbc1aa2eb 100644 --- a/pylabrobot/thermocycling/inheco/odtc_model.py +++ b/pylabrobot/thermocycling/inheco/odtc_model.py @@ -179,6 +179,77 @@ def normalize_variant(variant: int) -> int: ) +# ============================================================================= +# Volume / fluid quantity (ODTC domain rule: µL -> fluid_quantity code) +# ============================================================================= + + +def volume_to_fluid_quantity(volume_ul: float) -> int: + """Map volume in µL to ODTC fluid_quantity code. + + Args: + volume_ul: Volume in microliters. + + Returns: + fluid_quantity code: 0 (10-29ul), 1 (30-74ul), or 2 (75-100ul). + + Raises: + ValueError: If volume > 100 µL. + """ + if volume_ul > 100: + raise ValueError( + f"Volume {volume_ul} µL exceeds ODTC maximum of 100 µL. " + "Please use a volume between 0-100 µL." + ) + if volume_ul <= 29: + return 0 # 10-29ul + if volume_ul <= 74: + return 1 # 30-74ul + return 2 # 75-100ul + + +def validate_volume_fluid_quantity( + volume_ul: float, + fluid_quantity: int, + is_premethod: bool = False, + logger: Optional[logging.Logger] = None, +) -> None: + """Validate that volume matches fluid_quantity and warn if mismatch. + + Args: + volume_ul: Volume in microliters. + fluid_quantity: ODTC fluid_quantity code (0, 1, or 2). + is_premethod: If True, suppress warnings for volume=0 (premethods don't need volume). + logger: Logger for warnings (uses module logger if None). + """ + log = logger if logger is not None else logging.getLogger(__name__) + if volume_ul <= 0: + if not is_premethod: + log.warning( + "block_max_volume=%s µL is invalid. Using default fluid_quantity=1 (30-74ul). " + "Please provide a valid volume for accurate thermal calibration.", + volume_ul, + ) + return + if volume_ul > 100: + raise ValueError( + f"Volume {volume_ul} µL exceeds ODTC maximum of 100 µL. " + "Please use a volume between 0-100 µL." + ) + expected = volume_to_fluid_quantity(volume_ul) + if fluid_quantity != expected: + volume_ranges = {0: "10-29 µL", 1: "30-74 µL", 2: "75-100 µL"} + log.warning( + "Volume mismatch: block_max_volume=%s µL suggests fluid_quantity=%s (%s), " + "but config has fluid_quantity=%s (%s). This may affect thermal calibration accuracy.", + volume_ul, + expected, + volume_ranges[expected], + fluid_quantity, + volume_ranges.get(fluid_quantity, "unknown"), + ) + + # ============================================================================= # XML Field Metadata # ============================================================================= @@ -358,20 +429,10 @@ def format_compact(self) -> str: # ============================================================================= -# DataEvent Snapshots (SiLA DataEvent payload parsing) +# DataEvent payload parsing (private; used only inside ODTCProgress.from_data_event) # ============================================================================= -@dataclass -class ODTCDataEventSnapshot: - """Parsed snapshot from one DataEvent (elapsed time and temperatures).""" - - elapsed_s: float - target_temp_c: Optional[float] = None - current_temp_c: Optional[float] = None - lid_temp_c: Optional[float] = None - - 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") @@ -386,12 +447,14 @@ def _parse_data_event_series_value(series_elem: Any) -> Optional[float]: return None -def parse_data_event_payload(payload: Dict[str, Any]) -> Optional[ODTCDataEventSnapshot]: - """Parse a single DataEvent payload into an ODTCDataEventSnapshot. +def _parse_data_event_payload(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Parse a single DataEvent payload into a dict (elapsed_s, temps, etc.). - Input: dict with 'requestId' and 'dataValue' (string of XML, possibly - double-escaped). Extracts Elapsed time (ms), Target temperature, Current - temperature, LID temperature (1/100°C -> °C). Returns None on parse error. + Used only inside ODTCProgress.from_data_event. Input: dict with 'dataValue' + (string of XML, possibly double-escaped). Returns dict with keys: + elapsed_s, target_temp_c, current_temp_c, lid_temp_c, current_step_index, + total_step_count, current_cycle_index, total_cycle_count, remaining_hold_s. + Returns None on parse error. """ if not isinstance(payload, dict): return None @@ -418,6 +481,11 @@ def parse_data_event_payload(payload: Dict[str, Any]) -> Optional[ODTCDataEventS 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 @@ -434,12 +502,37 @@ def parse_data_event_payload(payload: Dict[str, Any]) -> Optional[ODTCDataEventS current_temp_c = raw / 100.0 elif name_id == "LID temperature" and unit == "1/100°C": lid_temp_c = raw / 100.0 - return ODTCDataEventSnapshot( - elapsed_s=elapsed_s, - target_temp_c=target_temp_c, - current_temp_c=current_temp_c, - lid_temp_c=lid_temp_c, - ) + 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, + } # ============================================================================= @@ -786,7 +879,8 @@ def estimate_odtc_protocol_duration_seconds(odtc: ODTCProtocol) -> float: """Estimate total run duration for an ODTCProtocol. Premethods use PREMETHOD_ESTIMATED_DURATION_SECONDS; methods use - step/loop-based estimation. + step/loop-based estimation. For estimation/tooling only; the ODTC backend + does not use this for handle lifetime/eta (event-driven). Returns: Estimated duration in seconds. @@ -1504,11 +1598,43 @@ def _expand_step_sequence( return expanded +def odtc_expanded_step_count(odtc: ODTCProtocol) -> int: + """Return total step count in execution order (loops expanded). Used for progress display when device does not send it.""" + if not odtc.steps: + return 0 + loops = _analyze_loop_structure(odtc.steps) + return len(_expand_step_sequence(odtc.steps, loops)) + + +def odtc_cycle_count(odtc: ODTCProtocol) -> int: + """Return cycle count from ODTC loop structure (main/top-level loop repeat count). Used for progress when device does not send it.""" + if not odtc.steps: + return 0 + loops = _analyze_loop_structure(odtc.steps) + if not loops: + return 1 + # Top-level loop(s): not contained in any other; take the outermost (largest span) as main cycle count. + top_level = [ + (start, end, count) + for (start, end, count) in loops + if not any( + (s, e, _) != (start, end, count) and s <= start and end <= e + for (s, e, _) in loops + ) + ] + if not top_level: + return 0 + # Single top-level loop (typical PCR) -> its repeat count; else outermost span's repeat count. + main = max(top_level, key=lambda x: x[1] - x[0]) + return main[2] + + def estimate_method_duration_seconds(odtc: ODTCProtocol) -> float: """Estimate total method duration from steps (ramp + plateau + overshoot, with loops). Per ODTC Firmware Command Set: duration is slope time + overshoot time + plateau - time per step in consideration of the loops. + time per step in consideration of the loops. For estimation/tooling only; the ODTC + backend does not use this for handle lifetime/eta (event-driven). Args: odtc: ODTCProtocol (kind='method') with steps and start_block_temperature. @@ -1536,6 +1662,241 @@ def estimate_method_duration_seconds(odtc: ODTCProtocol) -> float: return total +# ============================================================================= +# Protocol position from elapsed time (private; used only inside ODTCProgress.from_data_event) +# ============================================================================= + + +def _build_protocol_timeline(odtc: ODTCProtocol) -> List[Tuple[float, float, int, int, float, float]]: + """Build timeline segments for an ODTCProtocol (method or premethod). + + Returns a list of (t_start, t_end, step_index, cycle_index, setpoint_c, plateau_end_t). + step_index is 0-based within cycle; cycle_index is 0-based. + plateau_end_t is the time at which the plateau (hold) ends for remaining_hold_s. + """ + if odtc.kind == "premethod": + duration = PREMETHOD_ESTIMATED_DURATION_SECONDS + setpoint = odtc.target_block_temperature + return [(0.0, duration, 0, 0, setpoint, duration)] + + if not odtc.steps: + return [] + + loops = _analyze_loop_structure(odtc.steps) + step_nums = _expand_step_sequence(odtc.steps, loops) + steps_by_num = {s.number: s for s in odtc.steps} + total_expanded = len(step_nums) + total_cycles = odtc_cycle_count(odtc) + steps_per_cycle = total_expanded // total_cycles if total_cycles > 0 else max(1, total_expanded) + + segments: List[Tuple[float, float, int, int, float, float]] = [] + t = 0.0 + prev_temp = odtc.start_block_temperature + min_slope = 0.1 + + for flat_index, step_num in enumerate(step_nums): + step = steps_by_num[step_num] + slope = max(abs(step.slope), min_slope) + ramp_time = abs(step.plateau_temperature - prev_temp) / slope + plateau_end_t = t + ramp_time + step.plateau_time + segment_end = t + ramp_time + step.plateau_time + step.overshoot_time + + cycle_index = flat_index // steps_per_cycle + step_index = flat_index % steps_per_cycle + setpoint = step.plateau_temperature + + segments.append((t, segment_end, step_index, cycle_index, setpoint, plateau_end_t)) + t = segment_end + prev_temp = step.plateau_temperature + + return segments + + +def _protocol_position_from_elapsed(odtc: ODTCProtocol, elapsed_s: float) -> Dict[str, Any]: + """Compute protocol position (step, cycle, setpoint, remaining hold) from elapsed time. + + Used only inside ODTCProgress.from_data_event. Returns dict with keys: + step_index, cycle_index, setpoint_c, remaining_hold_s, total_steps, total_cycles. + """ + if elapsed_s < 0: + elapsed_s = 0.0 + + segments = _build_protocol_timeline(odtc) + if not segments: + total_steps = odtc_expanded_step_count(odtc) if odtc.steps else 0 + total_cycles = odtc_cycle_count(odtc) if odtc.steps else 1 + return { + "step_index": 0, + "cycle_index": 0, + "setpoint_c": odtc.start_block_temperature if hasattr(odtc, "start_block_temperature") else None, + "remaining_hold_s": 0.0, + "total_steps": total_steps, + "total_cycles": total_cycles, + } + + if odtc.kind == "method" and odtc.steps: + total_expanded = len(_expand_step_sequence(odtc.steps, _analyze_loop_structure(odtc.steps))) + total_cycles = odtc_cycle_count(odtc) + steps_per_cycle = total_expanded // total_cycles if total_cycles > 0 else total_expanded + else: + steps_per_cycle = 1 + total_cycles = 1 + + for (t_start, t_end, step_index, cycle_index, setpoint_c, plateau_end_t) in segments: + if elapsed_s <= t_end: + remaining = max(0.0, plateau_end_t - elapsed_s) + return { + "step_index": step_index, + "cycle_index": cycle_index, + "setpoint_c": setpoint_c, + "remaining_hold_s": remaining, + "total_steps": steps_per_cycle, + "total_cycles": total_cycles, + } + + (_, _, step_index, cycle_index, setpoint_c, _) = segments[-1] + return { + "step_index": step_index, + "cycle_index": cycle_index, + "setpoint_c": setpoint_c, + "remaining_hold_s": 0.0, + "total_steps": steps_per_cycle, + "total_cycles": total_cycles, + } + + +# ============================================================================= +# ODTCProgress (raw DataEvent payload + optional protocol -> progress for interface) +# ============================================================================= + + +@dataclass +class ODTCProgress: + """Progress for a run: built from raw DataEvent payload and optional ODTCProtocol. + + Single type for all progress/duration. Event-derived: elapsed_s, temps (from + parsing payload). Protocol-derived: step/cycle/setpoint/hold (from timeline lookup). + estimated_duration_s is our protocol-based total; remaining_duration_s is always + max(0, estimated_duration_s - elapsed_s). Device never sends estimated or remaining duration. + Returned from get_progress_snapshot and passed to the progress callback. + str(progress) or format_progress_log_message() gives the standard progress line (same as logged every progress_log_interval). + """ + + elapsed_s: float + target_temp_c: Optional[float] = None + current_temp_c: Optional[float] = None + lid_temp_c: Optional[float] = None + current_step_index: int = 0 + total_step_count: int = 0 + current_cycle_index: int = 0 + total_cycle_count: int = 0 + remaining_hold_s: float = 0.0 + estimated_duration_s: Optional[float] = None + remaining_duration_s: Optional[float] = None + + @classmethod + def from_data_event( + cls, + payload: Optional[Dict[str, Any]], + odtc: Optional[ODTCProtocol] = None, + ) -> "ODTCProgress": + """Build ODTCProgress from raw DataEvent payload and optional protocol. + + payload: Raw DataEvent dict (or None for no events yet). Parsed via _parse_data_event_payload. + odtc: When not None, position (step/cycle/setpoint/hold) and estimated/remaining duration + are computed from protocol; remaining_duration_s = max(0, estimated_duration_s - elapsed_s). + """ + parsed = _parse_data_event_payload(payload) if payload is not None else None + if parsed is None: + elapsed_s = 0.0 + target_temp_c: Optional[float] = None + current_temp_c: Optional[float] = None + lid_temp_c: Optional[float] = None + step_idx = 0 + step_count = 0 + cycle_idx = 0 + cycle_count = 0 + hold_s = 0.0 + else: + elapsed_s = parsed["elapsed_s"] + target_temp_c = parsed.get("target_temp_c") + current_temp_c = parsed.get("current_temp_c") + lid_temp_c = parsed.get("lid_temp_c") + step_idx = parsed["current_step_index"] if parsed.get("current_step_index") is not None else 0 + step_count = parsed["total_step_count"] if parsed.get("total_step_count") is not None else 0 + cycle_idx = parsed["current_cycle_index"] if parsed.get("current_cycle_index") is not None else 0 + cycle_count = parsed["total_cycle_count"] if parsed.get("total_cycle_count") is not None else 0 + hold_s = parsed["remaining_hold_s"] if parsed.get("remaining_hold_s") is not None else 0.0 + + if odtc is None: + est_s: Optional[float] = None + rem_s = 0.0 + return cls( + elapsed_s=elapsed_s, + target_temp_c=target_temp_c, + current_temp_c=current_temp_c, + lid_temp_c=lid_temp_c, + current_step_index=step_idx, + total_step_count=step_count, + current_cycle_index=cycle_idx, + total_cycle_count=cycle_count, + remaining_hold_s=hold_s, + estimated_duration_s=est_s, + remaining_duration_s=rem_s, + ) + + position = _protocol_position_from_elapsed(odtc, elapsed_s) + target = target_temp_c + if odtc.kind == "premethod": + target = odtc.target_block_temperature + elif position.get("setpoint_c") is not None and target is None: + target = position["setpoint_c"] + + if odtc.kind == "premethod": + est_s = PREMETHOD_ESTIMATED_DURATION_SECONDS + else: + est_s = estimate_odtc_protocol_duration_seconds(odtc) + rem_s = max(0.0, est_s - elapsed_s) + + return cls( + elapsed_s=elapsed_s, + target_temp_c=target, + current_temp_c=current_temp_c, + lid_temp_c=lid_temp_c, + current_step_index=position["step_index"], + total_step_count=position.get("total_steps") or 0, + current_cycle_index=position["cycle_index"], + total_cycle_count=position.get("total_cycles") or 0, + remaining_hold_s=position.get("remaining_hold_s") or 0.0, + estimated_duration_s=est_s, + remaining_duration_s=rem_s, + ) + + def format_progress_log_message(self) -> str: + """Return the progress log message (elapsed, step/cycle/setpoint when present, temps).""" + step_total = self.total_step_count + cycle_total = self.total_cycle_count + step_idx = self.current_step_index + cycle_idx = self.current_cycle_index + setpoint = self.target_temp_c if self.target_temp_c is not None else 0.0 + block = self.current_temp_c or 0.0 + lid = self.lid_temp_c or 0.0 + if step_total and cycle_total: + return ( + f"ODTC progress: elapsed {self.elapsed_s:.0f}s, step {step_idx + 1}/{step_total}, " + f"cycle {cycle_idx + 1}/{cycle_total}, setpoint {setpoint:.1f}°C, " + f"block {block:.1f}°C, lid {lid:.1f}°C" + ) + return ( + f"ODTC progress: elapsed {self.elapsed_s:.0f}s, block {block:.1f}°C " + f"(target {setpoint:.1f}°C), lid {lid:.1f}°C" + ) + + def __str__(self) -> str: + """Same as format_progress_log_message(); use for consistent printing and progress reporting.""" + return self.format_progress_log_message() + + def odtc_method_to_protocol(odtc: ODTCProtocol) -> Tuple["Protocol", ODTCConfig]: """Convert an ODTCProtocol (kind='method') to a Protocol with companion config. diff --git a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py index 96e9fd4da6e..2e710de5265 100644 --- a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py +++ b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio +import json import logging import time import urllib.request @@ -23,6 +24,8 @@ from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface from pylabrobot.storage.inheco.scila.soap import soap_decode, soap_encode, XSI +from .odtc_model import ODTCProgress + # ----------------------------------------------------------------------------- # SiLA/ODTC exceptions (typed command and device errors) @@ -30,43 +33,21 @@ class SiLAError(RuntimeError): - """Base exception for SiLA command and device errors.""" - - pass - - -class SiLACommandRejected(SiLAError): - """Command rejected: device busy (return code 4) or not allowed in state (return code 9).""" - - pass - - -class SiLALockIdError(SiLAError): - """LockId mismatch (return code 5).""" - - pass - - -class SiLARequestIdError(SiLAError): - """Invalid or duplicate requestId (return code 6).""" - - pass - + """Base exception for SiLA command and device errors. Use .code for return-code-specific handling (4=busy, 5=lock, 6=requestId, 9=state, 11=parameter, 1000+=device).""" -class SiLAParameterError(SiLAError): - """Invalid command parameter (return code 11).""" - - pass + def __init__(self, msg: str, code: Optional[int] = None) -> None: + super().__init__(msg) + self.code = code -class SiLADeviceError(SiLAError): - """Device-specific error (return codes 1000, 2000, 2001, 2007, etc.).""" +class SiLATimeoutError(SiLAError): + """Command timed out: lifetime_of_execution exceeded or ResponseEvent not received.""" pass -class SiLATimeoutError(SiLAError): - """Command timed out: lifetime_of_execution exceeded or ResponseEvent not received.""" +class FirstEventTimeout(SiLAError): + """No first event received within timeout (e.g. no DataEvent for ExecuteMethod).""" pass @@ -150,10 +131,42 @@ class SiLAState(str, Enum): INERROR = "inError" # Device returns "inError" (camelCase per SCILABackend comment) +class FirstEventType(str, Enum): + """Event type to wait for before handing off an async command handle. + + Per SiLA Device Control & Data Interface Spec and ODTC TD_SILA_FWCommandSet: + - DataEvent: transmission of data during async command execution (has requestId). + Used by ExecuteMethod (methods and premethods) per ODTC firmware. + - StatusEvent: unsolicited device state changes. Used by OpenDoor, CloseDoor, + Initialize, Reset, LockDevice, UnlockDevice, StopMethod (no DataEvent stream). + """ + + DATA_EVENT = "DataEvent" + STATUS_EVENT = "StatusEvent" + + +# Command -> event type to wait for (first event before returning handle). +# Verified against SiLA_Device_Control__Data__Interface_Specification_V1.2.01 and +# TD_SILA_FWCommandSet (ODTC): only ExecuteMethod sends DataEvents; others use StatusEvent. +COMMAND_FIRST_EVENT_TYPE: Dict[str, FirstEventType] = { + "ExecuteMethod": FirstEventType.DATA_EVENT, + "OpenDoor": FirstEventType.STATUS_EVENT, + "CloseDoor": FirstEventType.STATUS_EVENT, + "Initialize": FirstEventType.STATUS_EVENT, + "Reset": FirstEventType.STATUS_EVENT, + "LockDevice": FirstEventType.STATUS_EVENT, + "UnlockDevice": FirstEventType.STATUS_EVENT, + "StopMethod": FirstEventType.STATUS_EVENT, +} + + # Default max wait for async command completion (3 hours). SiLA2-aligned: protocol execution always bounded. DEFAULT_LIFETIME_OF_EXECUTION: float = 10800.0 -# Buffer (seconds) added to estimated_remaining_time before starting polling loop. +# Default timeout for waiting for first DataEvent (ExecuteMethod) and default lifetime/eta for status-driven commands. +DEFAULT_FIRST_EVENT_TIMEOUT_SECONDS: float = 60.0 + +# Delay (seconds) after command start before starting GetStatus polling loop. POLLING_START_BUFFER: float = 10.0 @@ -165,7 +178,6 @@ class PendingCommand: request_id: int fut: asyncio.Future[Any] started_at: float - estimated_remaining_time: Optional[float] = None # Caller-provided estimate (seconds) lock_id: Optional[str] = None # LockId sent with LockDevice command (for tracking) @@ -351,6 +363,10 @@ def __init__( # DataEvent storage by request_id self._data_events_by_request_id: Dict[int, List[Dict[str, Any]]] = {} + # Optional: when set, each received DataEvent payload is appended as one JSON line (for debugging / API discovery) + self.data_event_log_path: Optional[str] = None + # Estimated remaining time (s) per request_id; set by backend after first DataEvent so polling waits until eta+buffer. + self._estimated_remaining_by_request_id: Dict[int, float] = {} def _check_state_allowability(self, command: str) -> bool: """Check if command is allowed in current state. @@ -428,6 +444,46 @@ def _get_terminal_state(self, command: str) -> str: """Return the device state that indicates this async command has finished (for polling fallback).""" return self.ASYNC_COMMAND_TERMINAL_STATE.get(command, self._DEFAULT_TERMINAL_STATE) + def get_first_event_type_for_command(self, command: str) -> FirstEventType: + """Return which event type to wait for before handing off the handle (per SiLA/ODTC docs).""" + normalized = self._normalize_command_name(command) + return COMMAND_FIRST_EVENT_TYPE.get(normalized, FirstEventType.STATUS_EVENT) + + async def wait_for_first_event( + self, + request_id: int, + event_type: FirstEventType, + timeout_seconds: float, + ) -> Optional[Dict[str, Any]]: + """Wait for the first event of the given type for this request_id, or raise on timeout. + + For DATA_EVENT: polls _data_events_by_request_id until at least one event or timeout. + For STATUS_EVENT: returns None immediately (StatusEvent has no requestId per SiLA spec). + + Args: + request_id: SiLA request ID of the command. + event_type: FirstEventType.DATA_EVENT or FirstEventType.STATUS_EVENT. + timeout_seconds: Max seconds to wait (DATA_EVENT only). + + Returns: + First event payload dict (DATA_EVENT) or None (STATUS_EVENT). + + Raises: + FirstEventTimeout: If no DataEvent received within timeout_seconds. + """ + if event_type == FirstEventType.STATUS_EVENT: + return None + started_at = time.time() + while True: + events = self._data_events_by_request_id.get(request_id) or [] + if events: + return events[0] + if time.time() - started_at >= timeout_seconds: + raise FirstEventTimeout( + f"No DataEvent received for request_id {request_id} within {timeout_seconds}s" + ) + await asyncio.sleep(0.2) + def _complete_pending( self, request_id: int, @@ -459,6 +515,7 @@ def _complete_pending( self._logger.info("Device reset (unlocked)") self._pending_by_id.pop(request_id, None) + self._estimated_remaining_by_request_id.pop(request_id, None) self._active_request_ids.discard(request_id) normalized_cmd = self._normalize_command_name(pending.name) self._executing_commands.discard(normalized_cmd) @@ -471,6 +528,19 @@ def _complete_pending( else: pending.fut.set_result(result) + def set_estimated_remaining_time(self, request_id: int, eta_seconds: float) -> None: + """Set device-estimated remaining time for a pending command so polling waits until eta+buffer. + + Call after receiving the first DataEvent (ExecuteMethod) or when eta is known. + The _poll_until_complete task will not start GetStatus polling until + time.time() >= started_at + eta_seconds + POLLING_START_BUFFER. + + Args: + request_id: Pending command request_id. + eta_seconds: Estimated remaining duration in seconds (0 = only wait POLLING_START_BUFFER). + """ + self._estimated_remaining_by_request_id[request_id] = max(0.0, eta_seconds) + def _validate_lock_id(self, lock_id: Optional[str]) -> None: """Validate lockId parameter. @@ -486,9 +556,10 @@ def _validate_lock_id(self, lock_id: Optional[str]) -> None: # Device is locked - must provide matching lockId if lock_id != self._lock_id: - raise SiLALockIdError( + raise SiLAError( f"Device is locked with lockId '{self._lock_id}', " - f"but command provided lockId '{lock_id}'. Return code: 5" + f"but command provided lockId '{lock_id}'. Return code: 5", + code=5, ) def _update_state_from_status(self, state_str: str) -> None: @@ -537,22 +608,18 @@ def _handle_return_code( # Asynchronous command finished (success) - handled in ResponseEvent return elif return_code == 4: - # Device busy - raise SiLACommandRejected(f"Command {command_name} rejected: Device is busy (return code 4)") + raise SiLAError(f"Command {command_name} rejected: Device is busy (return code 4)", code=4) elif return_code == 5: - # LockId error - raise SiLALockIdError(f"Command {command_name} rejected: LockId mismatch (return code 5)") + raise SiLAError(f"Command {command_name} rejected: LockId mismatch (return code 5)", code=5) elif return_code == 6: - # RequestId error - raise SiLARequestIdError(f"Command {command_name} rejected: Invalid or duplicate requestId (return code 6)") + raise SiLAError(f"Command {command_name} rejected: Invalid or duplicate requestId (return code 6)", code=6) elif return_code == 9: - # Command not allowed in this state - raise SiLACommandRejected( - f"Command {command_name} not allowed in state {self._current_state.value} (return code 9)" + raise SiLAError( + f"Command {command_name} not allowed in state {self._current_state.value} (return code 9)", + code=9, ) elif return_code == 11: - # Invalid parameter - raise SiLAParameterError(f"Command {command_name} rejected: Invalid parameter (return code 11): {message}") + raise SiLAError(f"Command {command_name} rejected: Invalid parameter (return code 11): {message}", code=11) elif return_code == 12: # Finished with warning self._logger.warning(f"Command {command_name} finished with warning (return code 12): {message}") @@ -560,10 +627,10 @@ def _handle_return_code( elif return_code >= 1000: # Device-specific return code if return_code in self.DEVICE_ERROR_CODES: - # DeviceError - transition to InError self._current_state = SiLAState.INERROR - raise SiLADeviceError( - f"Command {command_name} failed with device error (return code {return_code}): {message}" + raise SiLAError( + f"Command {command_name} failed with device error (return code {return_code}): {message}", + code=return_code, ) else: # Warning or recoverable error @@ -663,6 +730,24 @@ async def _on_http(self, req: InhecoSiLAInterface._HTTPRequest) -> bytes: f"DataEvent received for requestId {request_id} " f"(total: {len(self._data_events_by_request_id[request_id])})" ) + # One-line summary at DEBUG so default "waiting" shows only backend progress + # (every progress_log_interval, e.g. 150 s). Backend logs use correct target for premethods. + progress = ODTCProgress.from_data_event(data_event, None) + self._logger.debug( + "DataEvent requestId %s: elapsed %.0fs, block %.1f°C, target %.1f°C, lid %.1f°C", + request_id, + progress.elapsed_s, + progress.current_temp_c or 0.0, + progress.target_temp_c or 0.0, + progress.lid_temp_c or 0.0, + ) + + if self.data_event_log_path: + try: + with open(self.data_event_log_path, "a", encoding="utf-8") as f: + f.write(json.dumps(data_event, default=str) + "\n") + except OSError as e: + self._logger.warning("Failed to append DataEvent to %s: %s", self.data_event_log_path, e) return SOAP_RESPONSE_DataEventResponse.encode("utf-8") @@ -698,8 +783,8 @@ async def _execute_command( command: str, lock_id: Optional[str] = None, **kwargs: Any, - ) -> Any | tuple[asyncio.Future[Any], int, Optional[float], float]: - """Execute a SiLA command; return decoded dict (sync) or (fut, request_id, eta, started_at) (async). + ) -> Any | tuple[asyncio.Future[Any], int, float]: + """Execute a SiLA command; return decoded dict (sync) or (fut, request_id, started_at) (async). Internal helper used by send_command and start_command. Callers should use send_command (run and return result) or start_command (start and return handle). @@ -707,15 +792,13 @@ async def _execute_command( if self._closed: raise RuntimeError("Interface is closed") - # Caller-provided estimate; must not be sent to device. - estimated_duration_seconds: Optional[float] = kwargs.pop("estimated_duration_seconds", None) - if command != "GetStatus": self._validate_lock_id(lock_id) if not self._check_state_allowability(command): - raise SiLACommandRejected( - f"Command {command} not allowed in state {self._current_state.value} (return code 9)" + raise SiLAError( + f"Command {command} not allowed in state {self._current_state.value} (return code 9)", + code=9, ) if command not in self.SYNCHRONOUS_COMMANDS: @@ -723,21 +806,23 @@ async def _execute_command( if normalized_cmd in self.PARALLELISM_TABLE: async with self._parallelism_lock: if not self._check_parallelism(normalized_cmd): - raise SiLACommandRejected( - f"Command {command} cannot run in parallel with currently executing commands (return code 4)" + raise SiLAError( + f"Command {command} cannot run in parallel with currently executing commands (return code 4)", + code=4, ) else: async with self._parallelism_lock: if self._executing_commands: - raise SiLACommandRejected( - f"Command {command} not in parallelism table and device is busy (return code 4)" + raise SiLAError( + f"Command {command} not in parallelism table and device is busy (return code 4)", + code=4, ) else: normalized_cmd = self._normalize_command_name(command) request_id = self._make_request_id() if request_id in self._active_request_ids: - raise SiLARequestIdError(f"Duplicate requestId generated: {request_id} (return code 6)") + raise SiLAError(f"Duplicate requestId generated: {request_id} (return code 6)", code=6) params: Dict[str, Any] = {"requestId": request_id, **kwargs} if command != "GetStatus": @@ -786,7 +871,6 @@ def _do_request() -> bytes: if return_code == 2: fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future() - estimated_remaining_time: Optional[float] = estimated_duration_seconds pending_lock_id = None if command == "LockDevice" and "lockId" in params: @@ -798,7 +882,6 @@ def _do_request() -> bytes: request_id=request_id, fut=fut, started_at=started_at, - estimated_remaining_time=estimated_remaining_time, lock_id=pending_lock_id, ) @@ -820,8 +903,9 @@ async def _poll_until_complete() -> None: pending_ref = self._pending_by_id.get(request_id) if pending_ref is None or pending_ref.fut.done(): break + eta = self._estimated_remaining_by_request_id.get(request_id) or 0.0 remaining_wait = ( - started_at + (estimated_remaining_time or 0) + POLLING_START_BUFFER - time.time() + started_at + eta + POLLING_START_BUFFER - time.time() ) if remaining_wait > 0: await asyncio.sleep(min(remaining_wait, self._poll_interval)) @@ -870,7 +954,7 @@ async def _poll_until_complete() -> None: await asyncio.sleep(self._poll_interval) asyncio.create_task(_poll_until_complete()) - return (fut, request_id, estimated_remaining_time, started_at) + return (fut, request_id, started_at) self._handle_return_code(return_code, message, command, request_id) raise SiLAError(f"Command {command} failed: {return_code} {message}") @@ -909,8 +993,8 @@ async def start_command( command: str, lock_id: Optional[str] = None, **kwargs: Any, - ) -> tuple[asyncio.Future[Any], int, Optional[float], float]: - """Start a SiLA command and return a handle (future + request_id, eta, started_at). + ) -> tuple[asyncio.Future[Any], int, float]: + """Start a SiLA command and return a handle (future, request_id, started_at). Use for async device commands (OpenDoor, CloseDoor, ExecuteMethod, Initialize, Reset, LockDevice, UnlockDevice, StopMethod) when you want @@ -920,12 +1004,11 @@ async def start_command( Args: command: Command name (must be an async command). lock_id: LockId (defaults to None, validated if device is locked). - **kwargs: Additional command parameters. May include estimated_duration_seconds - (optional float, seconds); it is used as estimated_remaining_time on the handle - and is not sent to the device. + **kwargs: Additional command parameters (e.g. methodName, deviceId). Not sent to + device: requestId (injected), lockId when applicable. Returns: - (future, request_id, estimated_remaining_time, started_at) tuple. + (future, request_id, started_at) tuple. ETA/lifetime come from the backend (event-driven). Await the future for the result or to propagate exceptions. Raises: diff --git a/pylabrobot/thermocycling/inheco/odtc_tests.py b/pylabrobot/thermocycling/inheco/odtc_tests.py index 99b4169d935..1b4260a856d 100644 --- a/pylabrobot/thermocycling/inheco/odtc_tests.py +++ b/pylabrobot/thermocycling/inheco/odtc_tests.py @@ -3,13 +3,15 @@ import asyncio import unittest import xml.etree.ElementTree as ET -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional, cast from unittest.mock import AsyncMock, MagicMock, patch -from pylabrobot.thermocycling.inheco.odtc_backend import CommandExecution, MethodExecution, ODTCBackend +from pylabrobot.resources import Coordinate +from pylabrobot.thermocycling.inheco.odtc_backend import ODTCBackend, ODTCExecution from pylabrobot.thermocycling.inheco.odtc_model import ( ODTCMethodSet, ODTC_DIMENSIONS, + ODTCProgress, ODTCProtocol, ODTCStage, ODTCStep, @@ -20,13 +22,76 @@ odtc_protocol_to_protocol, parse_method_set, ) -from pylabrobot.thermocycling.inheco.odtc_thermocycler import ODTCThermocycler -from pylabrobot.resources import Coordinate from pylabrobot.thermocycling.inheco.odtc_sila_interface import ( - SiLATimeoutError, + FirstEventTimeout, + FirstEventType, ODTCSiLAInterface, SiLAState, + SiLATimeoutError, ) +from pylabrobot.thermocycling.inheco.odtc_thermocycler import ODTCThermocycler + + +def _minimal_data_event_payload(remaining_s: float = 300.0) -> Dict[str, Any]: + """Minimal DataEvent payload (valid XML); ODTCProgress.from_data_event parses elapsed_s=0 when no Elapsed time.""" + inner = ( + f'' + f'{int(remaining_s)}' + ) + escaped = inner.replace("<", "<").replace(">", ">") + return { + "requestId": 12345, + "dataValue": f"{escaped}", + } + + +def _data_event_payload_with_elapsed(elapsed_s: float, request_id: int = 12345) -> Dict[str, Any]: + """DataEvent payload with Elapsed time (ms) for progress/lookup tests.""" + ms = int(elapsed_s * 1000) + inner = ( + f'' + f'{ms}' + ) + escaped = inner.replace("<", "<").replace(">", ">") + return { + "requestId": request_id, + "dataValue": f"{escaped}", + } + + +def _data_event_payload_with_elapsed_and_temps( + elapsed_s: float, + current_temp_c: Optional[float] = None, + lid_temp_c: Optional[float] = None, + target_temp_c: Optional[float] = None, + request_id: int = 12345, +) -> Dict[str, Any]: + """DataEvent payload with Elapsed time and optional temperatures (1/100°C in XML).""" + parts = [ + f'' + f'{int(elapsed_s * 1000)}', + ] + if current_temp_c is not None: + parts.append( + f'' + f'{int(current_temp_c * 100)}' + ) + if lid_temp_c is not None: + parts.append( + f'' + f'{int(lid_temp_c * 100)}' + ) + if target_temp_c is not None: + parts.append( + f'' + f'{int(target_temp_c * 100)}' + ) + inner = "" + "".join(parts) + "" + escaped = inner.replace("<", "<").replace(">", ">") + return { + "requestId": request_id, + "dataValue": f"{escaped}", + } class TestNormalizeVariant(unittest.TestCase): @@ -48,6 +113,23 @@ def test_invalid_raises(self): self.assertIn("Valid", str(cm.exception)) +class TestODTCProgressFromDataEventPayload(unittest.TestCase): + """Tests for ODTCProgress.from_data_event with raw payload (parsing covered indirectly).""" + + def test_from_data_event_experiment_step_sequence_fallback(self): + """When no 'Step' dataSeries, current_step_index is taken from experimentStep @sequence (1-based).""" + inner = ( + '' + '10000' + '' + ) + escaped = inner.replace("<", "<").replace(">", ">") + payload = {"requestId": 1, "dataValue": f"{escaped}"} + progress = ODTCProgress.from_data_event(payload, None) + self.assertEqual(progress.current_step_index, 4) # 1-based sequence 5 -> 0-based index 4 + self.assertEqual(progress.elapsed_s, 10.0) + + class TestEstimateMethodDurationSeconds(unittest.TestCase): """Tests for estimate_method_duration_seconds (ODTC method duration from steps).""" @@ -140,6 +222,296 @@ def test_two_steps_with_loop(self): self.assertLess(got, 1000) +class TestODTCProgressPositionFromElapsed(unittest.TestCase): + """Tests for ODTCProgress.from_data_event(payload, odtc) position (timeline lookup from elapsed).""" + + def test_premethod_elapsed_zero(self): + """Premethod at elapsed 0: step 0, cycle 0, setpoint = target_block_temperature.""" + odtc = ODTCProtocol( + kind="premethod", + name="Pre37", + target_block_temperature=37.0, + stages=[], + ) + payload = _data_event_payload_with_elapsed(0.0) + progress = ODTCProgress.from_data_event(payload, odtc) + self.assertEqual(progress.current_step_index, 0) + self.assertEqual(progress.current_cycle_index, 0) + self.assertEqual(progress.target_temp_c, 37.0) + self.assertEqual(progress.total_step_count, 1) + self.assertEqual(progress.total_cycle_count, 1) + self.assertGreater(progress.remaining_hold_s, 0) + self.assertEqual(progress.estimated_duration_s, PREMETHOD_ESTIMATED_DURATION_SECONDS) + self.assertEqual(progress.remaining_duration_s, PREMETHOD_ESTIMATED_DURATION_SECONDS) + + def test_premethod_elapsed_mid_run(self): + """Premethod mid-run: same step/cycle/setpoint, remaining_hold decreases.""" + odtc = ODTCProtocol( + kind="premethod", + name="Pre37", + target_block_temperature=37.0, + stages=[], + ) + payload = _data_event_payload_with_elapsed(300.0) + progress = ODTCProgress.from_data_event(payload, odtc) + self.assertEqual(progress.current_step_index, 0) + self.assertEqual(progress.current_cycle_index, 0) + self.assertEqual(progress.target_temp_c, 37.0) + self.assertGreater(progress.remaining_hold_s, 0) + self.assertLess(progress.remaining_hold_s, PREMETHOD_ESTIMATED_DURATION_SECONDS - 300.0 + 1) + rem = progress.remaining_duration_s + self.assertIsNotNone(rem) + self.assertAlmostEqual(cast(float, rem), 300.0, delta=1.0) + + def test_premethod_elapsed_beyond_duration(self): + """Premethod beyond estimated duration: remaining_hold_s = 0.""" + odtc = ODTCProtocol( + kind="premethod", + name="Pre37", + target_block_temperature=37.0, + stages=[], + ) + beyond = PREMETHOD_ESTIMATED_DURATION_SECONDS + 60.0 + payload = _data_event_payload_with_elapsed(beyond) + progress = ODTCProgress.from_data_event(payload, odtc) + self.assertEqual(progress.current_step_index, 0) + self.assertEqual(progress.current_cycle_index, 0) + self.assertEqual(progress.target_temp_c, 37.0) + self.assertEqual(progress.remaining_hold_s, 0.0) + self.assertEqual(progress.remaining_duration_s, 0.0) + + def test_method_no_steps(self): + """Method with no steps: step 0, cycle 0.""" + odtc = ODTCProtocol( + kind="method", + name="empty", + start_block_temperature=20.0, + steps=[], + stages=[], + ) + payload = _data_event_payload_with_elapsed(0.0) + progress = ODTCProgress.from_data_event(payload, odtc) + self.assertEqual(progress.current_step_index, 0) + self.assertEqual(progress.current_cycle_index, 0) + self.assertEqual(progress.total_step_count, 0) + self.assertEqual(progress.total_cycle_count, 1) + + def test_method_single_step(self): + """Method with single step: step 0, cycle 0, setpoint from step.""" + odtc = ODTCProtocol( + kind="method", + name="single", + start_block_temperature=20.0, + steps=[ + ODTCStep( + number=1, + slope=4.4, + plateau_temperature=95.0, + plateau_time=30.0, + overshoot_time=5.0, + goto_number=0, + loop_number=0, + ), + ], + stages=[], + ) + payload = _data_event_payload_with_elapsed(0.0) + progress = ODTCProgress.from_data_event(payload, odtc) + self.assertEqual(progress.current_step_index, 0) + self.assertEqual(progress.current_cycle_index, 0) + self.assertEqual(progress.target_temp_c, 95.0) + self.assertGreater(progress.remaining_hold_s, 0) + total_dur = estimate_method_duration_seconds(odtc) + payload_end = _data_event_payload_with_elapsed(total_dur + 10.0) + progress_end = ODTCProgress.from_data_event(payload_end, odtc) + self.assertEqual(progress_end.remaining_hold_s, 0.0) + self.assertEqual(progress_end.target_temp_c, 95.0) + + def test_method_multi_step_with_loops(self): + """Method with 3 steps x 2 cycles: step_index and cycle_index advance with elapsed.""" + odtc = ODTCProtocol( + kind="method", + name="pcr", + start_block_temperature=20.0, + steps=[ + ODTCStep( + number=1, + slope=4.4, + plateau_temperature=95.0, + plateau_time=10.0, + overshoot_time=2.0, + goto_number=0, + loop_number=0, + ), + ODTCStep( + number=2, + slope=4.4, + plateau_temperature=55.0, + plateau_time=10.0, + overshoot_time=2.0, + goto_number=0, + loop_number=0, + ), + ODTCStep( + number=3, + slope=4.4, + plateau_temperature=72.0, + plateau_time=15.0, + overshoot_time=2.0, + goto_number=1, + loop_number=2, + ), + ], + stages=[], + ) + payload0 = _data_event_payload_with_elapsed(0.0) + progress0 = ODTCProgress.from_data_event(payload0, odtc) + self.assertEqual(progress0.current_step_index, 0) + self.assertEqual(progress0.current_cycle_index, 0) + self.assertEqual(progress0.target_temp_c, 95.0) + self.assertEqual(progress0.total_step_count, 3) + self.assertEqual(progress0.total_cycle_count, 2) + total_dur = estimate_method_duration_seconds(odtc) + payload_end = _data_event_payload_with_elapsed(total_dur + 100.0) + progress_end = ODTCProgress.from_data_event(payload_end, odtc) + self.assertEqual(progress_end.current_step_index, 2) + self.assertEqual(progress_end.current_cycle_index, 1) + self.assertEqual(progress_end.target_temp_c, 72.0) + self.assertEqual(progress_end.remaining_hold_s, 0.0) + + def test_elapsed_negative_treated_as_zero(self): + """Negative elapsed_s in payload is not possible (XML ms); from_data_event(None, odtc) gives elapsed_s=0.""" + odtc = ODTCProtocol( + kind="premethod", + name="Pre37", + target_block_temperature=37.0, + stages=[], + ) + progress = ODTCProgress.from_data_event(None, odtc) + self.assertEqual(progress.elapsed_s, 0.0) + self.assertEqual(progress.current_step_index, 0) + self.assertEqual(progress.current_cycle_index, 0) + self.assertEqual(progress.target_temp_c, 37.0) + self.assertEqual(progress.remaining_duration_s, PREMETHOD_ESTIMATED_DURATION_SECONDS) + + +class TestODTCProgress(unittest.TestCase): + """Tests for ODTCProgress.from_data_event(payload, odtc) and format_progress_log_message.""" + + def test_from_data_event_none_none(self): + """from_data_event(None, None): elapsed_s=0, temps None, estimated_duration_s=None, remaining_duration_s=0.""" + progress = ODTCProgress.from_data_event(None, None) + self.assertIsInstance(progress, ODTCProgress) + self.assertEqual(progress.elapsed_s, 0.0) + self.assertIsNone(progress.current_temp_c) + self.assertIsNone(progress.estimated_duration_s) + self.assertEqual(progress.remaining_duration_s, 0.0) + + def test_from_data_event_payload_none(self): + """from_data_event(payload, None): elapsed_s and temps from payload; estimated/remaining duration 0.""" + payload = _data_event_payload_with_elapsed_and_temps(50.0, current_temp_c=25.0, lid_temp_c=24.0) + progress = ODTCProgress.from_data_event(payload, None) + self.assertIsInstance(progress, ODTCProgress) + self.assertEqual(progress.elapsed_s, 50.0) + self.assertEqual(progress.current_temp_c, 25.0) + self.assertEqual(progress.lid_temp_c, 24.0) + self.assertEqual(progress.current_step_index, 0) + self.assertEqual(progress.current_cycle_index, 0) + self.assertEqual(progress.remaining_hold_s, 0.0) + self.assertIsNone(progress.estimated_duration_s) + self.assertEqual(progress.remaining_duration_s, 0.0) + msg = progress.format_progress_log_message() + self.assertIn("ODTC progress", msg) + self.assertIn("50", msg) + self.assertIn("25.0", msg) + + def test_from_data_event_none_odtc(self): + """from_data_event(None, odtc): elapsed_s=0; estimated_duration_s set; remaining_duration_s = estimated.""" + premethod = ODTCProtocol( + kind="premethod", + name="Pre37", + target_block_temperature=37.0, + stages=[], + ) + progress = ODTCProgress.from_data_event(None, premethod) + self.assertEqual(progress.elapsed_s, 0.0) + self.assertEqual(progress.estimated_duration_s, PREMETHOD_ESTIMATED_DURATION_SECONDS) + self.assertEqual(progress.remaining_duration_s, PREMETHOD_ESTIMATED_DURATION_SECONDS) + + def test_from_data_event_premethod(self): + """from_data_event(payload, premethod): step 0, cycle 0, setpoint; estimated/remaining duration.""" + payload = _data_event_payload_with_elapsed_and_temps(100.0, current_temp_c=35.0) + premethod = ODTCProtocol( + kind="premethod", + name="Pre37", + target_block_temperature=37.0, + stages=[], + ) + progress = ODTCProgress.from_data_event(payload, odtc=premethod) + self.assertIsInstance(progress, ODTCProgress) + self.assertEqual(progress.elapsed_s, 100.0) + self.assertEqual(progress.current_step_index, 0) + self.assertEqual(progress.current_cycle_index, 0) + self.assertEqual(progress.target_temp_c, 37.0) + self.assertGreater(progress.remaining_hold_s, 0) + self.assertEqual(progress.estimated_duration_s, PREMETHOD_ESTIMATED_DURATION_SECONDS) + rem = progress.remaining_duration_s + self.assertIsNotNone(rem) + self.assertAlmostEqual(cast(float, rem), 500.0, delta=1.0) # 600 - 100 + msg = progress.format_progress_log_message() + self.assertIn("ODTC progress", msg) + self.assertIn("step", msg) + self.assertIn("cycle", msg) + self.assertIn("37.0", msg) + + def test_from_data_event_method(self): + """from_data_event(payload, method): step_index, cycle_index, remaining_hold_s from position.""" + payload = _data_event_payload_with_elapsed(0.0) + odtc = ODTCProtocol( + kind="method", + name="pcr", + start_block_temperature=20.0, + steps=[ + ODTCStep( + number=1, + slope=4.4, + plateau_temperature=95.0, + plateau_time=10.0, + overshoot_time=2.0, + goto_number=0, + loop_number=0, + ), + ], + stages=[], + ) + progress = ODTCProgress.from_data_event(payload, odtc=odtc) + self.assertIsInstance(progress, ODTCProgress) + self.assertEqual(progress.current_step_index, 0) + self.assertEqual(progress.current_cycle_index, 0) + self.assertEqual(progress.target_temp_c, 95.0) + self.assertGreater(progress.remaining_hold_s, 0) + self.assertIsNotNone(progress.estimated_duration_s) + rem = progress.remaining_duration_s + self.assertIsNotNone(rem) + self.assertGreater(cast(float, rem), 0) + msg = progress.format_progress_log_message() + self.assertIn("ODTC progress", msg) + self.assertIn("elapsed", msg) + + def test_format_progress_log_message_includes_elapsed_and_temps(self): + """format_progress_log_message returns string with ODTC progress, elapsed_s, and temps.""" + progress = ODTCProgress( + elapsed_s=120.0, + current_temp_c=72.0, + lid_temp_c=105.0, + target_temp_c=72.0, + ) + msg = progress.format_progress_log_message() + self.assertIn("ODTC progress", msg) + self.assertIn("120", msg) + self.assertIn("72.0", msg) + + class TestODTCSiLAInterface(unittest.IsolatedAsyncioTestCase): """Tests for ODTCSiLAInterface.""" @@ -444,7 +816,7 @@ async def test_reset_sets_simulation_mode(self): """Test reset(simulation_mode=X) updates backend.simulation_mode.""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.start_command = AsyncMock(return_value=(fut, 1, None, 0.0)) # type: ignore[method-assign] + self.backend._sila.start_command = AsyncMock(return_value=(fut, 1, 0.0)) # type: ignore[method-assign] self.assertFalse(self.backend.simulation_mode) await self.backend.reset(simulation_mode=True) self.assertTrue(self.backend.simulation_mode) @@ -541,18 +913,29 @@ async def test_read_temperatures(self): self.assertAlmostEqual(sensor_values.lid, 25.75, places=2) # 2575 * 0.01 async def test_execute_method(self): - """Test execute_method with wait=True; uses start_command then await handle, returns MethodExecution.""" + """Test execute_method with wait=True; event-driven: wait_for_first_event then handle with eta from DataEvent.""" + self.backend.list_methods = AsyncMock(return_value=([], [])) # type: ignore[method-assign] + self.backend.get_protocol = AsyncMock(return_value=None) # type: ignore[method-assign] fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) + self.backend._sila.get_first_event_type_for_command = MagicMock( # type: ignore[method-assign] + return_value=FirstEventType.DATA_EVENT + ) self.backend._sila.start_command = AsyncMock( # type: ignore[method-assign] - return_value=(fut, 12345, None, 0.0) + return_value=(fut, 12345, 0.0) + ) + self.backend._sila.wait_for_first_event = AsyncMock( # type: ignore[method-assign] + return_value=_minimal_data_event_payload(remaining_s=300.0) ) result = await self.backend.execute_method("MyMethod", wait=True) - self.assertIsInstance(result, MethodExecution) + self.assertIsInstance(result, ODTCExecution) self.assertEqual(result.method_name, "MyMethod") self.backend._sila.start_command.assert_called_once() call_kwargs = self.backend._sila.start_command.call_args[1] self.assertEqual(call_kwargs["methodName"], "MyMethod") + self.assertNotIn("estimated_duration_seconds", call_kwargs) + # Device does not send remaining duration; we use estimated_duration_s - elapsed_s (get_protocol returns None so effective lifetime). + self.assertEqual(result.estimated_remaining_time, self.backend._get_effective_lifetime()) async def test_stop_method(self): """Test stop_method.""" @@ -611,101 +994,236 @@ async def test_get_lid_current_temperature(self): self.assertEqual(len(temps), 1) self.assertAlmostEqual(temps[0], 26.0, places=2) + async def test_get_progress_snapshot_with_registered_protocol_returns_enriched(self): + """With protocol registered and DataEvent with elapsed_s, get_progress_snapshot returns step/cycle/hold.""" + request_id = 12345 + premethod = ODTCProtocol( + kind="premethod", + name="Pre37", + target_block_temperature=37.0, + stages=[], + ) + self.backend._protocol_by_request_id[request_id] = premethod + self.backend._sila._data_events_by_request_id = { # type: ignore[attr-defined] + request_id: [_data_event_payload_with_elapsed(100.0, request_id)], + } + fut: asyncio.Future[Any] = asyncio.Future() + self.backend._current_execution = ODTCExecution( + request_id=request_id, + command_name="ExecuteMethod", + _future=fut, + backend=self.backend, + estimated_remaining_time=600.0, + started_at=0.0, + lifetime=660.0, + method_name="Pre37", + ) + try: + progress = await self.backend.get_progress_snapshot() + self.assertIsNotNone(progress) + assert progress is not None + self.assertIsInstance(progress, ODTCProgress) + self.assertEqual(progress.elapsed_s, 100.0) + self.assertEqual(progress.current_step_index, 0) + self.assertEqual(progress.current_cycle_index, 0) + self.assertGreater(progress.remaining_hold_s or 0, 0) + self.assertEqual(progress.target_temp_c, 37.0) + self.assertEqual(progress.estimated_duration_s, PREMETHOD_ESTIMATED_DURATION_SECONDS) + self.assertAlmostEqual(progress.remaining_duration_s or 0, 500.0, delta=1.0) # 600 - 100 + finally: + self.backend._current_execution = None + self.backend._protocol_by_request_id.pop(request_id, None) + + async def test_get_current_step_index_and_get_hold_time_with_registered_protocol(self): + """With protocol registered and DataEvent, get_current_step_index and get_hold_time return values.""" + request_id = 12346 + premethod = ODTCProtocol( + kind="premethod", + name="Pre37", + target_block_temperature=37.0, + stages=[], + ) + self.backend._protocol_by_request_id[request_id] = premethod + self.backend._sila._data_events_by_request_id = { # type: ignore[attr-defined] + request_id: [_data_event_payload_with_elapsed(50.0, request_id)], + } + fut: asyncio.Future[Any] = asyncio.Future() + self.backend._current_execution = ODTCExecution( + request_id=request_id, + command_name="ExecuteMethod", + _future=fut, + backend=self.backend, + estimated_remaining_time=600.0, + started_at=0.0, + lifetime=660.0, + method_name="Pre37", + ) + try: + step_idx = await self.backend.get_current_step_index() + self.assertEqual(step_idx, 0) + hold_s = await self.backend.get_hold_time() + self.assertGreaterEqual(hold_s, 0) + cycle_idx = await self.backend.get_current_cycle_index() + self.assertEqual(cycle_idx, 0) + finally: + self.backend._current_execution = None + self.backend._protocol_by_request_id.pop(request_id, None) + async def test_execute_method_wait_false(self): - """Test execute_method with wait=False (returns handle).""" + """Test execute_method with wait=False (returns handle); eta from our estimated_duration_s - elapsed_s (no device remaining).""" + self.backend.list_methods = AsyncMock(return_value=([], [])) # type: ignore[method-assign] + self.backend.get_protocol = AsyncMock(return_value=None) # type: ignore[method-assign] fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] + self.backend._sila.get_first_event_type_for_command = MagicMock( # type: ignore[method-assign] + return_value=FirstEventType.DATA_EVENT + ) + self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, 0.0)) # type: ignore[method-assign] + self.backend._sila.wait_for_first_event = AsyncMock( # type: ignore[method-assign] + return_value=_minimal_data_event_payload(remaining_s=300.0) + ) execution = await self.backend.execute_method("PCR_30cycles", wait=False) assert execution is not None # Type narrowing - self.assertIsInstance(execution, MethodExecution) + self.assertIsInstance(execution, ODTCExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.method_name, "PCR_30cycles") self.backend._sila.start_command.assert_called_once() call_kwargs = self.backend._sila.start_command.call_args[1] self.assertEqual(call_kwargs["methodName"], "PCR_30cycles") + self.assertNotIn("estimated_duration_seconds", call_kwargs) + # get_protocol returns None so we use effective lifetime for eta (device does not send remaining). + self.assertEqual(execution.estimated_remaining_time, self.backend._get_effective_lifetime()) + + async def test_execute_method_premethod_registers_protocol(self): + """When executing a premethod, protocol is registered so progress/step/cycle/hold work.""" + premethod = ODTCProtocol( + kind="premethod", + name="Pre37", + target_block_temperature=37.0, + stages=[], + ) + method_set = ODTCMethodSet(methods=[], premethods=[premethod]) + self.backend.list_methods = AsyncMock(return_value=([], ["Pre37"])) # type: ignore[method-assign] + self.backend.get_method_set = AsyncMock(return_value=method_set) # type: ignore[method-assign] + fut: asyncio.Future[Any] = asyncio.Future() + fut.set_result(None) + self.backend._sila.get_first_event_type_for_command = MagicMock( # type: ignore[method-assign] + return_value=FirstEventType.DATA_EVENT + ) + self.backend._sila.start_command = AsyncMock(return_value=(fut, 99999, 0.0)) # type: ignore[method-assign] + self.backend._sila.wait_for_first_event = AsyncMock( # type: ignore[method-assign] + return_value=_minimal_data_event_payload(remaining_s=300.0) + ) + execution = await self.backend.execute_method("Pre37", wait=False) + assert execution is not None + self.assertIn(execution.request_id, self.backend._protocol_by_request_id) + registered = self.backend._protocol_by_request_id[execution.request_id] + self.assertIsInstance(registered, ODTCProtocol) + reg_odtc = cast(ODTCProtocol, registered) + self.assertEqual(reg_odtc.kind, "premethod") + self.assertEqual(reg_odtc.name, "Pre37") + self.assertEqual(reg_odtc.target_block_temperature, 37.0) + + async def test_execute_method_first_event_timeout(self): + """Test execute_method propagates FirstEventTimeout when no DataEvent received in time.""" + self.backend.list_methods = AsyncMock(return_value=([], [])) # type: ignore[method-assign] + self.backend.get_protocol = AsyncMock(return_value=None) # type: ignore[method-assign] + fut: asyncio.Future[Any] = asyncio.Future() + fut.set_result(None) + self.backend._sila.get_first_event_type_for_command = MagicMock( # type: ignore[method-assign] + return_value=FirstEventType.DATA_EVENT + ) + self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, 0.0)) # type: ignore[method-assign] + self.backend._sila.wait_for_first_event = AsyncMock( # type: ignore[method-assign] + side_effect=FirstEventTimeout( + "No DataEvent received for request_id 12345 within 60.0s" + ) + ) + with self.assertRaises(FirstEventTimeout) as cm: + await self.backend.execute_method("MyMethod", wait=False) + self.assertIn("12345", str(cm.exception)) + self.assertIn("60", str(cm.exception)) async def test_method_execution_awaitable(self): - """Test that MethodExecution is awaitable and wait() completes.""" + """Test that ODTCExecution is awaitable and wait() completes (returns None).""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result("success") - execution = MethodExecution( + execution = ODTCExecution( request_id=12345, command_name="ExecuteMethod", method_name="PCR_30cycles", _future=fut, - backend=self.backend + backend=self.backend, ) result = await execution - self.assertEqual(result, "success") + self.assertIsNone(result) await execution.wait() # Should not raise async def test_method_execution_is_running(self): - """Test MethodExecution.is_running() method.""" + """Test ODTCExecution.is_running() for ExecuteMethod.""" fut: asyncio.Future[Any] = asyncio.Future() - execution = MethodExecution( + execution = ODTCExecution( request_id=12345, command_name="ExecuteMethod", method_name="PCR_30cycles", _future=fut, - backend=self.backend + backend=self.backend, ) self.backend.get_status = AsyncMock(return_value="busy") # type: ignore[method-assign] is_running = await execution.is_running() self.assertTrue(is_running) async def test_method_execution_stop(self): - """Test MethodExecution.stop() method.""" + """Test ODTCExecution.stop() for ExecuteMethod.""" fut: asyncio.Future[Any] = asyncio.Future() - execution = MethodExecution( + execution = ODTCExecution( request_id=12345, command_name="ExecuteMethod", method_name="PCR_30cycles", _future=fut, - backend=self.backend + backend=self.backend, ) self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign] await execution.stop() self.backend._sila.send_command.assert_called_once_with("StopMethod") - async def test_method_execution_inheritance(self): - """Test that MethodExecution is a subclass of CommandExecution.""" + async def test_odtc_execution_has_command_and_method(self): + """Test ODTCExecution has command_name and method_name when ExecuteMethod.""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - execution = MethodExecution( + execution = ODTCExecution( request_id=12345, command_name="ExecuteMethod", method_name="PCR_30cycles", _future=fut, - backend=self.backend + backend=self.backend, ) - self.assertIsInstance(execution, CommandExecution) self.assertEqual(execution.command_name, "ExecuteMethod") self.assertEqual(execution.method_name, "PCR_30cycles") async def test_command_execution_awaitable(self): - """Test that CommandExecution is awaitable and wait() completes.""" + """Test that ODTCExecution is awaitable and wait() completes (returns None).""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result("success") - execution = CommandExecution( + execution = ODTCExecution( request_id=12345, command_name="OpenDoor", _future=fut, - backend=self.backend + backend=self.backend, ) result = await execution - self.assertEqual(result, "success") + self.assertIsNone(result) await execution.wait() # Should not raise async def test_command_execution_get_data_events(self): - """Test CommandExecution.get_data_events() method.""" + """Test ODTCExecution.get_data_events() method.""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - execution = CommandExecution( + execution = ODTCExecution( request_id=12345, command_name="OpenDoor", _future=fut, - backend=self.backend + backend=self.backend, ) self.backend._sila._data_events_by_request_id = { 12345: [{"requestId": 12345, "data": "test1"}, {"requestId": 12345, "data": "test2"}], @@ -715,27 +1233,30 @@ async def test_command_execution_get_data_events(self): self.assertEqual(len(events), 2) self.assertEqual(events[0]["requestId"], 12345) - async def test_open_door_wait_false_returns_command_execution(self): - """Test open_door with wait=False returns CommandExecution handle.""" + async def test_open_door_wait_false_returns_execution_handle(self): + """Test open_door with wait=False returns ODTCExecution; lifetime/eta from first_event_timeout.""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] + self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, 0.0)) # type: ignore[method-assign] execution = await self.backend.open_door(wait=False) assert execution is not None # Type narrowing - self.assertIsInstance(execution, CommandExecution) + self.assertIsInstance(execution, ODTCExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "OpenDoor") self.backend._sila.start_command.assert_called_once() self.assertEqual(self.backend._sila.start_command.call_args[0][0], "OpenDoor") + self.assertNotIn("estimated_duration_seconds", self.backend._sila.start_command.call_args[1]) + self.assertEqual(execution.estimated_remaining_time, 60.0) + self.assertEqual(execution.lifetime, 120.0) async def test_reset_wait_false_returns_handle_with_kwargs(self): - """Test reset with wait=False returns CommandExecution and passes deviceId/eventReceiverURI.""" + """Test reset with wait=False returns ODTCExecution and passes deviceId/eventReceiverURI.""" fut: asyncio.Future[Any] = asyncio.Future() fut.set_result(None) - self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, None, 0.0)) # type: ignore[method-assign] + self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, 0.0)) # type: ignore[method-assign] execution = await self.backend.reset(wait=False) assert execution is not None # Type narrowing - self.assertIsInstance(execution, CommandExecution) + self.assertIsInstance(execution, ODTCExecution) self.assertEqual(execution.request_id, 12345) self.assertEqual(execution.command_name, "Reset") self.backend._sila.start_command.assert_called_once() @@ -874,7 +1395,7 @@ async def test_get_protocol_returns_stored_for_method(self): self.assertEqual(len(protocol.stages[0].steps), 1) async def test_run_stored_protocol_calls_execute_method(self): - """Test run_stored_protocol calls execute_method with name, wait, and estimated_duration_seconds.""" + """Test run_stored_protocol calls execute_method with name, wait, protocol (no estimated_duration_seconds).""" self.backend.execute_method = AsyncMock(return_value=None) # type: ignore[method-assign] with patch.object( self.backend, "get_protocol", new_callable=AsyncMock, return_value=None @@ -883,7 +1404,7 @@ async def test_run_stored_protocol_calls_execute_method(self): ): await self.backend.run_stored_protocol("MyMethod", wait=True) self.backend.execute_method.assert_called_once_with( - "MyMethod", wait=True, estimated_duration_seconds=None, protocol=None + "MyMethod", wait=True, protocol=None ) diff --git a/pylabrobot/thermocycling/inheco/odtc_thermocycler.py b/pylabrobot/thermocycling/inheco/odtc_thermocycler.py index 9b769535999..532b61060ac 100644 --- a/pylabrobot/thermocycling/inheco/odtc_thermocycler.py +++ b/pylabrobot/thermocycling/inheco/odtc_thermocycler.py @@ -99,6 +99,15 @@ async def is_profile_running(self, **backend_kwargs: Any) -> bool: """ return await self.backend.is_method_running() + async def get_progress_snapshot(self): + """Get progress from the latest DataEvent (elapsed, temperatures, step/cycle/hold). Returns None if no run active. + + Returns Optional[ODTCProgress]. Use tc.backend.get_progress_snapshot(); see ODTCProgress + for attributes (elapsed_s, current_temp_c, target_temp_c, lid_temp_c, step/cycle/hold) + and format_progress_log_message(). + """ + return await self.backend.get_progress_snapshot() + async def wait_for_profile_completion( self, poll_interval: float = 60.0, diff --git a/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb b/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb index 07354d9caa6..047c7d623ae 100644 --- a/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb +++ b/pylabrobot/thermocycling/inheco/odtc_tutorial.ipynb @@ -9,6 +9,52 @@ "This notebook walks through the ODTC (Inheco) thermocycler interface: setup, listing methods, lid (door) commands, setting block temperature, and running a protocol. Use it as a reference for the recommended workflow." ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 0. Logging (optional)\n", + "\n", + "**Optional.** Run this cell if you want to see backend/enriched progress and instrument (SiLA) communication in a file. Backend and SiLA log to one timestamped file; progress (ODTCProgress) is logged every 150 s while you await a handle. Optional: set **`tc.backend.data_event_log_path`** (e.g. in section 1) for raw DataEvent JSONL." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Logging to odtc_run_20260215_004101.log\n", + "DataEvent summaries go to the same log; set tc.backend.data_event_log_path = 'odtc_data_events_20260215_004101.jsonl' for raw JSONL (optional)\n" + ] + } + ], + "source": [ + "import logging\n", + "from datetime import datetime\n", + "\n", + "# One log file and one DataEvents file per notebook run (timestamped)\n", + "_run_id = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n", + "odtc_log_file = f\"odtc_run_{_run_id}.log\"\n", + "odtc_data_events_file = f\"odtc_data_events_{_run_id}.jsonl\"\n", + "\n", + "# File handler: ODTC backend + thermocycling + SiLA (storage inheco used during setup)\n", + "_fh = logging.FileHandler(odtc_log_file, encoding=\"utf-8\")\n", + "_fh.setLevel(logging.DEBUG) # Set to logging.INFO to reduce verbosity\n", + "_fh.setFormatter(logging.Formatter(\"%(asctime)s - %(name)s - %(levelname)s - %(message)s\"))\n", + "\n", + "for _name in (\"pylabrobot.thermocycling.inheco\", \"pylabrobot.storage.inheco\"):\n", + " _log = logging.getLogger(_name)\n", + " _log.setLevel(logging.DEBUG)\n", + " _log.addHandler(_fh)\n", + "\n", + "print(f\"Logging to {odtc_log_file}\")\n", + "print(f\"DataEvent summaries go to the same log; set tc.backend.data_event_log_path = '{odtc_data_events_file}' for raw JSONL (optional)\")" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -20,7 +66,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -29,6 +75,8 @@ "from pylabrobot.thermocycling.inheco import ODTCThermocycler\n", "# Preferred: ODTCThermocycler (built-in ODTC dimensions; variant 96 or 384)\n", "tc = ODTCThermocycler(name=\"odtc_test\", odtc_ip=\"192.168.1.50\", variant=96, child_location=Coordinate.zero())\n", + "# Optional: raw DataEvent JSONL (path from section 0)\n", + "tc.backend.data_event_log_path = odtc_data_events_file\n", "# Override: tc = Thermocycler(..., backend=ODTCBackend(odtc_ip=..., variant=96, logger=...), ...) for custom backend" ] }, @@ -43,9 +91,27 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-02-15 00:41:02,166 - pylabrobot.storage.inheco.scila.inheco_sila_interface - INFO - Device reset (unlocked)\n", + "2026-02-15 00:41:02,188 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - GetStatus returned raw state: 'standby' (type: str)\n", + "2026-02-15 00:41:02,188 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device is in standby state, calling Initialize...\n", + "2026-02-15 00:41:02,580 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - Device successfully initialized and is in idle state\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Connected and initialized.\n" + ] + } + ], "source": [ "await tc.setup()\n", "print(\"✓ Connected and initialized.\")" @@ -64,9 +130,105 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "metadata": {}, - "outputs": [], + "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", @@ -102,11 +264,19 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Close started (request_id=329628599)\n" + ] + } + ], "source": [ - "# Non-blocking: returns CommandExecution handle; await handle.wait() when you need to wait\n", + "# 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})\")" ] @@ -117,19 +287,39 @@ "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 handle, progress (from DataEvents) is reported every **progress_log_interval** (default 150 s). Use **`config=tc.backend.get_default_config(post_heating=True)`** to hold temperatures after the method ends." + "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": null, + "execution_count": 6, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-02-15 00:41:10,960 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - [2026-02-15 00:41:10] Waiting for command\n", + " Command: CloseDoor\n", + " Duration (timeout): 120.0s\n", + " Remaining: 120s\n", + "2026-02-15 00:41:18,952 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - MethodSet XML saved to: debug_set_mount_temp.xml\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Door closed.\n", + "Block: 28.1 °C Lid: 60.9 °C\n" + ] + } + ], "source": [ "await door_handle.wait()\n", "print(\"Door closed.\")\n", "\n", - "# set_block_temperature runs a premethod; wait=False returns MethodExecution handle\n", + "# set_block_temperature runs a premethod; wait=False returns execution handle (ODTCExecution)\n", "# Override: debug_xml=True, xml_output_path=\"out.xml\" to save generated MethodSet XML\n", "mount_handle = await tc.set_block_temperature([37.0],\n", " wait=False,\n", @@ -143,27 +333,39 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Poll temps while method runs (optional)\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": "code", - "execution_count": null, + "execution_count": 7, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-02-15 00:41:41,819 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - [2026-02-15 00:41:41] Waiting for command\n", + " Command: plr_currentProtocol (ExecuteMethod)\n", + " Duration (timeout): 655.374s\n", + " Remaining: 648s\n", + "2026-02-15 00:41:41,820 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - ODTC progress: elapsed 5s, step 1/1, cycle 1/1, setpoint 37.0°C, block 27.6°C, lid 59.7°C\n", + "2026-02-15 00:44:11,825 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - ODTC progress: elapsed 152s, step 1/1, cycle 1/1, setpoint 37.0°C, block 37.0°C, lid 109.5°C\n", + "2026-02-15 00:46:41,831 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - ODTC progress: elapsed 305s, step 1/1, cycle 1/1, setpoint 37.0°C, block 37.0°C, lid 109.6°C\n", + "2026-02-15 00:49:11,836 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - ODTC progress: elapsed 452s, step 1/1, cycle 1/1, setpoint 37.0°C, block 37.0°C, lid 109.9°C\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Protocol started (request_id=462796715)\n", + "Block: 37.0 °C Lid: 110.2 °C\n" + ] + } + ], "source": [ "from pylabrobot.thermocycling.standard import Protocol, Stage, Step\n", "\n", "# Wait for set_block_temperature (previous cell) to finish before starting a protocol\n", "await mount_handle\n", "\n", - "# run_protocol is always non-blocking; returns MethodExecution. To block: await handle.wait() or tc.wait_for_profile_completion()\n", + "# run_protocol is always non-blocking; returns execution handle (ODTCExecution). To block: await handle.wait() or tc.wait_for_profile_completion()\n", "config = tc.backend.get_default_config(post_heating=False) # if True: hold temps after method ends\n", "cycle_protocol = Protocol(stages=[\n", " Stage(steps=[\n", @@ -183,32 +385,57 @@ "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 (elapsed time, step/cycle, temperatures). You can also poll status: **`is_profile_running()`**, **`get_hold_time()`**, **`get_current_cycle_index()`** / **`get_total_cycle_count()`**, **`get_current_step_index()`** / **`get_total_step_count()`**. Run the cell below after starting the protocol to poll and print status manually." + "**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": null, + "execution_count": 8, "metadata": {}, - "outputs": [], + "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", - "for poll in range(6):\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", - " hold_s = await tc.get_hold_time()\n", - " cycle = await tc.get_current_cycle_index()\n", - " total_cycles = await tc.get_total_cycle_count()\n", - " step = await tc.get_current_step_index()\n", - " total_steps = await tc.get_total_step_count()\n", - " block = await tc.get_block_current_temperature()\n", - " lid = await tc.get_lid_current_temperature()\n", - " print(f\"Poll {poll + 1}: cycle {cycle + 1}/{total_cycles}, step {step + 1}/{total_steps}, hold_remaining={hold_s:.1f}s, block={block[0]:.1f}°C, lid={lid[0]:.1f}°C\")\n", - " await asyncio.sleep(10)\n", + " snap = await tc.get_progress_snapshot()\n", + " if snap:\n", + " print(f\"Poll {poll + 1}: {snap}\")\n", + " else:\n", + " print(f\"Poll {poll + 1}: no progress snapshot yet.\")\n", + " await asyncio.sleep(5)\n", "else:\n", " print(\"Still running after 6 polls; use await execution.wait() or tc.wait_for_profile_completion() to block until done.\")" ] @@ -224,9 +451,32 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-02-15 00:50:34,070 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - [2026-02-15 00:50:34] Waiting for command\n", + " Command: plr_currentProtocol (ExecuteMethod)\n", + " Duration (timeout): 113.69154545454546s\n", + " Remaining: 56s\n", + "2026-02-15 00:50:34,072 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - ODTC progress: elapsed 55s, step 3/3, cycle 1/1, setpoint 10.0°C, block 13.9°C, lid 109.8°C\n", + "2026-02-15 00:50:36,788 - pylabrobot.thermocycling.inheco.odtc_backend - INFO - [2026-02-15 00:50:36] Waiting for command\n", + " Command: OpenDoor\n", + " Duration (timeout): 120.0s\n", + " Remaining: 120s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Done.\n" + ] + } + ], "source": [ "# Block until protocol done; progress is logged every 150 s (progress_log_interval) while waiting\n", "# Alternatively: await tc.wait_for_profile_completion(poll_interval=..., timeout=...)\n", From a824c9286343c1d14d60fd7aa538aa20a24d456f Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:52:26 -0800 Subject: [PATCH 26/28] Formatting --- pylabrobot/thermocycling/standard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylabrobot/thermocycling/standard.py b/pylabrobot/thermocycling/standard.py index 23c474505b2..0f97edb1b97 100644 --- a/pylabrobot/thermocycling/standard.py +++ b/pylabrobot/thermocycling/standard.py @@ -1,6 +1,6 @@ import enum from dataclasses import dataclass -from typing import List, Optional, Sequence +from typing import List, Optional @dataclass From ab4264a423964d11e6fc045e0902c58aace9d3eb Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:56:25 -0800 Subject: [PATCH 27/28] Line Formatting --- pylabrobot/thermocycling/backend.py | 4 +- pylabrobot/thermocycling/chatterbox.py | 4 +- .../thermocycling/inheco/odtc_backend.py | 112 +++++----- pylabrobot/thermocycling/inheco/odtc_model.py | 93 +++++---- .../inheco/odtc_sila_interface.py | 191 ++++++++++++++---- pylabrobot/thermocycling/inheco/odtc_tests.py | 89 ++++---- .../thermocycling/inheco/odtc_thermocycler.py | 2 +- pylabrobot/thermocycling/opentrons_backend.py | 4 +- .../thermocycling/opentrons_backend_usb.py | 4 +- pylabrobot/thermocycling/thermocycler.py | 4 +- 10 files changed, 318 insertions(+), 189 deletions(-) diff --git a/pylabrobot/thermocycling/backend.py b/pylabrobot/thermocycling/backend.py index 76a36e37ba3..30221ada5c0 100644 --- a/pylabrobot/thermocycling/backend.py +++ b/pylabrobot/thermocycling/backend.py @@ -79,9 +79,7 @@ async def run_stored_protocol(self, name: str, wait: bool = False, **kwargs): Raises: NotImplementedError: This backend does not support running stored protocols by name. """ - raise NotImplementedError( - "This backend does not support running stored protocols by name." - ) + raise NotImplementedError("This backend does not support running stored protocols by name.") @abstractmethod async def get_block_current_temperature(self) -> List[float]: diff --git a/pylabrobot/thermocycling/chatterbox.py b/pylabrobot/thermocycling/chatterbox.py index 2bd070246b0..af4a23ac3e5 100644 --- a/pylabrobot/thermocycling/chatterbox.py +++ b/pylabrobot/thermocycling/chatterbox.py @@ -97,9 +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, **kwargs - ): + 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/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index 42d5688fa0c..28e937ee80f 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -12,22 +12,14 @@ from pylabrobot.thermocycling.backend import ThermocyclerBackend from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol -from .odtc_sila_interface import ( - DEFAULT_FIRST_EVENT_TIMEOUT_SECONDS, - DEFAULT_LIFETIME_OF_EXECUTION, - FirstEventType, - ODTCSiLAInterface, - POLLING_START_BUFFER, - SiLAState, -) from .odtc_model import ( + PREMETHOD_ESTIMATED_DURATION_SECONDS, ODTCConfig, + ODTCHardwareConstraints, ODTCMethodSet, ODTCProgress, ODTCProtocol, ODTCSensorValues, - ODTCHardwareConstraints, - PREMETHOD_ESTIMATED_DURATION_SECONDS, ProtocolList, estimate_odtc_protocol_duration_seconds, generate_odtc_timestamp, @@ -46,6 +38,14 @@ validate_volume_fluid_quantity, volume_to_fluid_quantity, ) +from .odtc_sila_interface import ( + DEFAULT_FIRST_EVENT_TIMEOUT_SECONDS, + DEFAULT_LIFETIME_OF_EXECUTION, + POLLING_START_BUFFER, + FirstEventType, + ODTCSiLAInterface, + SiLAState, +) # Buffer (seconds) added to device remaining duration (ExecuteMethod) or first_event_timeout (status commands) for timeout cap (fail faster than full lifetime). LIFETIME_BUFFER_SECONDS: float = 60.0 @@ -53,6 +53,7 @@ class ODTCCommand(str, Enum): """SiLA async command identifier for execute().""" + INITIALIZE = "Initialize" RESET = "Reset" LOCK_DEVICE = "LockDevice" @@ -147,9 +148,7 @@ def get_parameter_string( else: found = None if found is None: - raise ValueError( - f"Parameter '{name}' not found in {self._command_name} response" - ) + raise ValueError(f"Parameter '{name}' not found in {self._command_name} response") value = found.get("String") if value is None: raise ValueError(f"String element not found in {name} parameter") @@ -177,9 +176,7 @@ def get_parameter_string( string_elem = param.find("String") if string_elem is None or string_elem.text is None: - raise ValueError( - f"String element not found in {self._command_name} Parameter response" - ) + raise ValueError(f"String element not found in {self._command_name} Parameter response") return str(string_elem.text) def _get_dict_path(self, path: List[str], required: bool = True) -> Any: @@ -248,12 +245,19 @@ def status(self) -> str: def _log_wait_info(self) -> None: import time + name = f"{self.method_name} ({self.command_name})" if self.method_name else self.command_name - lifetime = self.lifetime if self.lifetime is not None else self.backend._get_effective_lifetime() + lifetime = ( + self.lifetime if self.lifetime is not None else self.backend._get_effective_lifetime() + ) started_at = self.started_at if self.started_at is not None else time.time() remaining = max(0.0, lifetime - (time.time() - started_at)) if lifetime is not None else None ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) - lines = [f"[{ts}] Waiting for command", f" Command: {name}", f" Duration (timeout): {lifetime}s"] + lines = [ + f"[{ts}] Waiting for command", + f" Command: {name}", + f" Duration (timeout): {lifetime}s", + ] if remaining is not None: lines.append(f" Remaining: {remaining:.0f}s") self.backend.logger.info("\n".join(lines)) @@ -268,7 +272,10 @@ async def wait(self) -> None: if interval and interval > 0: task = asyncio.create_task( self.backend._run_progress_loop_until( - self.request_id, interval, self._is_done, self.backend.progress_callback, + self.request_id, + interval, + self._is_done, + self.backend.progress_callback, ) ) try: @@ -284,9 +291,12 @@ async def wait(self) -> None: async def wait_resumable(self, poll_interval: float = 5.0) -> None: import time + self._log_wait_info() started_at = self.started_at if self.started_at is not None else time.time() - lifetime = self.lifetime if self.lifetime is not None else self.backend._get_effective_lifetime() + lifetime = ( + self.lifetime if self.lifetime is not None else self.backend._get_effective_lifetime() + ) await self.backend.wait_for_completion_by_time( request_id=self.request_id, started_at=started_at, @@ -336,9 +346,9 @@ def __init__( lifetime_of_execution: Optional[float] = None, on_response_event_missing: Literal["warn_and_continue", "error"] = "warn_and_continue", progress_log_interval: Optional[float] = 150.0, - progress_callback: Optional[Callable[..., None]] = None, - data_event_log_path: Optional[str] = None, - first_event_timeout_seconds: float = DEFAULT_FIRST_EVENT_TIMEOUT_SECONDS, + progress_callback: Optional[Callable[..., None]] = None, + data_event_log_path: Optional[str] = None, + first_event_timeout_seconds: float = DEFAULT_FIRST_EVENT_TIMEOUT_SECONDS, ): """Initialize ODTC backend. @@ -458,7 +468,7 @@ async def setup( except Exception as e: # noqa: BLE001 last_error = e if attempt < max_attempts - 1: - wait_time = retry_backoff_base_seconds * (2 ** attempt) + wait_time = retry_backoff_base_seconds * (2**attempt) self.logger.warning( "Setup attempt %s/%s failed: %s. Retrying in %.1fs.", attempt + 1, @@ -537,9 +547,7 @@ async def _run_async_command( if wait: await self._sila.send_command(command_name, **send_kwargs) return None - fut, request_id, started_at = await self._sila.start_command( - command_name, **send_kwargs - ) + fut, request_id, started_at = await self._sila.start_command(command_name, **send_kwargs) effective = self._get_effective_lifetime() event_type = self._sila.get_first_event_type_for_command(command_name) @@ -605,7 +613,8 @@ async def execute( if event_receiver_uri is None: event_receiver_uri = f"http://{self._sila._client_ip}:{self._sila.bound_port}/" return await self._run_async_command( - "Reset", wait, + "Reset", + wait, deviceId=kwargs.get("device_id", "ODTC"), eventReceiverURI=event_receiver_uri, simulationMode=self._simulation_mode, @@ -652,15 +661,14 @@ async def execute( else: estimated_duration_s = self._get_effective_lifetime() handle = await self._run_async_command( - "ExecuteMethod", False, + "ExecuteMethod", + False, method_name=method_name, estimated_duration_s=estimated_duration_s, **params, ) assert handle is not None - handle._future.add_done_callback( - lambda _: self._clear_execution_state_for_handle(handle) - ) + handle._future.add_done_callback(lambda _: self._clear_execution_state_for_handle(handle)) if protocol_to_register is not None: self._protocol_by_request_id[handle.request_id] = protocol_to_register self._current_execution = handle @@ -734,7 +742,9 @@ async def get_device_identification(self) -> dict: ) return result if isinstance(result, dict) else {} - async def lock_device(self, lock_id: str, lock_timeout: Optional[float] = None, wait: bool = True) -> Optional[ODTCExecution]: + async def lock_device( + self, lock_id: str, lock_timeout: Optional[float] = None, wait: bool = True + ) -> Optional[ODTCExecution]: """Lock the device for exclusive access (SiLA: LockDevice). See execute(ODTCCommand.LOCK_DEVICE).""" return await self.execute( ODTCCommand.LOCK_DEVICE, wait=wait, lock_id=lock_id, lock_timeout=lock_timeout @@ -753,7 +763,6 @@ async def close_door(self, wait: bool = True) -> Optional[ODTCExecution]: """Close the door (thermocycler lid). SiLA: CloseDoor. See execute(ODTCCommand.CLOSE_DOOR).""" return await self.execute(ODTCCommand.CLOSE_DOOR, wait=wait) - async def read_temperatures(self) -> ODTCSensorValues: """Read all temperature sensors. @@ -842,9 +851,7 @@ async def wait_for_method_completion( if timeout is not None: elapsed = time.time() - start_time if elapsed > timeout: - raise TimeoutError( - f"Method execution did not complete within {timeout}s" - ) + raise TimeoutError(f"Method execution did not complete within {timeout}s") await asyncio.sleep(poll_interval) async def wait_for_completion_by_time( @@ -884,7 +891,9 @@ async def wait_for_completion_by_time( """ import time - interval = progress_log_interval if progress_log_interval is not None else self.progress_log_interval + interval = ( + progress_log_interval if progress_log_interval is not None else self.progress_log_interval + ) callback = progress_callback if progress_callback is not None else self.progress_callback buffer = POLLING_START_BUFFER eta = estimated_remaining_time or 0.0 @@ -892,18 +901,18 @@ async def wait_for_completion_by_time( now = time.time() elapsed = now - started_at if elapsed >= lifetime: - raise TimeoutError( - f"Command (request_id={request_id}) did not complete within {lifetime}s" - ) + raise TimeoutError(f"Command (request_id={request_id}) did not complete within {lifetime}s") # Don't start polling until estimated time + buffer has passed remaining_wait = started_at + eta + buffer - now if remaining_wait > 0: await asyncio.sleep(min(remaining_wait, poll_interval)) continue + # From here: poll until terminal_state or timeout; progress via same loop as wait() async def _is_done() -> bool: status = await self.get_status() return status == terminal_state or (time.time() - started_at) >= lifetime + progress_task: Optional[asyncio.Task[None]] = None if interval and interval > 0: progress_task = asyncio.create_task( @@ -927,7 +936,9 @@ async def _is_done() -> bool: except asyncio.CancelledError: pass - async def get_data_events(self, request_id: Optional[int] = None) -> Dict[int, List[Dict[str, Any]]]: + async def get_data_events( + self, request_id: Optional[int] = None + ) -> Dict[int, List[Dict[str, Any]]]: """Get collected DataEvents. Args: @@ -1044,8 +1055,7 @@ async def _upload_odtc_protocol( allow_overwrite = True if not odtc.name: self.logger.warning( - "ODTCProtocol name resolved to scratch name '%s'. " - "Auto-enabling allow_overwrite=True.", + "ODTCProtocol name resolved to scratch name '%s'. " "Auto-enabling allow_overwrite=True.", resolved_name, ) @@ -1182,13 +1192,17 @@ def _existing_item_type(existing: ODTCProtocol) -> str: for method in method_set.methods: existing_method = get_method_by_name(existing_method_set, method.name) if existing_method is not None: - conflicts.append(f"Method '{method.name}' already exists as {_existing_item_type(existing_method)}") + conflicts.append( + f"Method '{method.name}' already exists as {_existing_item_type(existing_method)}" + ) # Check all premethod names (unified search) for premethod in method_set.premethods: existing_method = get_method_by_name(existing_method_set, premethod.name) if existing_method is not None: - conflicts.append(f"Method '{premethod.name}' already exists as {_existing_item_type(existing_method)}") + 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) @@ -1202,6 +1216,7 @@ def _existing_item_type(existing: ODTCProtocol) -> str: # 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) @@ -1224,6 +1239,7 @@ def _existing_item_type(existing: ODTCProtocol) -> str: # 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") @@ -1601,9 +1617,7 @@ async def get_hold_time(self) -> float: """Get remaining hold time in seconds for the current step.""" request_id = self._request_id_for_get_progress() if request_id is None: - raise RuntimeError( - "No profile running; get_hold_time requires an active method execution." - ) + raise RuntimeError("No profile running; get_hold_time requires an active method execution.") progress = await self._get_progress(request_id) if progress is None: raise RuntimeError( diff --git a/pylabrobot/thermocycling/inheco/odtc_model.py b/pylabrobot/thermocycling/inheco/odtc_model.py index 46fbc1aa2eb..fbaf131af60 100644 --- a/pylabrobot/thermocycling/inheco/odtc_model.py +++ b/pylabrobot/thermocycling/inheco/odtc_model.py @@ -11,11 +11,27 @@ import html import logging -from datetime import datetime import xml.etree.ElementTree as ET from dataclasses import dataclass, field, fields, replace +from datetime import datetime from enum import Enum -from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Literal, Optional, Tuple, Type, TypeVar, Union, cast, get_args, get_origin, get_type_hints +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Iterator, + List, + Literal, + Optional, + Tuple, + Type, + TypeVar, + Union, + cast, + get_args, + get_origin, + get_type_hints, +) from pylabrobot.thermocycling.standard import Protocol, Stage, Step @@ -174,9 +190,7 @@ def normalize_variant(variant: int) -> int: return 384000 if variant in (960000, 384000): return variant - raise ValueError( - f"Unknown variant {variant}. Valid: {list(_VALID_VARIANTS)}" - ) + raise ValueError(f"Unknown variant {variant}. Valid: {list(_VALID_VARIANTS)}") # ============================================================================= @@ -741,7 +755,9 @@ def __str__(self) -> str: 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 "" + 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}" ) @@ -801,7 +817,9 @@ def protocol_to_odtc_protocol( 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 + 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 @@ -1019,7 +1037,9 @@ def from_xml(elem: ET.Element, cls: Type[T]) -> T: return cls(**kwargs) -def to_xml(obj: Any, tag_name: Optional[str] = None, parent: Optional[ET.Element] = None) -> ET.Element: +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. @@ -1162,11 +1182,10 @@ def _get_steps_for_serialization(odtc: ODTCProtocol) -> List[ODTCStep]: 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)) + 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 [] @@ -1255,7 +1274,9 @@ def parse_method_set_file(filepath: str) -> ODTCMethodSet: 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" + 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: @@ -1274,8 +1295,6 @@ def parse_sensor_values(xml_str: str) -> ODTCSensorValues: # ============================================================================= - - def get_premethod_by_name(method_set: ODTCMethodSet, name: str) -> Optional[ODTCProtocol]: """Find a premethod by name.""" return next((pm for pm in method_set.premethods if pm.name == name), None) @@ -1434,8 +1453,7 @@ def _build_one_odtc_stage_for_range( """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) + (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]) @@ -1446,7 +1464,7 @@ def _build_one_odtc_stage_for_range( # 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 is_, ie, _ in inner_loops_sorted: for n in range(is_, ie + 1): step_nums_in_range.discard(n) sorted(step_nums_in_range) @@ -1454,7 +1472,7 @@ def _build_one_odtc_stage_for_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: + 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) @@ -1472,7 +1490,9 @@ def _build_one_odtc_stage_for_range( 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) + return ODTCStage( + steps=cast(List[Step], steps_list), repeats=repeats, inner_stages=inner_stages_list + ) def _odtc_stage_to_steps_impl( @@ -1528,9 +1548,7 @@ def _build_odtc_stages_from_steps(steps: List[ODTCStep]) -> List[ODTCStage]: 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) - ] + 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 @@ -1539,7 +1557,7 @@ def contains(outer: Tuple[int, int, int], inner: Tuple[int, int, int]) -> bool: 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 s, e, _ in top_level: for n in range(s, e + 1): step_nums_in_top_level.add(n) @@ -1559,7 +1577,7 @@ def contains(outer: Tuple[int, int, int], inner: Tuple[int, int, int]) -> bool: 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: + 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 @@ -1617,10 +1635,7 @@ def odtc_cycle_count(odtc: ODTCProtocol) -> int: 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 any((s, e, _) != (start, end, count) and s <= start and end <= e for (s, e, _) in loops) ] if not top_level: return 0 @@ -1667,7 +1682,9 @@ def estimate_method_duration_seconds(odtc: ODTCProtocol) -> float: # ============================================================================= -def _build_protocol_timeline(odtc: ODTCProtocol) -> List[Tuple[float, float, int, int, float, float]]: +def _build_protocol_timeline( + odtc: ODTCProtocol, +) -> List[Tuple[float, float, int, int, float, float]]: """Build timeline segments for an ODTCProtocol (method or premethod). Returns a list of (t_start, t_end, step_index, cycle_index, setpoint_c, plateau_end_t). @@ -1728,7 +1745,9 @@ def _protocol_position_from_elapsed(odtc: ODTCProtocol, elapsed_s: float) -> Dic return { "step_index": 0, "cycle_index": 0, - "setpoint_c": odtc.start_block_temperature if hasattr(odtc, "start_block_temperature") else None, + "setpoint_c": odtc.start_block_temperature + if hasattr(odtc, "start_block_temperature") + else None, "remaining_hold_s": 0.0, "total_steps": total_steps, "total_cycles": total_cycles, @@ -1742,7 +1761,7 @@ def _protocol_position_from_elapsed(odtc: ODTCProtocol, elapsed_s: float) -> Dic steps_per_cycle = 1 total_cycles = 1 - for (t_start, t_end, step_index, cycle_index, setpoint_c, plateau_end_t) in segments: + 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 { @@ -1824,8 +1843,12 @@ def from_data_event( lid_temp_c = parsed.get("lid_temp_c") step_idx = parsed["current_step_index"] if parsed.get("current_step_index") is not None else 0 step_count = parsed["total_step_count"] if parsed.get("total_step_count") is not None else 0 - cycle_idx = parsed["current_cycle_index"] if parsed.get("current_cycle_index") is not None else 0 - cycle_count = parsed["total_cycle_count"] if parsed.get("total_cycle_count") is not None else 0 + cycle_idx = ( + parsed["current_cycle_index"] if parsed.get("current_cycle_index") is not None else 0 + ) + cycle_count = ( + parsed["total_cycle_count"] if parsed.get("total_cycle_count") is not None else 0 + ) hold_s = parsed["remaining_hold_s"] if parsed.get("remaining_hold_s") is not None else 0.0 if odtc is None: diff --git a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py index 2e710de5265..740dce97b02 100644 --- a/pylabrobot/thermocycling/inheco/odtc_sila_interface.py +++ b/pylabrobot/thermocycling/inheco/odtc_sila_interface.py @@ -15,18 +15,16 @@ import logging import time import urllib.request +import xml.etree.ElementTree as ET from dataclasses import dataclass from enum import Enum from typing import Any, Dict, List, Literal, Optional, Set -import xml.etree.ElementTree as ET - from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface -from pylabrobot.storage.inheco.scila.soap import soap_decode, soap_encode, XSI +from pylabrobot.storage.inheco.scila.soap import XSI, soap_decode, soap_encode from .odtc_model import ODTCProgress - # ----------------------------------------------------------------------------- # SiLA/ODTC exceptions (typed command and device errors) # ----------------------------------------------------------------------------- @@ -127,7 +125,9 @@ class SiLAState(str, Enum): IDLE = "idle" BUSY = "busy" PAUSED = "paused" - ERRORHANDLING = "errorHandling" # Device returns "errorHandling" (camelCase per SCILABackend pattern) + ERRORHANDLING = ( + "errorHandling" # Device returns "errorHandling" (camelCase per SCILABackend pattern) + ) INERROR = "inError" # Device returns "inError" (camelCase per SCILABackend comment) @@ -282,27 +282,134 @@ class ODTCSiLAInterface(InhecoSiLAInterface): # State allowability table from ODTC doc section 4 # Format: {command: {state: True if allowed}} STATE_ALLOWABILITY: Dict[str, Dict[SiLAState, bool]] = { - "Abort": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "CloseDoor": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "DoContinue": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "ExecuteMethod": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "GetConfiguration": {SiLAState.STARTUP: False, SiLAState.STANDBY: True, SiLAState.IDLE: False, SiLAState.BUSY: False}, - "GetParameters": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "GetDeviceIdentification": {SiLAState.STARTUP: True, SiLAState.STANDBY: True, SiLAState.INITIALIZING: True, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "GetLastData": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "GetStatus": {SiLAState.STARTUP: True, SiLAState.STANDBY: True, SiLAState.INITIALIZING: True, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "Initialize": {SiLAState.STARTUP: False, SiLAState.STANDBY: True, SiLAState.IDLE: False, SiLAState.BUSY: False}, - "LockDevice": {SiLAState.STARTUP: False, SiLAState.STANDBY: True, SiLAState.IDLE: False, SiLAState.BUSY: False}, - "OpenDoor": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "Pause": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "PrepareForInput": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "PrepareForOutput": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "ReadActualTemperature": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "Reset": {SiLAState.STARTUP: True, SiLAState.STANDBY: True, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "SetConfiguration": {SiLAState.STARTUP: False, SiLAState.STANDBY: True, SiLAState.IDLE: False, SiLAState.BUSY: False}, - "SetParameters": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "StopMethod": {SiLAState.STARTUP: False, SiLAState.STANDBY: False, SiLAState.IDLE: True, SiLAState.BUSY: True}, - "UnlockDevice": {SiLAState.STARTUP: False, SiLAState.STANDBY: True, SiLAState.IDLE: False, SiLAState.BUSY: False}, + "Abort": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: False, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "CloseDoor": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: False, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "DoContinue": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: False, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "ExecuteMethod": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: False, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "GetConfiguration": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: True, + SiLAState.IDLE: False, + SiLAState.BUSY: False, + }, + "GetParameters": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: False, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "GetDeviceIdentification": { + SiLAState.STARTUP: True, + SiLAState.STANDBY: True, + SiLAState.INITIALIZING: True, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "GetLastData": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: False, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "GetStatus": { + SiLAState.STARTUP: True, + SiLAState.STANDBY: True, + SiLAState.INITIALIZING: True, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "Initialize": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: True, + SiLAState.IDLE: False, + SiLAState.BUSY: False, + }, + "LockDevice": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: True, + SiLAState.IDLE: False, + SiLAState.BUSY: False, + }, + "OpenDoor": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: False, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "Pause": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: False, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "PrepareForInput": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: False, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "PrepareForOutput": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: False, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "ReadActualTemperature": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: False, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "Reset": { + SiLAState.STARTUP: True, + SiLAState.STANDBY: True, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "SetConfiguration": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: True, + SiLAState.IDLE: False, + SiLAState.BUSY: False, + }, + "SetParameters": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: False, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "StopMethod": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: False, + SiLAState.IDLE: True, + SiLAState.BUSY: True, + }, + "UnlockDevice": { + SiLAState.STARTUP: False, + SiLAState.STANDBY: True, + SiLAState.IDLE: False, + SiLAState.BUSY: False, + }, } # Synchronous commands (return code 1, no ResponseEvent) @@ -572,7 +679,9 @@ def _update_state_from_status(self, state_str: str) -> None: self._logger.debug("_update_state_from_status: Empty state string, skipping update") return - self._logger.debug(f"_update_state_from_status: Received state: {state_str!r} (type: {type(state_str).__name__})") + self._logger.debug( + f"_update_state_from_status: Received state: {state_str!r} (type: {type(state_str).__name__})" + ) # Match exactly against enum values (no normalization - we want to see what device actually returns) try: @@ -612,17 +721,23 @@ def _handle_return_code( elif return_code == 5: raise SiLAError(f"Command {command_name} rejected: LockId mismatch (return code 5)", code=5) elif return_code == 6: - raise SiLAError(f"Command {command_name} rejected: Invalid or duplicate requestId (return code 6)", code=6) + raise SiLAError( + f"Command {command_name} rejected: Invalid or duplicate requestId (return code 6)", code=6 + ) elif return_code == 9: raise SiLAError( f"Command {command_name} not allowed in state {self._current_state.value} (return code 9)", code=9, ) elif return_code == 11: - raise SiLAError(f"Command {command_name} rejected: Invalid parameter (return code 11): {message}", code=11) + raise SiLAError( + f"Command {command_name} rejected: Invalid parameter (return code 11): {message}", code=11 + ) elif return_code == 12: # Finished with warning - self._logger.warning(f"Command {command_name} finished with warning (return code 12): {message}") + self._logger.warning( + f"Command {command_name} finished with warning (return code 12): {message}" + ) return elif return_code >= 1000: # Device-specific return code @@ -700,7 +815,9 @@ async def _on_http(self, req: InhecoSiLAInterface._HTTPRequest) -> bytes: err_msg = message.replace("\n", " ") if message else f"Unknown error (code {return_code})" self._complete_pending( request_id, - exception=RuntimeError(f"Command {pending.name} failed with code {return_code}: '{err_msg}'"), + exception=RuntimeError( + f"Command {pending.name} failed with code {return_code}: '{err_msg}'" + ), update_lock_state=False, ) @@ -747,7 +864,9 @@ async def _on_http(self, req: InhecoSiLAInterface._HTTPRequest) -> bytes: with open(self.data_event_log_path, "a", encoding="utf-8") as f: f.write(json.dumps(data_event, default=str) + "\n") except OSError as e: - self._logger.warning("Failed to append DataEvent to %s: %s", self.data_event_log_path, e) + self._logger.warning( + "Failed to append DataEvent to %s: %s", self.data_event_log_path, e + ) return SOAP_RESPONSE_DataEventResponse.encode("utf-8") @@ -759,7 +878,9 @@ async def _on_http(self, req: InhecoSiLAInterface._HTTPRequest) -> bytes: return_code = return_value.get("returnCode") message = return_value.get("message", "") - self._logger.error(f"ErrorEvent for requestId {req_id}: code {return_code}, message: {message}") + self._logger.error( + f"ErrorEvent for requestId {req_id}: code {return_code}, message: {message}" + ) self._current_state = SiLAState.ERRORHANDLING @@ -904,9 +1025,7 @@ async def _poll_until_complete() -> None: if pending_ref is None or pending_ref.fut.done(): break eta = self._estimated_remaining_by_request_id.get(request_id) or 0.0 - remaining_wait = ( - started_at + eta + POLLING_START_BUFFER - time.time() - ) + remaining_wait = started_at + eta + POLLING_START_BUFFER - time.time() if remaining_wait > 0: await asyncio.sleep(min(remaining_wait, self._poll_interval)) continue diff --git a/pylabrobot/thermocycling/inheco/odtc_tests.py b/pylabrobot/thermocycling/inheco/odtc_tests.py index 1b4260a856d..95d13de2fa5 100644 --- a/pylabrobot/thermocycling/inheco/odtc_tests.py +++ b/pylabrobot/thermocycling/inheco/odtc_tests.py @@ -9,13 +9,13 @@ from pylabrobot.resources import Coordinate from pylabrobot.thermocycling.inheco.odtc_backend import ODTCBackend, ODTCExecution from pylabrobot.thermocycling.inheco.odtc_model import ( - ODTCMethodSet, ODTC_DIMENSIONS, + PREMETHOD_ESTIMATED_DURATION_SECONDS, + ODTCMethodSet, ODTCProgress, ODTCProtocol, ODTCStage, ODTCStep, - PREMETHOD_ESTIMATED_DURATION_SECONDS, estimate_method_duration_seconds, method_set_to_xml, normalize_variant, @@ -36,7 +36,7 @@ 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)}' + f"{int(remaining_s)}" ) escaped = inner.replace("<", "<").replace(">", ">") return { @@ -50,7 +50,7 @@ def _data_event_payload_with_elapsed(elapsed_s: float, request_id: int = 12345) ms = int(elapsed_s * 1000) inner = ( f'' - f'{ms}' + f"{ms}" ) escaped = inner.replace("<", "<").replace(">", ">") return { @@ -69,22 +69,22 @@ def _data_event_payload_with_elapsed_and_temps( """DataEvent payload with Elapsed time and optional temperatures (1/100°C in XML).""" parts = [ f'' - f'{int(elapsed_s * 1000)}', + f"{int(elapsed_s * 1000)}", ] if current_temp_c is not None: parts.append( f'' - f'{int(current_temp_c * 100)}' + f"{int(current_temp_c * 100)}" ) if lid_temp_c is not None: parts.append( f'' - f'{int(lid_temp_c * 100)}' + f"{int(lid_temp_c * 100)}" ) if target_temp_c is not None: parts.append( f'' - f'{int(target_temp_c * 100)}' + f"{int(target_temp_c * 100)}" ) inner = "" + "".join(parts) + "" escaped = inner.replace("<", "<").replace(">", ">") @@ -121,7 +121,7 @@ def test_from_data_event_experiment_step_sequence_fallback(self): inner = ( '' '10000' - '' + "" ) escaped = inner.replace("<", "<").replace(">", ">") payload = {"requestId": 1, "dataValue": f"{escaped}"} @@ -139,7 +139,9 @@ def test_premethod_constant(self): 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=[]) + 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): @@ -593,23 +595,23 @@ def test_update_state_from_status(self): self.interface._update_state_from_status("standby") self.assertEqual(self.interface._current_state, SiLAState.STANDBY) - + # Test camelCase enum values (exact match required) self.interface._update_state_from_status("inError") self.assertEqual(self.interface._current_state, SiLAState.INERROR) - + self.interface._update_state_from_status("errorHandling") self.assertEqual(self.interface._current_state, SiLAState.ERRORHANDLING) - + # Test that case mismatches are NOT accepted (exact matching only) # These should keep the current state and log a warning initial_state = self.interface._current_state self.interface._update_state_from_status("Idle") # Wrong case self.assertEqual(self.interface._current_state, initial_state) # Should remain unchanged - + self.interface._update_state_from_status("BUSY") # Wrong case self.assertEqual(self.interface._current_state, initial_state) # Should remain unchanged - + self.interface._update_state_from_status("INERROR") # Wrong case self.assertEqual(self.interface._current_state, initial_state) # Should remain unchanged @@ -865,7 +867,7 @@ async def test_get_status(self): return_value={ "GetStatusResponse": { "state": "idle", - "GetStatusResult": {"returnCode": 1, "message": "Success."} + "GetStatusResult": {"returnCode": 1, "message": "Success."}, } } ) @@ -957,9 +959,7 @@ async def test_unlock_device(self): self.backend._sila._lock_id = "my_lock_id" self.backend._sila.send_command = AsyncMock() # type: ignore[method-assign] await self.backend.unlock_device() - self.backend._sila.send_command.assert_called_once_with( - "UnlockDevice", lock_id="my_lock_id" - ) + self.backend._sila.send_command.assert_called_once_with("UnlockDevice", lock_id="my_lock_id") async def test_unlock_device_not_locked(self): """Test unlock_device when device is not locked.""" @@ -970,7 +970,7 @@ async def test_unlock_device_not_locked(self): async def test_get_block_current_temperature(self): """Test get_block_current_temperature.""" - sensor_xml = '2500' + sensor_xml = "2500" root = ET.Element("ResponseData") param = ET.SubElement(root, "Parameter", name="SensorValues") string_elem = ET.SubElement(param, "String") @@ -983,7 +983,7 @@ async def test_get_block_current_temperature(self): async def test_get_lid_current_temperature(self): """Test get_lid_current_temperature.""" - sensor_xml = '2600' + sensor_xml = "2600" root = ET.Element("ResponseData") param = ET.SubElement(root, "Parameter", name="SensorValues") string_elem = ET.SubElement(param, "String") @@ -1135,9 +1135,7 @@ async def test_execute_method_first_event_timeout(self): ) self.backend._sila.start_command = AsyncMock(return_value=(fut, 12345, 0.0)) # type: ignore[method-assign] self.backend._sila.wait_for_first_event = AsyncMock( # type: ignore[method-assign] - side_effect=FirstEventTimeout( - "No DataEvent received for request_id 12345 within 60.0s" - ) + side_effect=FirstEventTimeout("No DataEvent received for request_id 12345 within 60.0s") ) with self.assertRaises(FirstEventTimeout) as cm: await self.backend.execute_method("MyMethod", wait=False) @@ -1268,19 +1266,13 @@ async def test_reset_wait_false_returns_handle_with_kwargs(self): async def test_is_method_running(self): """Test is_method_running().""" - with patch.object( - ODTCBackend, "get_status", new_callable=AsyncMock, return_value="busy" - ): + with patch.object(ODTCBackend, "get_status", new_callable=AsyncMock, return_value="busy"): self.assertTrue(await self.backend.is_method_running()) - with patch.object( - ODTCBackend, "get_status", new_callable=AsyncMock, return_value="idle" - ): + with patch.object(ODTCBackend, "get_status", new_callable=AsyncMock, return_value="idle"): self.assertFalse(await self.backend.is_method_running()) - with patch.object( - ODTCBackend, "get_status", new_callable=AsyncMock, return_value="BUSY" - ): + with patch.object(ODTCBackend, "get_status", new_callable=AsyncMock, return_value="BUSY"): # Backend compares to SiLAState.BUSY.value ("busy"), so uppercase is False self.assertFalse(await self.backend.is_method_running()) @@ -1403,9 +1395,7 @@ async def test_run_stored_protocol_calls_execute_method(self): self.backend, "get_method_set", new_callable=AsyncMock, return_value=ODTCMethodSet() ): await self.backend.run_stored_protocol("MyMethod", wait=True) - self.backend.execute_method.assert_called_once_with( - "MyMethod", wait=True, protocol=None - ) + self.backend.execute_method.assert_called_once_with("MyMethod", wait=True, protocol=None) class TestODTCThermocycler(unittest.TestCase): @@ -1525,10 +1515,7 @@ def test_data_event_storage_logic(self): data_events_by_request_id: Dict[int, List[Dict[str, Any]]] = {} # Simulate receiving a DataEvent - data_event = { - "requestId": 12345, - "data": "test_data" - } + data_event = {"requestId": 12345, "data": "test_data"} # Apply the same logic as in _on_http handler request_id = data_event.get("requestId") @@ -1540,16 +1527,10 @@ def test_data_event_storage_logic(self): # 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 - ) + 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" - } + 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: @@ -1559,9 +1540,7 @@ def test_data_event_storage_logic(self): 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" - } + 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: @@ -1637,7 +1616,9 @@ 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])) + 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] @@ -1666,7 +1647,9 @@ def test_round_trip_via_stages_serializes_and_reparses(self): steps=[], # No steps; serialization will use stages stages=[outer], ) - xml_str = method_set_to_xml(ODTCMethodSet(delete_all_methods=False, premethods=[], methods=[odtc])) + 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] diff --git a/pylabrobot/thermocycling/inheco/odtc_thermocycler.py b/pylabrobot/thermocycling/inheco/odtc_thermocycler.py index 532b61060ac..7b6262f3430 100644 --- a/pylabrobot/thermocycling/inheco/odtc_thermocycler.py +++ b/pylabrobot/thermocycling/inheco/odtc_thermocycler.py @@ -8,7 +8,7 @@ from pylabrobot.thermocycling.thermocycler import Thermocycler from .odtc_backend import ODTCBackend -from .odtc_model import ODTCConfig, ODTCHardwareConstraints, ODTC_DIMENSIONS +from .odtc_model import ODTC_DIMENSIONS, ODTCConfig, ODTCHardwareConstraints def _model_from_variant(variant: int) -> str: diff --git a/pylabrobot/thermocycling/opentrons_backend.py b/pylabrobot/thermocycling/opentrons_backend.py index f6ace7f85a6..11b6f61b142 100644 --- a/pylabrobot/thermocycling/opentrons_backend.py +++ b/pylabrobot/thermocycling/opentrons_backend.py @@ -91,9 +91,7 @@ async def deactivate_lid(self): """Deactivate the lid heater.""" return thermocycler_deactivate_lid(module_id=self.opentrons_id) - async def run_protocol( - self, protocol: Protocol, block_max_volume: float, **kwargs - ): + async def run_protocol(self, protocol: Protocol, block_max_volume: float, **kwargs): """Enqueue and return immediately (no wait) the PCR profile command.""" # flatten the protocol to a list of Steps diff --git a/pylabrobot/thermocycling/opentrons_backend_usb.py b/pylabrobot/thermocycling/opentrons_backend_usb.py index 481a8edc6de..b8003bf5320 100644 --- a/pylabrobot/thermocycling/opentrons_backend_usb.py +++ b/pylabrobot/thermocycling/opentrons_backend_usb.py @@ -166,9 +166,7 @@ async def _execute_cycles( volume=volume, ) - async def run_protocol( - self, protocol: Protocol, block_max_volume: float, **kwargs - ): + 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/thermocycler.py b/pylabrobot/thermocycling/thermocycler.py index cb1dc1aa117..1fd3df4ad82 100644 --- a/pylabrobot/thermocycling/thermocycler.py +++ b/pylabrobot/thermocycling/thermocycler.py @@ -117,9 +117,7 @@ async def run_protocol( f"Expected {num_zones}, got {len(step.temperature)} in step {i}." ) - return await self.backend.run_protocol( - protocol, block_max_volume, **backend_kwargs - ) + return await self.backend.run_protocol(protocol, block_max_volume, **backend_kwargs) async def run_stored_protocol( self, From d49f7c669c6acec9834ed847d38ad859c57229d8 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:51:17 -0800 Subject: [PATCH 28/28] ODTC Dimensions and serialization fix --- pylabrobot/thermocycling/inheco/__init__.py | 6 +++--- .../thermocycling/inheco/odtc_backend.py | 19 ++++++++++++++----- pylabrobot/thermocycling/inheco/odtc_model.py | 2 +- .../thermocycling/inheco/odtc_thermocycler.py | 2 +- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/pylabrobot/thermocycling/inheco/__init__.py b/pylabrobot/thermocycling/inheco/__init__.py index 76ce50cda53..b0b82b14ac1 100644 --- a/pylabrobot/thermocycling/inheco/__init__.py +++ b/pylabrobot/thermocycling/inheco/__init__.py @@ -14,9 +14,9 @@ backend = ODTCBackend(odtc_ip="192.168.1.100", variant=384) tc = Thermocycler( name="odtc1", - size_x=147, - size_y=298, - size_z=130, + size_x=156.5, + size_y=248, + size_z=124.3, backend=backend, child_location=..., ) diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/thermocycling/inheco/odtc_backend.py index 28e937ee80f..589ce0d333f 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/thermocycling/inheco/odtc_backend.py @@ -331,9 +331,9 @@ class ODTCBackend(ThermocyclerBackend): Uses ODTCSiLAInterface for low-level SiLA communication with parallelism, state management, and lockId validation. - ODTC dimensions for Thermocycler: size_x=147, size_y=298, size_z=130 (mm). + ODTC dimensions for Thermocycler: size_x=156.5, size_y=248, size_z=124.3 (mm). Construct: backend = ODTCBackend(odtc_ip="...", variant=384000); then - Thermocycler(name="odtc1", size_x=147, size_y=298, size_z=130, backend=backend, ...). + Thermocycler(name="odtc1", size_x=156.5, size_y=248, size_z=124.3, backend=backend, ...). """ def __init__( @@ -521,13 +521,22 @@ async def stop(self) -> None: await self._sila.close() def serialize(self) -> dict: - """Return serialized representation of the backend.""" - return { + """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, - "port": self._sila.bound_port, } + try: + out["port"] = self._sila.bound_port + except RuntimeError: + # Server not started yet; omit port so deck can be serialized without connecting + pass + return out def _get_effective_lifetime(self) -> float: """Effective max wait for async command completion (seconds).""" diff --git a/pylabrobot/thermocycling/inheco/odtc_model.py b/pylabrobot/thermocycling/inheco/odtc_model.py index fbaf131af60..6f9fa0a3d6c 100644 --- a/pylabrobot/thermocycling/inheco/odtc_model.py +++ b/pylabrobot/thermocycling/inheco/odtc_model.py @@ -97,7 +97,7 @@ class ODTCDimensions: z: float -ODTC_DIMENSIONS = ODTCDimensions(x=147.0, y=298.0, z=130.0) +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 diff --git a/pylabrobot/thermocycling/inheco/odtc_thermocycler.py b/pylabrobot/thermocycling/inheco/odtc_thermocycler.py index 7b6262f3430..8d11cf8fb60 100644 --- a/pylabrobot/thermocycling/inheco/odtc_thermocycler.py +++ b/pylabrobot/thermocycling/inheco/odtc_thermocycler.py @@ -24,7 +24,7 @@ class ODTCThermocycler(Thermocycler): """Inheco ODTC thermocycler resource. Owns connection params (odtc_ip, variant) and creates ODTCBackend by default. - Dimensions (147 x 298 x 130 mm) are set from ODTC_DIMENSIONS. + Dimensions (156.5 x 248 x 124.3 mm) are set from ODTC_DIMENSIONS. """ def __init__(