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__(