diff --git a/pylabrobot/capabilities/thermocycling/__init__.py b/pylabrobot/capabilities/thermocycling/__init__.py
new file mode 100644
index 00000000000..ea302f8024c
--- /dev/null
+++ b/pylabrobot/capabilities/thermocycling/__init__.py
@@ -0,0 +1,3 @@
+from .backend import ThermocyclingBackend
+from .standard import Protocol, Stage, Step
+from .thermocycling import ThermocyclingCapability
diff --git a/pylabrobot/capabilities/thermocycling/backend.py b/pylabrobot/capabilities/thermocycling/backend.py
new file mode 100644
index 00000000000..1bf0f41b77c
--- /dev/null
+++ b/pylabrobot/capabilities/thermocycling/backend.py
@@ -0,0 +1,55 @@
+from abc import ABCMeta, abstractmethod
+
+from pylabrobot.device import DeviceBackend
+
+from .standard import Protocol
+
+
+class ThermocyclingBackend(DeviceBackend, metaclass=ABCMeta):
+ """Abstract backend for thermocyclers.
+
+ Only thermocycling-specific operations live here: lid control, protocol
+ execution, and profile progress queries. Block/lid temperature control
+ is handled by separate TemperatureControllerBackend instances.
+ """
+
+ @abstractmethod
+ async def open_lid(self) -> None:
+ """Open the thermocycler lid."""
+
+ @abstractmethod
+ async def close_lid(self) -> None:
+ """Close the thermocycler lid."""
+
+ @abstractmethod
+ async def get_lid_open(self) -> bool:
+ """Return True if the lid is open."""
+
+ @abstractmethod
+ async def run_protocol(self, protocol: Protocol, block_max_volume: float) -> None:
+ """Execute a thermocycler protocol.
+
+ Args:
+ protocol: Protocol containing stages with steps and repeats.
+ block_max_volume: Maximum block volume in uL.
+ """
+
+ @abstractmethod
+ async def get_hold_time(self) -> float:
+ """Get remaining hold time in seconds."""
+
+ @abstractmethod
+ async def get_current_cycle_index(self) -> int:
+ """Get the zero-based index of the current cycle."""
+
+ @abstractmethod
+ async def get_total_cycle_count(self) -> int:
+ """Get the total cycle count."""
+
+ @abstractmethod
+ async def get_current_step_index(self) -> int:
+ """Get the zero-based index of the current step within the cycle."""
+
+ @abstractmethod
+ async def get_total_step_count(self) -> int:
+ """Get the total number of steps in the current cycle."""
diff --git a/pylabrobot/capabilities/thermocycling/standard.py b/pylabrobot/capabilities/thermocycling/standard.py
new file mode 100644
index 00000000000..92f3e20dcab
--- /dev/null
+++ b/pylabrobot/capabilities/thermocycling/standard.py
@@ -0,0 +1,65 @@
+import enum
+from dataclasses import dataclass
+from typing import List, Optional
+
+from pylabrobot.serializer import SerializableMixin
+
+
+@dataclass
+class Step(SerializableMixin):
+ """A single step in a thermocycler profile."""
+
+ temperature: List[float]
+ hold_seconds: float
+ rate: Optional[float] = None # degrees Celsius per second
+
+ def serialize(self) -> dict:
+ return {
+ "temperature": self.temperature,
+ "hold_seconds": self.hold_seconds,
+ "rate": self.rate,
+ }
+
+
+@dataclass
+class Stage(SerializableMixin):
+ """A stage in a thermocycler protocol: a list of steps repeated N times."""
+
+ steps: List[Step]
+ repeats: int
+
+ def serialize(self) -> dict:
+ return {
+ "steps": [step.serialize() for step in self.steps],
+ "repeats": self.repeats,
+ }
+
+
+@dataclass
+class Protocol(SerializableMixin):
+ """A thermocycler protocol: a list of stages."""
+
+ stages: List[Stage]
+
+ def serialize(self) -> dict:
+ return {
+ "stages": [stage.serialize() for stage in self.stages],
+ }
+
+ @classmethod
+ def deserialize(cls, data: dict) -> "Protocol":
+ stages = []
+ for stage_data in data.get("stages", []):
+ steps = [Step(**step) for step in stage_data["steps"]]
+ stages.append(Stage(steps=steps, repeats=stage_data.get("repeats", 1)))
+ return cls(stages=stages)
+
+
+class LidStatus(enum.Enum):
+ IDLE = "idle"
+ HOLDING_AT_TARGET = "holding at target"
+
+
+class BlockStatus(enum.Enum):
+ IDLE = "idle"
+ HOLDING_AT_TARGET = "holding at target"
diff --git a/pylabrobot/capabilities/thermocycling/thermocycling.py b/pylabrobot/capabilities/thermocycling/thermocycling.py
new file mode 100644
index 00000000000..5aa25a3aa5b
--- /dev/null
+++ b/pylabrobot/capabilities/thermocycling/thermocycling.py
@@ -0,0 +1,152 @@
+import asyncio
+from typing import List, Optional
+
+from pylabrobot.capabilities.capability import Capability
+from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability
+
+from .backend import ThermocyclingBackend
+from .standard import Protocol, Stage, Step
+
+
+class ThermocyclingCapability(Capability):
+ """Thermocycling capability.
+
+ Owns protocol execution (delegated to the backend) and lid control.
+ Block and lid temperature control for ad-hoc use (outside of protocol runs)
+ is accessed via the `block` and `lid` TemperatureControlCapability instances.
+ """
+
+ def __init__(
+ self,
+ backend: ThermocyclingBackend,
+ block: TemperatureControlCapability,
+ lid: TemperatureControlCapability,
+ ):
+ super().__init__(backend=backend)
+ self.backend: ThermocyclingBackend = backend
+ self.block = block
+ self.lid = lid
+
+ async def open_lid(self) -> None:
+ await self.backend.open_lid()
+
+ async def close_lid(self) -> None:
+ await self.backend.close_lid()
+
+ async def get_lid_open(self) -> bool:
+ return await self.backend.get_lid_open()
+
+ async def run_protocol(self, protocol: Protocol, block_max_volume: float) -> None:
+ """Execute a thermocycler protocol.
+
+ Args:
+ protocol: Protocol containing stages with steps and repeats.
+ block_max_volume: Maximum block volume in uL.
+ """
+ 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}."
+ )
+
+ await self.backend.run_protocol(protocol, block_max_volume)
+
+ async def run_pcr_profile(
+ self,
+ denaturation_temp: List[float],
+ denaturation_time: float,
+ annealing_temp: List[float],
+ annealing_time: float,
+ extension_temp: List[float],
+ extension_time: float,
+ num_cycles: int,
+ block_max_volume: float,
+ lid_temperature: float,
+ pre_denaturation_temp: Optional[List[float]] = None,
+ pre_denaturation_time: Optional[float] = None,
+ final_extension_temp: Optional[List[float]] = None,
+ final_extension_time: Optional[float] = None,
+ storage_temp: Optional[List[float]] = None,
+ storage_time: Optional[float] = None,
+ ) -> None:
+ """Build and run a standard PCR profile.
+
+ Sets the lid temperature first, waits for it, then runs the protocol.
+ """
+ await self.lid.set_temperature(lid_temperature)
+ await self.lid.wait_for_temperature()
+
+ stages: List[Stage] = []
+
+ if pre_denaturation_temp is not None and pre_denaturation_time is not None:
+ stages.append(
+ Stage(
+ steps=[Step(temperature=pre_denaturation_temp, hold_seconds=pre_denaturation_time)],
+ repeats=1,
+ )
+ )
+
+ stages.append(
+ Stage(
+ steps=[
+ Step(temperature=denaturation_temp, hold_seconds=denaturation_time),
+ Step(temperature=annealing_temp, hold_seconds=annealing_time),
+ Step(temperature=extension_temp, hold_seconds=extension_time),
+ ],
+ repeats=num_cycles,
+ )
+ )
+
+ if final_extension_temp is not None and final_extension_time is not None:
+ stages.append(
+ Stage(
+ steps=[Step(temperature=final_extension_temp, hold_seconds=final_extension_time)],
+ repeats=1,
+ )
+ )
+
+ if storage_temp is not None and storage_time is not None:
+ stages.append(
+ Stage(steps=[Step(temperature=storage_temp, hold_seconds=storage_time)], repeats=1)
+ )
+
+ await self.run_protocol(protocol=Protocol(stages=stages), block_max_volume=block_max_volume)
+
+ async def get_hold_time(self) -> float:
+ return await self.backend.get_hold_time()
+
+ async def get_current_cycle_index(self) -> int:
+ return await self.backend.get_current_cycle_index()
+
+ async def get_total_cycle_count(self) -> int:
+ return await self.backend.get_total_cycle_count()
+
+ async def get_current_step_index(self) -> int:
+ return await self.backend.get_current_step_index()
+
+ async def get_total_step_count(self) -> int:
+ return await self.backend.get_total_step_count()
+
+ async def is_profile_running(self) -> bool:
+ """Return True if a protocol is still in progress."""
+ hold = await self.backend.get_hold_time()
+ cycle = await self.backend.get_current_cycle_index()
+ total_cycles = await self.backend.get_total_cycle_count()
+ step = await self.backend.get_current_step_index()
+ total_steps = await self.backend.get_total_step_count()
+
+ if hold and hold > 0:
+ return True
+ if cycle < total_cycles - 1:
+ return True
+ if cycle == total_cycles - 1 and step < total_steps - 1:
+ return True
+ return False
+
+ async def wait_for_profile_completion(self, poll_interval: float = 60.0) -> None:
+ """Block until the protocol finishes."""
+ while await self.is_profile_running():
+ await asyncio.sleep(poll_interval)
diff --git a/pylabrobot/inheco/__init__.py b/pylabrobot/inheco/__init__.py
index c930f5d97f4..3255abd5060 100644
--- a/pylabrobot/inheco/__init__.py
+++ b/pylabrobot/inheco/__init__.py
@@ -1,5 +1,6 @@
from .control_box import InhecoTECControlBox
from .cpac import InhecoCPAC, InhecoCPACBackend, inheco_cpac_ultraflat
+from .odtc import ODTC, ODTCBlockBackend, ODTCDriver, ODTCLidBackend, ODTCThermocyclingBackend
from .thermoshake import (
InhecoThermoShake,
InhecoThermoshakeBackend,
diff --git a/pylabrobot/inheco/odtc/__init__.py b/pylabrobot/inheco/odtc/__init__.py
new file mode 100644
index 00000000000..6bb44e89dc6
--- /dev/null
+++ b/pylabrobot/inheco/odtc/__init__.py
@@ -0,0 +1,7 @@
+from .odtc import (
+ ODTC,
+ ODTCBlockBackend,
+ ODTCDriver,
+ ODTCLidBackend,
+ ODTCThermocyclingBackend,
+)
diff --git a/pylabrobot/inheco/odtc/odtc.py b/pylabrobot/inheco/odtc/odtc.py
new file mode 100644
index 00000000000..395835669e4
--- /dev/null
+++ b/pylabrobot/inheco/odtc/odtc.py
@@ -0,0 +1,438 @@
+"""Inheco ODTC thermocycler backend and device."""
+
+import asyncio
+import datetime
+import time
+import xml.etree.ElementTree as ET
+from typing import Any, Dict, Optional
+
+from pylabrobot.capabilities.temperature_controlling import (
+ TemperatureControlCapability,
+ TemperatureControllerBackend,
+)
+from pylabrobot.capabilities.thermocycling import (
+ Protocol,
+ ThermocyclingBackend,
+ ThermocyclingCapability,
+)
+from pylabrobot.device import Device, DeviceBackend
+from pylabrobot.inheco.scila.inheco_sila_interface import InhecoSiLAInterface, SiLAError
+from pylabrobot.resources import Coordinate, ResourceHolder
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _format_number(n: Any) -> str:
+ if n is None:
+ return "0"
+ try:
+ f = float(n)
+ return str(int(f)) if f.is_integer() else str(f)
+ except (ValueError, TypeError):
+ return str(n)
+
+
+def _recursive_find_key(data: Any, key: str) -> Any:
+ if isinstance(data, dict):
+ if key in data:
+ return data[key]
+ for v in data.values():
+ item = _recursive_find_key(v, key)
+ if item is not None:
+ return item
+ elif isinstance(data, list):
+ for v in data:
+ item = _recursive_find_key(v, key)
+ if item is not None:
+ return item
+ elif hasattr(data, "find"):
+ node = data.find(f".//{key}")
+ if node is not None:
+ return node.text
+ if str(data.tag).endswith(key):
+ return data.text
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Driver
+# ---------------------------------------------------------------------------
+
+
+class ODTCDriver(DeviceBackend):
+ """Low-level SiLA driver for the Inheco ODTC."""
+
+ def __init__(self, ip: str, client_ip: Optional[str] = None):
+ super().__init__()
+ self._sila = InhecoSiLAInterface(client_ip=client_ip, machine_ip=ip)
+
+ async def setup(self):
+ await self._sila.setup()
+ await self._reset_and_initialize()
+
+ async def stop(self):
+ await self._sila.close()
+
+ async def _reset_and_initialize(self) -> None:
+ try:
+ event_uri = f"http://{self._sila._client_ip}:{self._sila.bound_port}/"
+ await self._sila.send_command(
+ command="Reset", deviceId="ODTC", eventReceiverURI=event_uri, simulationMode=False
+ )
+ await self._sila.send_command("Initialize")
+ except Exception as e:
+ print(f"Warning during ODTC initialization: {e}")
+
+ async def wait_for_idle(self, timeout=30):
+ start = time.time()
+ while time.time() - start < timeout:
+ root = await self._sila.send_command("GetStatus")
+ st = _recursive_find_key(root, "state")
+ if st and st in ["idle", "standby"]:
+ return
+ await asyncio.sleep(1)
+ raise RuntimeError("Timeout waiting for ODTC idle state")
+
+ async def send_command(self, command: str, **kwargs):
+ return await self._sila.send_command(command, **kwargs)
+
+ async def get_sensor_data(self, cache: dict) -> Dict[str, float]:
+ """Read sensor data. Uses cache dict for 2-second caching."""
+ if time.time() - cache.get("_time", 0) < 2.0 and cache.get("_data"):
+ return cache["_data"]
+
+ try:
+ root = await self._sila.send_command("ReadActualTemperature")
+ embedded_xml = _recursive_find_key(root, "String")
+
+ if embedded_xml and isinstance(embedded_xml, str):
+ sensor_root = ET.fromstring(embedded_xml)
+ data = {}
+ for child in sensor_root:
+ if child.tag and child.text:
+ try:
+ data[child.tag] = float(child.text) / 100.0
+ except ValueError:
+ pass
+ cache["_data"] = data
+ cache["_time"] = time.time()
+ return data
+ except Exception as e:
+ print(f"Error reading sensor data: {e}")
+ return cache.get("_data", {})
+
+
+# ---------------------------------------------------------------------------
+# Capability backends
+# ---------------------------------------------------------------------------
+
+
+class ODTCBlockBackend(TemperatureControllerBackend):
+ """Block temperature controller for the ODTC."""
+
+ def __init__(self, driver: ODTCDriver):
+ super().__init__()
+ self._driver = driver
+ self._target: Optional[float] = None
+ self._lid_target: Optional[float] = None
+ self._sensor_cache: Dict[str, Any] = {}
+
+ @property
+ def supports_active_cooling(self) -> bool:
+ return True
+
+ async def setup(self):
+ pass
+
+ async def stop(self):
+ pass
+
+ async def set_temperature(self, temperature: float):
+ self._target = temperature
+ lid = self._lid_target if self._lid_target is not None else 105.0
+ await self._run_pre_method(temperature, lid)
+
+ async def get_current_temperature(self) -> float:
+ data = await self._driver.get_sensor_data(self._sensor_cache)
+ return data.get("Mount", 0.0)
+
+ async def deactivate(self):
+ await self._driver.send_command("StopMethod")
+
+ async def _run_pre_method(self, block_temp: float, lid_temp: float):
+ now = datetime.datetime.now().astimezone()
+ method_name = f"PLR_Hold_{now.strftime('%Y%m%d_%H%M%S')}"
+
+ methods_xml = (
+ f''
+ f""
+ f"false"
+ f''
+ f"{_format_number(block_temp)}"
+ f"{_format_number(lid_temp)}"
+ f"true"
+ f""
+ f""
+ )
+
+ ps = ET.Element("ParameterSet")
+ pm = ET.SubElement(ps, "Parameter", name="MethodsXML")
+ ET.SubElement(pm, "String").text = methods_xml
+ params_xml = ET.tostring(ps, encoding="unicode")
+
+ await self._driver.send_command("StopMethod")
+ await self._driver.wait_for_idle()
+ await self._driver.send_command("SetParameters", paramsXML=params_xml)
+ await self._driver.send_command("ExecuteMethod", methodName=method_name)
+
+
+class ODTCLidBackend(TemperatureControllerBackend):
+ """Lid temperature controller for the ODTC."""
+
+ def __init__(self, driver: ODTCDriver, block_backend: ODTCBlockBackend):
+ super().__init__()
+ self._driver = driver
+ self._block = block_backend
+ self._sensor_cache: Dict[str, Any] = {}
+
+ @property
+ def supports_active_cooling(self) -> bool:
+ return False
+
+ async def setup(self):
+ pass
+
+ async def stop(self):
+ pass
+
+ async def set_temperature(self, temperature: float):
+ self._block._lid_target = temperature
+ block = self._block._target if self._block._target is not None else 25.0
+ await self._block._run_pre_method(block, temperature)
+
+ async def get_current_temperature(self) -> float:
+ data = await self._driver.get_sensor_data(self._sensor_cache)
+ return data.get("Lid", 0.0)
+
+ async def deactivate(self):
+ raise NotImplementedError("ODTC lid cannot be deactivated independently.")
+
+
+class ODTCThermocyclingBackend(ThermocyclingBackend):
+ """Thermocycling backend for the ODTC."""
+
+ def __init__(self, driver: ODTCDriver):
+ super().__init__()
+ self._driver = driver
+
+ async def setup(self):
+ pass
+
+ async def stop(self):
+ await self._driver.send_command("StopMethod")
+
+ async def open_lid(self) -> None:
+ await self._driver.send_command("OpenDoor")
+
+ async def close_lid(self) -> None:
+ await self._driver.send_command("CloseDoor")
+
+ async def get_lid_open(self) -> bool:
+ raise NotImplementedError()
+
+ async def run_protocol(
+ self,
+ protocol: Protocol,
+ block_max_volume: float = 20.0,
+ start_block_temperature: float = 25.0,
+ start_lid_temperature: float = 30.0,
+ post_heating: bool = True,
+ method_name: Optional[str] = None,
+ **kwargs,
+ ) -> None:
+ method_xml, method_name = _generate_method_xml(
+ protocol=protocol,
+ block_max_volume=block_max_volume,
+ start_block_temperature=start_block_temperature,
+ start_lid_temperature=start_lid_temperature,
+ post_heating=post_heating,
+ method_name=method_name,
+ **kwargs,
+ )
+
+ ps = ET.Element("ParameterSet")
+ pm = ET.SubElement(ps, "Parameter", name="MethodsXML")
+ ET.SubElement(pm, "String").text = method_xml
+ params_xml = ET.tostring(ps, encoding="unicode")
+
+ await self._driver.send_command("SetParameters", paramsXML=params_xml)
+ try:
+ await self._driver.send_command("ExecuteMethod", methodName=method_name)
+ except SiLAError as e:
+ if e.code == 12: # SuccessWithWarning
+ print(f"[ODTC Warning] {e.message}")
+ else:
+ raise
+
+ async def get_hold_time(self) -> float:
+ raise NotImplementedError()
+
+ async def get_current_cycle_index(self) -> int:
+ raise NotImplementedError()
+
+ async def get_total_cycle_count(self) -> int:
+ raise NotImplementedError()
+
+ async def get_current_step_index(self) -> int:
+ raise NotImplementedError()
+
+ async def get_total_step_count(self) -> int:
+ raise NotImplementedError()
+
+
+# ---------------------------------------------------------------------------
+# XML generation helper
+# ---------------------------------------------------------------------------
+
+
+def _generate_method_xml(
+ protocol: Protocol,
+ block_max_volume: float,
+ start_block_temperature: float,
+ start_lid_temperature: float,
+ post_heating: bool,
+ method_name: Optional[str] = None,
+ **kwargs,
+) -> tuple:
+ if not method_name:
+ method_name = f"PLR_Protocol_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
+
+ if block_max_volume < 30.0:
+ fluid_quantity = "0"
+ elif block_max_volume < 75.0:
+ fluid_quantity = "1"
+ else:
+ fluid_quantity = "2"
+
+ now = datetime.datetime.now().astimezone()
+ now_str = now.isoformat()
+
+ root = ET.Element("MethodSet")
+ ET.SubElement(root, "DeleteAllMethods").text = "false"
+
+ method_elem = ET.SubElement(
+ root, "Method", methodName=method_name, creator="PyLabRobot", dateTime=now_str
+ )
+ ET.SubElement(method_elem, "Variant").text = "960000"
+ ET.SubElement(method_elem, "PlateType").text = "0"
+ ET.SubElement(method_elem, "FluidQuantity").text = fluid_quantity
+ ET.SubElement(method_elem, "PostHeating").text = "true" if post_heating else "false"
+ ET.SubElement(method_elem, "StartBlockTemperature").text = _format_number(start_block_temperature)
+ ET.SubElement(method_elem, "StartLidTemperature").text = _format_number(start_lid_temperature)
+
+ def_slope = _format_number(kwargs.get("slope", "4.4"))
+ def_os_slope1 = _format_number(kwargs.get("overshoot_slope1", "0.1"))
+ def_os_temp = _format_number(kwargs.get("overshoot_temperature", "0"))
+ def_os_time = _format_number(kwargs.get("overshoot_time", "0"))
+ def_os_slope2 = _format_number(kwargs.get("overshoot_slope2", "0.1"))
+ pid_number = _format_number(kwargs.get("pid_number", "1"))
+
+ step_counter = 1
+ for stage in protocol.stages:
+ if not stage.steps:
+ continue
+ start_of_stage = step_counter
+
+ for i, step in enumerate(stage.steps):
+ b_temp = step.temperature[0] if step.temperature else 25
+ l_temp = start_lid_temperature
+ duration = step.hold_seconds
+ s_slope = _format_number(step.rate) if step.rate is not None else def_slope
+
+ s = ET.SubElement(method_elem, "Step")
+ ET.SubElement(s, "Number").text = str(step_counter)
+ ET.SubElement(s, "Slope").text = s_slope
+ ET.SubElement(s, "PlateauTemperature").text = _format_number(b_temp)
+ ET.SubElement(s, "PlateauTime").text = _format_number(duration)
+ ET.SubElement(s, "OverShootSlope1").text = def_os_slope1
+ ET.SubElement(s, "OverShootTemperature").text = def_os_temp
+ ET.SubElement(s, "OverShootTime").text = def_os_time
+ ET.SubElement(s, "OverShootSlope2").text = def_os_slope2
+
+ if i == len(stage.steps) - 1 and stage.repeats > 1:
+ ET.SubElement(s, "GotoNumber").text = str(start_of_stage)
+ ET.SubElement(s, "LoopNumber").text = str(stage.repeats - 1)
+ else:
+ ET.SubElement(s, "GotoNumber").text = "0"
+ ET.SubElement(s, "LoopNumber").text = "0"
+
+ ET.SubElement(s, "PIDNumber").text = pid_number
+ ET.SubElement(s, "LidTemp").text = _format_number(l_temp)
+ step_counter += 1
+
+ pid_set = ET.SubElement(method_elem, "PIDSet")
+ pid = ET.SubElement(pid_set, "PID", number=pid_number)
+ defaults = {
+ "PHeating": "60",
+ "PCooling": "80",
+ "IHeating": "250",
+ "ICooling": "100",
+ "DHeating": "10",
+ "DCooling": "10",
+ "PLid": "100",
+ "ILid": "70",
+ }
+ for k, v in defaults.items():
+ val = kwargs.get(k, v)
+ ET.SubElement(pid, k).text = _format_number(val)
+
+ xml_str = '' + ET.tostring(root, encoding="unicode")
+ return xml_str, method_name
+
+
+# ---------------------------------------------------------------------------
+# Device
+# ---------------------------------------------------------------------------
+
+
+class ODTC(ResourceHolder, Device):
+ """Inheco ODTC thermocycler."""
+
+ def __init__(
+ self,
+ name: str,
+ ip: str,
+ client_ip: Optional[str] = None,
+ child_location: Coordinate = Coordinate.zero(),
+ size_x: float = 150.0,
+ size_y: float = 150.0,
+ size_z: float = 200.0,
+ ):
+ self._driver = ODTCDriver(ip=ip, client_ip=client_ip)
+
+ ResourceHolder.__init__(
+ self,
+ name=name,
+ size_x=size_x,
+ size_y=size_y,
+ size_z=size_z,
+ child_location=child_location,
+ category="thermocycler",
+ model="Inheco ODTC",
+ )
+ Device.__init__(self, backend=self._driver)
+
+ block_be = ODTCBlockBackend(self._driver)
+ lid_be = ODTCLidBackend(self._driver, block_backend=block_be)
+ tc_be = ODTCThermocyclingBackend(self._driver)
+
+ self.block = TemperatureControlCapability(backend=block_be)
+ self.lid = TemperatureControlCapability(backend=lid_be)
+ self.thermocycling = ThermocyclingCapability(backend=tc_be, block=self.block, lid=self.lid)
+ self._capabilities = [self.block, self.lid, self.thermocycling]
+
+ def serialize(self) -> dict:
+ return {**ResourceHolder.serialize(self), **Device.serialize(self)}
diff --git a/pylabrobot/legacy/thermocycling/inheco/odtc_backend.py b/pylabrobot/legacy/thermocycling/inheco/odtc_backend.py
index 327a60955e3..fcf1f6ecf38 100644
--- a/pylabrobot/legacy/thermocycling/inheco/odtc_backend.py
+++ b/pylabrobot/legacy/thermocycling/inheco/odtc_backend.py
@@ -1,94 +1,42 @@
-import asyncio
-import datetime
-import time
-import xml.etree.ElementTree as ET
-from typing import Any, Dict, List, Optional
+"""Legacy. Use pylabrobot.inheco.odtc instead."""
-from pylabrobot.legacy.storage.inheco.scila.inheco_sila_interface import (
- InhecoSiLAInterface,
- SiLAError,
-)
-from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend
-from pylabrobot.legacy.thermocycling.standard import BlockStatus, LidStatus, Protocol
-
-
-def _format_number(n: Any) -> str:
- if n is None:
- return "0"
- try:
- f = float(n)
- return str(int(f)) if f.is_integer() else str(f)
- except (ValueError, TypeError):
- return str(n)
+from typing import Dict, List, Optional
-
-def _recursive_find_key(data: Any, key: str) -> Any:
- if isinstance(data, dict):
- if key in data:
- return data[key]
- for v in data.values():
- item = _recursive_find_key(v, key)
- if item is not None:
- return item
- elif isinstance(data, list):
- for v in data:
- item = _recursive_find_key(v, key)
- if item is not None:
- return item
- elif hasattr(data, "find"):
- node = data.find(f".//{key}")
- if node is not None:
- return node.text
- if str(data.tag).endswith(key):
- return data.text
- return None
+from pylabrobot.inheco.odtc.odtc import ODTCBlockBackend, ODTCDriver, ODTCThermocyclingBackend
+from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend
+from pylabrobot.legacy.thermocycling.standard import (
+ BlockStatus,
+ LidStatus,
+ Protocol,
+ protocol_to_new,
+)
class ExperimentalODTCBackend(ThermocyclerBackend):
+ """Legacy. Use pylabrobot.inheco.odtc.ODTC instead."""
+
def __init__(self, ip: str, client_ip: Optional[str] = None) -> None:
- self._sila_interface = InhecoSiLAInterface(client_ip=client_ip, machine_ip=ip)
+ self._driver = ODTCDriver(ip=ip, client_ip=client_ip)
+ self._tc = ODTCThermocyclingBackend(self._driver)
self._block_target_temp: Optional[float] = None
self._lid_target_temp: Optional[float] = None
- self._current_sensors: Dict[str, float] = {}
- self._temp_update_time: float = 0
+ self._sensor_cache: Dict = {}
+
+ @property
+ def _sila_interface(self):
+ return self._driver._sila
async def setup(self) -> None:
- await self._sila_interface.setup()
- await self._reset_and_initialize()
+ await self._driver.setup()
async def stop(self):
- await self._sila_interface.close()
-
- async def _reset_and_initialize(self) -> None:
- try:
- event_uri = f"http://{self._sila_interface._client_ip}:{self._sila_interface.bound_port}/"
- await self._sila_interface.send_command(
- command="Reset", deviceId="ODTC", eventReceiverURI=event_uri, simulationMode=False
- )
- await self._sila_interface.send_command("Initialize")
- except Exception as e:
- print(f"Warning during ODTC initialization: {e}")
-
- async def _wait_for_idle(self, timeout=30):
- """Wait until device state is not Busy."""
- start = time.time()
- while time.time() - start < timeout:
- root = await self._sila_interface.send_command("GetStatus")
- st = _recursive_find_key(root, "state")
- if st and st in ["idle", "standby"]:
- return
- await asyncio.sleep(1)
- raise RuntimeError("Timeout waiting for ODTC idle state")
-
- # -------------------------------------------------------------------------
- # Lid
- # -------------------------------------------------------------------------
+ await self._driver.stop()
async def open_lid(self):
- await self._sila_interface.send_command("OpenDoor")
+ await self._tc.open_lid()
async def close_lid(self):
- await self._sila_interface.send_command("CloseDoor")
+ await self._tc.close_lid()
async def get_lid_open(self) -> bool:
raise NotImplementedError()
@@ -96,96 +44,24 @@ async def get_lid_open(self) -> bool:
async def get_lid_status(self) -> LidStatus:
raise NotImplementedError()
- # -------------------------------------------------------------------------
- # Temperature Helpers
- # -------------------------------------------------------------------------
-
async def get_sensor_data(self) -> Dict[str, float]:
- """
- Get all sensor data from the device.
- Returns a dictionary with keys: 'Mount', 'Mount_Monitor', 'Lid', 'Lid_Monitor',
- 'Ambient', 'PCB', 'Heatsink', 'Heatsink_TEC'.
- Values are in degrees Celsius.
- """
- if time.time() - self._temp_update_time < 2.0 and self._current_sensors:
- return self._current_sensors
-
- try:
- root = await self._sila_interface.send_command("ReadActualTemperature")
-
- embedded_xml = _recursive_find_key(root, "String")
-
- if embedded_xml and isinstance(embedded_xml, str):
- sensor_root = ET.fromstring(embedded_xml)
-
- data = {}
- for child in sensor_root:
- if child.tag and child.text:
- try:
- # Values are integers scaled by 100 (3700 -> 37.0 C)
- data[child.tag] = float(child.text) / 100.0
- except ValueError:
- pass
-
- self._current_sensors = data
- self._temp_update_time = time.time()
- return self._current_sensors
- except Exception as e:
- print(f"Error reading sensor data: {e}")
- pass
- return self._current_sensors
-
- async def _run_pre_method(self, block_temp: float, lid_temp: float, dynamic_time: bool = True):
- """
- Define and run a PreMethod (Hold) used for setting constant temperature.
- WARNING: ODTC pre-methods take 7-10 minutes to pre-warm evenly the block and lid before a run.
- This command is not ideal for quick temperature changes.
- dynamic_time: if True, method will complete in less than 10 minutes (like 7)
- if False, command holds temp for 10 minutes before proceeding
- """
- now = datetime.datetime.now().astimezone()
- method_name = f"PLR_Hold_{now.strftime('%Y%m%d_%H%M%S')}"
-
- methods_xml = (
- f''
- f""
- f"false"
- f''
- f"{_format_number(block_temp)}"
- f"{_format_number(lid_temp)}"
- f"{'true' if dynamic_time else 'false'}"
- f""
- f""
- )
-
- ps = ET.Element("ParameterSet")
- pm = ET.SubElement(ps, "Parameter", name="MethodsXML")
- ET.SubElement(pm, "String").text = methods_xml
- params_xml = ET.tostring(ps, encoding="unicode")
-
- await self.stop_method()
- await self._wait_for_idle()
-
- await self._sila_interface.send_command("SetParameters", paramsXML=params_xml)
- await self._sila_interface.send_command("ExecuteMethod", methodName=method_name)
-
- # -------------------------------------------------------------------------
- # Block Temperature
- # -------------------------------------------------------------------------
+ return await self._driver.get_sensor_data(self._sensor_cache)
async def set_block_temperature(self, temperature: List[float], dynamic_time: bool = True):
if not temperature:
return
self._block_target_temp = temperature[0]
lid = self._lid_target_temp if self._lid_target_temp is not None else 105.0
- await self._run_pre_method(self._block_target_temp, lid, dynamic_time=dynamic_time)
+ block_be = ODTCBlockBackend(self._driver)
+ block_be._lid_target = lid
+ await block_be._run_pre_method(self._block_target_temp, lid)
async def deactivate_block(self):
- await self.stop_method()
+ await self._driver.send_command("StopMethod")
async def get_block_current_temperature(self) -> List[float]:
- temps = await self.get_sensor_data()
- return [temps.get("Mount", 0.0)]
+ data = await self._driver.get_sensor_data(self._sensor_cache)
+ return [data.get("Mount", 0.0)]
async def get_block_target_temperature(self) -> List[float]:
raise NotImplementedError()
@@ -193,137 +69,25 @@ async def get_block_target_temperature(self) -> List[float]:
async def get_block_status(self) -> BlockStatus:
raise NotImplementedError()
- # -------------------------------------------------------------------------
- # Lid Temperature
- # -------------------------------------------------------------------------
-
async def set_lid_temperature(self, temperature: List[float], dynamic_time: bool = True):
if not temperature:
return
self._lid_target_temp = temperature[0]
block = self._block_target_temp if self._block_target_temp is not None else 25.0
- await self._run_pre_method(block, self._lid_target_temp, dynamic_time=dynamic_time)
+ block_be = ODTCBlockBackend(self._driver)
+ block_be._lid_target = self._lid_target_temp
+ await block_be._run_pre_method(block, self._lid_target_temp)
async def deactivate_lid(self):
raise NotImplementedError()
async def get_lid_current_temperature(self) -> List[float]:
- temps = await self.get_sensor_data()
- return [temps.get("Lid", 0.0)]
+ data = await self._driver.get_sensor_data(self._sensor_cache)
+ return [data.get("Lid", 0.0)]
async def get_lid_target_temperature(self) -> List[float]:
raise NotImplementedError()
- # -------------------------------------------------------------------------
- # Protocol
- # -------------------------------------------------------------------------
-
- def _generate_method_xml(
- self,
- protocol: Protocol,
- block_max_volume: float,
- start_block_temperature: float,
- start_lid_temperature: float,
- post_heating: bool,
- method_name: Optional[str] = None,
- **kwargs,
- ) -> tuple[str, str]:
- if not method_name:
- method_name = f"PLR_Protocol_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
-
- if block_max_volume < 30.0:
- fluid_quantity = "0"
- elif block_max_volume < 75.0:
- fluid_quantity = "1"
- else:
- fluid_quantity = "2"
-
- # Use ISO format with timezone for strict SiLA compliance (e.g. 2026-01-06T18:39:30.503368-08:00)
- now = datetime.datetime.now().astimezone()
- now_str = now.isoformat()
-
- root = ET.Element("MethodSet")
- ET.SubElement(root, "DeleteAllMethods").text = "false"
-
- method_elem = ET.SubElement(
- root, "Method", methodName=method_name, creator="PyLabRobot", dateTime=now_str
- )
- ET.SubElement(method_elem, "Variant").text = "960000"
- ET.SubElement(method_elem, "PlateType").text = "0"
- ET.SubElement(method_elem, "FluidQuantity").text = fluid_quantity
-
- ET.SubElement(method_elem, "PostHeating").text = "true" if post_heating else "false"
-
- ET.SubElement(method_elem, "StartBlockTemperature").text = _format_number(
- start_block_temperature
- )
- ET.SubElement(method_elem, "StartLidTemperature").text = _format_number(start_lid_temperature)
-
- # Step defaults
- def_slope = _format_number(kwargs.get("slope", "4.4"))
- def_os_slope1 = _format_number(kwargs.get("overshoot_slope1", "0.1"))
- def_os_temp = _format_number(kwargs.get("overshoot_temperature", "0"))
- def_os_time = _format_number(kwargs.get("overshoot_time", "0"))
- def_os_slope2 = _format_number(kwargs.get("overshoot_slope2", "0.1"))
- pid_number = _format_number(kwargs.get("pid_number", "1"))
-
- step_counter = 1
- for stage_idx, stage in enumerate(protocol.stages):
- if not stage.steps:
- continue
- start_of_stage = step_counter
-
- for i, step in enumerate(stage.steps):
- b_temp = step.temperature[0] if step.temperature else 25
- l_temp = start_lid_temperature # Keep lid at start temp, could be extended to support step-specific lid temps
- duration = step.hold_seconds
- s_slope = _format_number(step.rate) if step.rate is not None else def_slope
-
- s = ET.SubElement(method_elem, "Step")
- ET.SubElement(s, "Number").text = str(step_counter)
- ET.SubElement(s, "Slope").text = s_slope
- ET.SubElement(s, "PlateauTemperature").text = _format_number(b_temp)
- ET.SubElement(s, "PlateauTime").text = _format_number(duration)
-
- # OverShoot params - use defaults passed to function
- ET.SubElement(s, "OverShootSlope1").text = def_os_slope1
- ET.SubElement(s, "OverShootTemperature").text = def_os_temp
- ET.SubElement(s, "OverShootTime").text = def_os_time
- ET.SubElement(s, "OverShootSlope2").text = def_os_slope2
-
- # Loop logic on the last step of the stage
- if i == len(stage.steps) - 1 and stage.repeats > 1:
- ET.SubElement(s, "GotoNumber").text = str(start_of_stage)
- ET.SubElement(s, "LoopNumber").text = str(stage.repeats - 1)
- else:
- ET.SubElement(s, "GotoNumber").text = "0"
- ET.SubElement(s, "LoopNumber").text = "0"
-
- ET.SubElement(s, "PIDNumber").text = pid_number
- ET.SubElement(s, "LidTemp").text = _format_number(l_temp)
- step_counter += 1
-
- # Default PID
- pid_set = ET.SubElement(method_elem, "PIDSet")
- pid = ET.SubElement(pid_set, "PID", number=pid_number)
- defaults = {
- "PHeating": "60",
- "PCooling": "80",
- "IHeating": "250",
- "ICooling": "100",
- "DHeating": "10",
- "DCooling": "10",
- "PLid": "100",
- "ILid": "70",
- }
- for k, v in defaults.items():
- # Allow kwargs to override specific PID values, e.g. PHeating="70"
- val = kwargs.get(k, v)
- ET.SubElement(pid, k).text = _format_number(val)
-
- xml_str = '' + ET.tostring(root, encoding="unicode")
- return xml_str, method_name
-
async def run_protocol(
self,
protocol: Protocol,
@@ -334,50 +98,19 @@ async def run_protocol(
method_name: Optional[str] = None,
**kwargs,
):
- """
- Run a PCR protocol.
-
- Args:
- protocol: The protocol to run.
- block_max_volume: Maximum block volume in microliters.
- start_block_temperature: The starting block temperature in C.
- start_lid_temperature: The starting lid temperature in C.
- post_heating: Whether to keep last temperature after method end.
- method_name: Optional name for the method on the device.
- **kwargs: Additional XML parameters for the ODTC method, including:
- slope, overshoot_slope1, overshoot_temperature, overshoot_time, overshoot_slope2,
- pid_number, and PID parameters (PHeating, PCooling, etc.)
- """
-
- method_xml, method_name = self._generate_method_xml(
- protocol,
- block_max_volume,
- start_block_temperature,
- start_lid_temperature,
- post_heating,
+ new_protocol = protocol_to_new(protocol)
+ await self._tc.run_protocol(
+ protocol=new_protocol,
+ block_max_volume=block_max_volume,
+ start_block_temperature=start_block_temperature,
+ start_lid_temperature=start_lid_temperature,
+ post_heating=post_heating,
method_name=method_name,
**kwargs,
)
- ps = ET.Element("ParameterSet")
- pm = ET.SubElement(ps, "Parameter", name="MethodsXML")
- ET.SubElement(pm, "String").text = method_xml
- params_xml = ET.tostring(ps, encoding="unicode")
-
- print("[ODTC] Uploading MethodSet...")
- await self._sila_interface.send_command("SetParameters", paramsXML=params_xml)
-
- print(f"[ODTC] Executing method '{method_name}'")
- try:
- await self._sila_interface.send_command("ExecuteMethod", methodName=method_name)
- except SiLAError as e:
- if e.code == 12: # SuccessWithWarning
- print(f"[ODTC Warning] {e.message}")
- else:
- raise e
-
async def stop_method(self):
- await self._sila_interface.send_command("StopMethod")
+ await self._driver.send_command("StopMethod")
async def get_hold_time(self) -> float:
raise NotImplementedError()
diff --git a/pylabrobot/legacy/thermocycling/opentrons_backend.py b/pylabrobot/legacy/thermocycling/opentrons_backend.py
index 9cd569bd2c0..33b7e328746 100644
--- a/pylabrobot/legacy/thermocycling/opentrons_backend.py
+++ b/pylabrobot/legacy/thermocycling/opentrons_backend.py
@@ -1,201 +1,115 @@
-"""Backend that drives an Opentrons Thermocycler via the HTTP API."""
+"""Legacy. Use pylabrobot.opentrons.thermocycler instead."""
-from typing import List, Optional, cast
+from typing import List
from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend
-from pylabrobot.legacy.thermocycling.standard import BlockStatus, LidStatus, Protocol
-
-try:
- from ot_api.modules import (
- list_connected_modules,
- thermocycler_close_lid,
- thermocycler_deactivate_block,
- thermocycler_deactivate_lid,
- thermocycler_open_lid,
- thermocycler_run_profile_no_wait,
- thermocycler_set_block_temperature,
- thermocycler_set_lid_temperature,
- )
-
- USE_OT = True
-except ImportError as e:
- USE_OT = False
- _OT_IMPORT_ERROR = e
+from pylabrobot.legacy.thermocycling.standard import (
+ BlockStatus,
+ LidStatus,
+ Protocol,
+ protocol_to_new,
+)
+from pylabrobot.opentrons.thermocycler import (
+ OpentronsThermocyclerDriver,
+ OpentronsThermocyclingBackend,
+)
class OpentronsThermocyclerBackend(ThermocyclerBackend):
- """HTTP-API backend for the Opentrons GEN-1/GEN-2 Thermocycler.
-
- All core functions are supported. run_profile() is fire-and-forget,
- since PCR runs can outlive the decorator's default timeout.
- """
+ """Legacy. Use pylabrobot.opentrons.OpentronsThermocyclingBackend instead."""
def __init__(self, opentrons_id: str):
- """Create a new backend bound to a specific thermocycler.
-
- Args:
- opentrons_id: The OT-API module "id" for your thermocycler.
- """
- super().__init__() # Call parent constructor
- if not USE_OT:
- raise RuntimeError(
- "Opentrons is not installed. Please run pip install pylabrobot[opentrons]."
- f" Import error: {_OT_IMPORT_ERROR}."
- )
- self.opentrons_id = opentrons_id
- self._current_protocol: Optional[Protocol] = None
+ self._driver = OpentronsThermocyclerDriver(opentrons_id=opentrons_id)
+ self._new = OpentronsThermocyclingBackend(self._driver)
+
+ @property
+ def opentrons_id(self):
+ return self._driver.opentrons_id
async def setup(self):
- """No extra setup needed for HTTP-API thermocycler."""
+ await self._new.setup()
async def stop(self):
- """Gracefully deactivate both heaters."""
- await self.deactivate_block()
- await self.deactivate_lid()
+ await self._new.stop()
def serialize(self) -> dict:
- """Include the Opentrons module ID in serialized state."""
- return {**super().serialize(), "opentrons_id": self.opentrons_id}
+ return {"type": self.__class__.__name__, "opentrons_id": self.opentrons_id}
async def open_lid(self):
- """Open the thermocycler lid."""
- return thermocycler_open_lid(module_id=self.opentrons_id)
+ await self._new.open_lid()
async def close_lid(self):
- """Close the thermocycler lid."""
- return thermocycler_close_lid(module_id=self.opentrons_id)
+ await self._new.close_lid()
async def set_block_temperature(self, temperature: List[float]):
- """Set block temperature in °C. Only single unique temperature supported."""
if len(set(temperature)) != 1:
raise ValueError(
- f"Opentrons thermocycler only supports a single unique block temperature, got {set(temperature)}"
+ f"Opentrons thermocycler only supports a single unique block temperature, "
+ f"got {set(temperature)}"
)
- temp_value = temperature[0]
- return thermocycler_set_block_temperature(celsius=temp_value, module_id=self.opentrons_id)
+ self._driver.set_block_temperature(temperature[0])
async def set_lid_temperature(self, temperature: List[float]):
- """Set lid temperature in °C. Only single unique temperature supported."""
if len(set(temperature)) != 1:
raise ValueError(
- f"Opentrons thermocycler only supports a single unique lid temperature, got {set(temperature)}"
+ f"Opentrons thermocycler only supports a single unique lid temperature, "
+ f"got {set(temperature)}"
)
- temp_value = temperature[0]
- return thermocycler_set_lid_temperature(celsius=temp_value, module_id=self.opentrons_id)
+ self._driver.set_lid_temperature(temperature[0])
async def deactivate_block(self):
- """Deactivate the block heater."""
- return thermocycler_deactivate_block(module_id=self.opentrons_id)
+ self._driver.deactivate_block()
async def deactivate_lid(self):
- """Deactivate the lid heater."""
- return thermocycler_deactivate_lid(module_id=self.opentrons_id)
+ self._driver.deactivate_lid()
async def run_protocol(self, protocol: Protocol, block_max_volume: float):
- """Enqueue and return immediately (no wait) the PCR profile command."""
-
- # flatten the protocol to a list of Steps
- # in opentrons, the "celsius" key is used instead of "temperature"
- # step.temperature is now a list, but Opentrons only supports single temperature
- ot_profile = []
- for stage in protocol.stages:
- for step in stage.steps:
- for _ in range(stage.repeats):
- if len(set(step.temperature)) != 1:
- raise ValueError(
- f"Opentrons thermocycler only supports a single unique temperature per step, got {set(step.temperature)}"
- )
- celsius = step.temperature[0]
- ot_profile.append({"celsius": celsius, "holdSeconds": step.hold_seconds})
-
- self._current_protocol = protocol
-
- return thermocycler_run_profile_no_wait(
- profile=ot_profile,
- block_max_volume=block_max_volume,
- module_id=self.opentrons_id,
- )
-
- def _find_module(self) -> dict:
- """Helper to locate this module's live-data dict."""
- for m in list_connected_modules():
- if m["id"] == self.opentrons_id:
- return cast(dict, m["data"])
- raise RuntimeError(f"Module '{self.opentrons_id}' not found")
+ await self._new.run_protocol(protocol_to_new(protocol), block_max_volume)
async def get_block_current_temperature(self) -> List[float]:
- return [cast(float, self._find_module()["currentTemperature"])]
+ return [self._driver.get_block_current_temperature()]
async def get_block_target_temperature(self) -> List[float]:
- target_temp = self._find_module().get("targetTemperature")
- if target_temp is None:
- raise RuntimeError("Block target temperature is not set. is a cycle running?")
- return [cast(float, target_temp)]
+ target = self._driver.get_block_target_temperature()
+ if target is None:
+ raise RuntimeError("Block target temperature is not set. Is a cycle running?")
+ return [target]
async def get_lid_current_temperature(self) -> List[float]:
- return [cast(float, self._find_module()["lidTemperature"])]
+ return [self._driver.get_lid_current_temperature()]
async def get_lid_target_temperature(self) -> List[float]:
- """Get the lid target temperature in °C. Raises RuntimeError if no target is active."""
- target_temp = self._find_module().get("lidTargetTemperature")
- if target_temp is None:
- raise RuntimeError("Lid target temperature is not set. is a cycle running?")
- return [cast(float, target_temp)]
+ target = self._driver.get_lid_target_temperature()
+ if target is None:
+ raise RuntimeError("Lid target temperature is not set. Is a cycle running?")
+ return [target]
async def get_lid_open(self) -> bool:
- return cast(str, self._find_module()["lidStatus"]) == "open"
+ return await self._new.get_lid_open()
async def get_lid_status(self) -> LidStatus:
- status = cast(str, self._find_module()["lidTemperatureStatus"])
- # Map Opentrons status strings to our enum
+ status = self._driver.get_lid_temperature_status_str()
if status == "holding at target":
return LidStatus.HOLDING_AT_TARGET
- else:
- return LidStatus.IDLE
+ return LidStatus.IDLE
async def get_block_status(self) -> BlockStatus:
- status = cast(str, self._find_module()["status"])
- # Map Opentrons status strings to our enum
+ status = self._driver.get_block_status_str()
if status == "holding at target":
return BlockStatus.HOLDING_AT_TARGET
- else:
- return BlockStatus.IDLE
+ return BlockStatus.IDLE
async def get_hold_time(self) -> float:
- return cast(float, self._find_module().get("holdTime", 0.0))
+ return await self._new.get_hold_time()
async def get_current_cycle_index(self) -> int:
- """Get the zero-based index of the current cycle from the Opentrons API."""
-
- # https://github.com/PyLabRobot/pylabrobot/issues/632
- raise NotImplementedError('Opentrons "cycle" concept is not understood currently.')
-
- # Since we send a flattened list of steps, we have to recover the cycle index based
- # on the current step index and total step count.
- seen_steps = 0
- current_step = self.get_current_step_index()
- for stage in self._current_protocol.stages:
- for _ in stage.steps:
- if seen_steps == current_step:
- return # TODO: what is a cycle in OT?
- seen_steps += 1
-
- raise RuntimeError("Current cycle index is not available. Is a profile running?")
+ return await self._new.get_current_cycle_index()
async def get_total_cycle_count(self) -> int:
- # https://github.com/PyLabRobot/pylabrobot/issues/632
- raise NotImplementedError('Opentrons "cycle" concept is not understood currently.')
+ return await self._new.get_total_cycle_count()
async def get_current_step_index(self) -> int:
- """Get the zero-based index of the current step from the Opentrons API."""
- # Opentrons API returns one-based, convert to zero-based
- step_index = self._find_module().get("currentStepIndex")
- if step_index is None:
- raise RuntimeError("Current step index is not available. Is a profile running?")
- return cast(int, step_index) - 1
+ return await self._new.get_current_step_index()
async def get_total_step_count(self) -> int:
- total_steps = self._find_module().get("totalStepCount")
- if total_steps is None:
- raise RuntimeError("Total step count is not available. Is a profile running?")
- return cast(int, total_steps)
+ return await self._new.get_total_step_count()
diff --git a/pylabrobot/legacy/thermocycling/opentrons_backend_tests.py b/pylabrobot/legacy/thermocycling/opentrons_backend_tests.py
index 59f7600a82a..7b8f7206276 100644
--- a/pylabrobot/legacy/thermocycling/opentrons_backend_tests.py
+++ b/pylabrobot/legacy/thermocycling/opentrons_backend_tests.py
@@ -29,7 +29,7 @@ def test_opentrons_v1_serialization(self):
deserialized = OpentronsThermocyclerModuleV1.deserialize(serialized)
assert tc_model == deserialized
- @patch("pylabrobot.legacy.thermocycling.opentrons_backend.list_connected_modules")
+ @patch("pylabrobot.opentrons.thermocycler.list_connected_modules")
async def test_find_module_raises_error_if_not_found(self, mock_list_connected_modules):
"""Test that an error is raised if the module is not found."""
mock_list_connected_modules.return_value = [{"id": "some_other_id", "data": {}}]
@@ -37,37 +37,37 @@ async def test_find_module_raises_error_if_not_found(self, mock_list_connected_m
await self.thermocycler_backend.get_lid_open()
self.assertEqual(str(e.exception), "Module 'test_id' not found")
- @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_open_lid")
+ @patch("pylabrobot.opentrons.thermocycler.thermocycler_open_lid")
async def test_open_lid(self, mock_open_lid):
await self.thermocycler_backend.open_lid()
mock_open_lid.assert_called_once_with(module_id="test_id")
- @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_close_lid")
+ @patch("pylabrobot.opentrons.thermocycler.thermocycler_close_lid")
async def test_close_lid(self, mock_close_lid):
await self.thermocycler_backend.close_lid()
mock_close_lid.assert_called_once_with(module_id="test_id")
- @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_set_block_temperature")
+ @patch("pylabrobot.opentrons.thermocycler.thermocycler_set_block_temperature")
async def test_set_block_temperature(self, mock_set_block_temp):
await self.thermocycler_backend.set_block_temperature([95.0])
mock_set_block_temp.assert_called_once_with(celsius=95.0, module_id="test_id")
- @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_set_lid_temperature")
+ @patch("pylabrobot.opentrons.thermocycler.thermocycler_set_lid_temperature")
async def test_set_lid_temperature(self, mock_set_lid_temp):
await self.thermocycler_backend.set_lid_temperature([105.0])
mock_set_lid_temp.assert_called_once_with(celsius=105.0, module_id="test_id")
- @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_deactivate_block")
+ @patch("pylabrobot.opentrons.thermocycler.thermocycler_deactivate_block")
async def test_deactivate_block(self, mock_deactivate_block):
await self.thermocycler_backend.deactivate_block()
mock_deactivate_block.assert_called_once_with(module_id="test_id")
- @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_deactivate_lid")
+ @patch("pylabrobot.opentrons.thermocycler.thermocycler_deactivate_lid")
async def test_deactivate_lid(self, mock_deactivate_lid):
await self.thermocycler_backend.deactivate_lid()
mock_deactivate_lid.assert_called_once_with(module_id="test_id")
- @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_run_profile_no_wait")
+ @patch("pylabrobot.opentrons.thermocycler.thermocycler_run_profile_no_wait")
async def test_run_protocol(self, mock_run_profile):
protocol = Protocol(stages=[Stage(steps=[Step(temperature=[95], hold_seconds=10)], repeats=1)])
await self.thermocycler_backend.run_protocol(protocol, 50.0)
@@ -76,7 +76,7 @@ async def test_run_protocol(self, mock_run_profile):
profile=[{"celsius": 95, "holdSeconds": 10}], block_max_volume=50.0, module_id="test_id"
)
- @patch("pylabrobot.legacy.thermocycling.opentrons_backend.list_connected_modules")
+ @patch("pylabrobot.opentrons.thermocycler.list_connected_modules")
async def test_getters_return_correct_data(self, mock_list_connected_modules):
mock_data = {
"id": "test_id",
diff --git a/pylabrobot/legacy/thermocycling/standard.py b/pylabrobot/legacy/thermocycling/standard.py
index ba8259de967..21dc8a2e264 100644
--- a/pylabrobot/legacy/thermocycling/standard.py
+++ b/pylabrobot/legacy/thermocycling/standard.py
@@ -67,3 +67,50 @@ class BlockStatus(enum.Enum):
IDLE = "idle"
HOLDING_AT_TARGET = "holding at target"
+
+
+# ---------------------------------------------------------------------------
+# Translation between legacy and new types
+# ---------------------------------------------------------------------------
+
+
+def protocol_to_new(protocol: Protocol):
+ """Convert a legacy Protocol to a new-architecture Protocol."""
+ from pylabrobot.capabilities.thermocycling import standard as new
+
+ return new.Protocol(
+ stages=[
+ new.Stage(
+ steps=[
+ new.Step(
+ temperature=list(step.temperature),
+ hold_seconds=step.hold_seconds,
+ rate=step.rate,
+ )
+ for step in stage.steps
+ ],
+ repeats=stage.repeats,
+ )
+ for stage in protocol.stages
+ ]
+ )
+
+
+def protocol_from_new(new_protocol) -> Protocol:
+ """Convert a new-architecture Protocol to a legacy Protocol."""
+ return Protocol(
+ stages=[
+ Stage(
+ steps=[
+ Step(
+ temperature=list(step.temperature),
+ hold_seconds=step.hold_seconds,
+ rate=step.rate,
+ )
+ for step in stage.steps
+ ],
+ repeats=stage.repeats,
+ )
+ for stage in new_protocol.stages
+ ]
+ )
diff --git a/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py b/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py
index 79aac61d343..03ca869842e 100644
--- a/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py
+++ b/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py
@@ -1,212 +1,44 @@
-import asyncio
-import hashlib
-import hmac
-import logging
-import re
-import ssl
-import warnings
-import xml.etree.ElementTree as ET
+"""Legacy ThermoFisher thermocycler backend -- thin delegation layer.
+
+All real SCPI logic lives in the new backends under
+``pylabrobot.thermo_fisher.thermocyclers``. This module keeps the original public interface so
+that :class:`ATCBackend`, :class:`ProflexBackend` and their tests continue to work unchanged.
+"""
+
from abc import ABCMeta
-from base64 import b64decode
-from dataclasses import dataclass
-from typing import Any, Dict, List, Optional, cast
-from xml.dom import minidom
+from typing import Dict, List, Optional, cast
-from pylabrobot.io import Socket
from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend
-from pylabrobot.legacy.thermocycling.standard import LidStatus, Protocol, Stage, Step
-
-
-def _generate_run_info_files(
- protocol: Protocol,
- block_id: int,
- sample_volume: float,
- run_mode: str,
- protocol_name: str,
- cover_enabled: bool,
- cover_temp: float,
- user_name: str,
- file_version="1.0.1",
- remote_run="true",
- hub="testhub",
- user="Guest",
- notes="",
- default_ramp_rate=100,
- ramp_rate_unit="DEGREES_PER_SECOND",
-):
- root = ET.Element("TCProtocol")
- file_version_el = ET.SubElement(root, "FileVersion")
- file_version_el.text = file_version
-
- protocol_name_el = ET.SubElement(root, "ProtocolName")
- protocol_name_el.text = protocol_name
-
- user_name_el = ET.SubElement(root, "UserName")
- user_name_el.text = user_name
-
- block_id_el = ET.SubElement(root, "BlockID")
- block_id_el.text = str(block_id + 1)
-
- sample_volume_el = ET.SubElement(root, "SampleVolume")
- sample_volume_el.text = str(sample_volume)
-
- run_mode_el = ET.SubElement(root, "RunMode")
- run_mode_el.text = str(run_mode)
-
- cover_temp_el = ET.SubElement(root, "CoverTemperature")
- cover_temp_el.text = str(cover_temp)
-
- cover_setting_el = ET.SubElement(root, "CoverSetting")
- cover_setting_el.text = "On" if cover_enabled else "Off"
-
- for stage_obj in protocol.stages:
- if isinstance(stage_obj, Step):
- stage = Stage(steps=[stage_obj], repeats=1)
- else:
- stage = stage_obj
-
- stage_el = ET.SubElement(root, "TCStage")
- stage_flag_el = ET.SubElement(stage_el, "StageFlag")
- stage_flag_el.text = "CYCLING"
-
- num_repetitions_el = ET.SubElement(stage_el, "NumOfRepetitions")
- num_repetitions_el.text = str(stage.repeats)
-
- for step in stage.steps:
- step_el = ET.SubElement(stage_el, "TCStep")
-
- ramp_rate_el = ET.SubElement(step_el, "RampRate")
- ramp_rate_el.text = str(
- int(step.rate if step.rate is not None else default_ramp_rate) / 100 * 6
- )
-
- ramp_rate_unit_el = ET.SubElement(step_el, "RampRateUnit")
- ramp_rate_unit_el.text = ramp_rate_unit
-
- for t_val in step.temperature:
- temp_el = ET.SubElement(step_el, "Temperature")
- temp_el.text = str(t_val)
-
- hold_time_el = ET.SubElement(step_el, "HoldTime")
- if step.hold_seconds == float("inf"):
- hold_time_el.text = "-1"
- elif step.hold_seconds == 0:
- hold_time_el.text = "0"
- else:
- hold_time_el.text = str(step.hold_seconds)
-
- ext_temp_el = ET.SubElement(step_el, "ExtTemperature")
- ext_temp_el.text = "0"
-
- ext_hold_el = ET.SubElement(step_el, "ExtHoldTime")
- ext_hold_el.text = "0"
-
- ext_start_cycle_el = ET.SubElement(step_el, "ExtStartingCycle")
- ext_start_cycle_el.text = "1"
-
- rough_string = ET.tostring(root, encoding="utf-8")
- reparsed = minidom.parseString(rough_string)
-
- xml_declaration = '\n'
- pretty_xml_as_string = (
- xml_declaration + reparsed.toprettyxml(indent=" ")[len('') :]
- )
-
- output2_lines = [
- f"-remoterun= {remote_run}",
- f"-hub= {hub}",
- f"-user= {user}",
- f"-method= {protocol_name}",
- f"-volume= {sample_volume}",
- f"-cover= {cover_temp}",
- f"-mode= {run_mode}",
- f"-coverEnabled= {'On' if cover_enabled else 'Off'}",
- f"-notes= {notes}",
- ]
- output2_string = "\n".join(output2_lines)
-
- return pretty_xml_as_string, output2_string
-
-
-def _gen_protocol_data(
- protocol: Protocol,
- block_id: int,
- sample_volume: float,
- run_mode: str,
- cover_temp: float,
- cover_enabled: bool,
- protocol_name: str,
- stage_name_prefixes: List[str],
-):
- def step_to_scpi(step: Step, step_index: int) -> dict:
- multiline: List[dict] = []
-
- infinite_hold = step.hold_seconds == float("inf")
-
- if infinite_hold and min(step.temperature) < 20:
- multiline.append({"cmd": "CoverRAMP", "params": {}, "args": ["30"]})
-
- multiline.append(
- {
- "cmd": "RAMP",
- "params": {"rate": str(step.rate if step.rate is not None else 100)},
- "args": [str(t) for t in step.temperature],
- }
- )
-
- if infinite_hold:
- multiline.append({"cmd": "HOLD", "params": {}, "args": []})
- elif step.hold_seconds > 0:
- multiline.append({"cmd": "HOLD", "params": {}, "args": [str(step.hold_seconds)]})
-
- return {
- "cmd": "STEP",
- "params": {},
- "args": [str(step_index)],
- "tag": "multiline.step",
- "multiline": multiline,
- }
-
- def stage_to_scpi(stage: Stage, stage_index: int, stage_name_prefix: str) -> dict:
- return {
- "cmd": "STAGe",
- "params": {"repeat": str(stage.repeats)},
- "args": [stage_index, f"{stage_name_prefix}_{stage_index}"],
- "tag": "multiline.stage",
- "multiline": [step_to_scpi(step, i + 1) for i, step in enumerate(stage.steps)],
- }
-
- stages = protocol.stages
- assert len(stages) == len(stage_name_prefixes), (
- "Number of stages must match number of stage names"
- )
-
- data = {
- # "status": "OK",
- "cmd": f"TBC{block_id + 1}:Protocol",
- "params": {"Volume": str(sample_volume), "RunMode": run_mode},
- "args": [protocol_name],
- "tag": "multiline.outer",
- "multiline": [
- stage_to_scpi(stage, stage_index=i + 1, stage_name_prefix=stage_name_prefix)
- for i, (stage, stage_name_prefix) in enumerate(zip(stages, stage_name_prefixes))
- ],
- "_blockId": block_id + 1,
- "_coverTemp": cover_temp,
- "_coverEnabled": "On" if cover_enabled else "Off",
- "_infinite_holds": [
- [stage_index, step_index]
- for stage_index, stage in enumerate(stages)
- for step_index, step in enumerate(stage.steps)
- if step.hold_seconds == float("inf")
- ],
- }
-
- return data
+from pylabrobot.legacy.thermocycling.standard import (
+ LidStatus,
+ Protocol,
+ Stage,
+ Step,
+ protocol_to_new,
+)
+from pylabrobot.thermo_fisher.thermocyclers import (
+ RunProgress,
+ ThermoFisherBlockBackend,
+ ThermoFisherLidBackend,
+ ThermoFisherThermocyclerDriver,
+ ThermoFisherThermocyclingBackend,
+ _gen_protocol_data,
+ _generate_run_info_files,
+)
+
+# Re-export so ``from … import _generate_run_info_files`` keeps working.
+__all__ = ["ThermoFisherThermocyclerBackend", "_generate_run_info_files", "_gen_protocol_data"]
class ThermoFisherThermocyclerBackend(ThermocyclerBackend, metaclass=ABCMeta):
- """Backend for Proflex thermocycler."""
+ """Legacy backend for ThermoFisher thermocyclers (ProFlex / ATC).
+
+ Delegates all real work to the new per-block backends:
+ :class:`ThermoFisherBlockBackend`, :class:`ThermoFisherLidBackend`,
+ and :class:`ThermoFisherThermocyclingBackend`.
+ """
+
+ RunProgress = RunProgress # keep nested reference for backward compat
def __init__(
self,
@@ -217,707 +49,154 @@ def __init__(
):
if port is not None:
raise NotImplementedError("Specifying a port is deprecated. Use use_ssl instead.")
-
- self.ip = ip
- self.use_ssl = use_ssl
-
- if use_ssl:
- self.port = 7443
- if serial_number is None:
- raise ValueError("Serial number is required for SSL connection (port 7443)")
- self.device_shared_secret = f"53rv1c3{serial_number}".encode("utf-8")
-
- ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
- ssl_context.check_hostname = False
- ssl_context.verify_mode = ssl.CERT_NONE
- # TLSv1 is required for legacy ThermoFisher hardware - silence deprecation warning
- with warnings.catch_warnings():
- warnings.filterwarnings("ignore", message="ssl.TLSVersion.TLSv1 is deprecated")
- ssl_context.minimum_version = ssl.TLSVersion.TLSv1
- ssl_context.maximum_version = ssl.TLSVersion.TLSv1
- try:
- # This is required for some legacy devices that use older ciphers or protocols
- # that are disabled by default in newer OpenSSL versions.
- ssl_context.set_ciphers("DEFAULT:@SECLEVEL=0")
- except (ValueError, ssl.SSLError):
- # This might fail on some systems/implementations, but it's worth a try
- pass
- else:
- self.port = 7000
- self.device_shared_secret = b"f4ct0rymt55"
- ssl_context = None
-
- self.io = Socket(
- human_readable_device_name="Thermo Fisher Thermocycler",
- host=ip,
- port=self.port,
- ssl_context=ssl_context,
- server_hostname=serial_number,
+ self._driver = ThermoFisherThermocyclerDriver(
+ ip=ip, use_ssl=use_ssl, serial_number=serial_number
)
- self._num_blocks: Optional[int] = None
- self.num_temp_zones = 0
- self.available_blocks: List[int] = []
- self.logger = logging.getLogger("pylabrobot.thermocycling.proflex")
- self.current_runs: Dict[int, str] = {}
+ self._block_backends: Dict[int, ThermoFisherBlockBackend] = {}
+ self._lid_backends: Dict[int, ThermoFisherLidBackend] = {}
+ self._tc_backends: Dict[int, ThermoFisherThermocyclingBackend] = {}
- @property
- def num_blocks(self) -> int:
- if self._num_blocks is None:
- raise ValueError("Number of blocks not set. Call setup() first.")
- return self._num_blocks
-
- def _get_auth_token(self, challenge: str):
- challenge_bytes = challenge.encode("utf-8")
- return hmac.new(self.device_shared_secret, challenge_bytes, hashlib.md5).hexdigest()
-
- def _build_scpi_msg(self, data: dict) -> str:
- def generate_output(data_dict: dict, indent_level=0) -> str:
- lines = []
- if indent_level == 0:
- line = data_dict["cmd"]
- for k, v in data_dict.get("params", {}).items():
- if v is True:
- line += f" -{k}"
- elif v is False:
- pass
- else:
- line += f" -{k}={v}"
- for val in data_dict.get("args", []):
- line += f" {val}"
- if "multiline" in data_dict:
- line += f" <{data_dict['tag']}>"
- lines.append(line)
-
- if "multiline" in data_dict:
- lines += generate_multiline(data_dict, indent_level + 1)
- lines.append(f"{data_dict['tag']}>")
- return "\n".join(lines)
-
- def generate_multiline(multi_dict, indent_level=0) -> List[str]:
- def indent():
- return " " * 8 * indent_level
-
- lines = []
- for element in multi_dict["multiline"]:
- line = indent() + element["cmd"]
- for k, v in element.get("params", {}).items():
- line += f" -{k}={v}"
- for arg in element.get("args", []):
- line += f" {arg}"
-
- if "multiline" in element:
- line += f" <{element['tag']}>"
- lines.append(line)
- lines += generate_multiline(element, indent_level + 1)
- lines.append(indent() + f"{element['tag']}>")
- else:
- lines.append(line)
- return lines
-
- return generate_output(data) + "\r\n"
+ # -- per-block backend accessors (lazy-cached) --------------------------------
- def _parse_scpi_response(self, response: str):
- START_TAG_REGEX = re.compile(r"(.*?)<(multiline\.[a-zA-Z0-9_]+)>")
- END_TAG_REGEX = re.compile(r"(multiline\.[a-zA-Z0-9_]+)>")
- PARAM_REGEX = re.compile(r"^-([A-Za-z0-9_]+)(?:=(.*))?$")
-
- def parse_command_line(line):
- start_match = START_TAG_REGEX.search(line)
- if start_match:
- cmd_part = start_match.group(1).strip()
- tag_name = start_match.group(2)
- else:
- cmd_part = line
- tag_name = None
-
- if not cmd_part:
- return None, [], tag_name
-
- parts = cmd_part.split()
- command = parts[0]
- args = parts[1:]
- return command, args, tag_name
-
- def process_args(args_list):
- params: Dict[str, Any] = {}
- positional_args = []
- for arg in args_list:
- match = PARAM_REGEX.match(arg)
- if match:
- key = match.group(1)
- value = match.group(2)
- if value is None:
- params[key] = True
- else:
- params[key] = value
- else:
- positional_args.append(arg)
- return positional_args, params
-
- def parse_structure(scpi_resp: str):
- first_space_idx = scpi_resp.find(" ")
- status = scpi_resp[:first_space_idx]
- scpi_resp = scpi_resp[first_space_idx + 1 :]
- lines = scpi_resp.split("\n")
-
- root = {"status": status, "multiline": []}
- stack = [root]
-
- for original_line in lines:
- line = original_line.strip()
- if not line:
- continue
- end_match = END_TAG_REGEX.match(line)
- if end_match:
- if len(stack) > 1:
- stack.pop()
- else:
- raise ValueError("Unmatched end tag: {}>".format(end_match.group(1)))
- continue
-
- command, args, start_tag = parse_command_line(line)
- if command is not None:
- pos_args, params = process_args(args)
- node = {"cmd": command, "args": pos_args, "params": params}
- if start_tag:
- node["multiline"] = []
- stack[-1]["multiline"].append(node) # type: ignore
- stack.append(node)
- node["tag"] = start_tag
- else:
- stack[-1]["multiline"].append(node) # type: ignore
-
- if len(stack) != 1:
- raise ValueError("Unbalanced tags in response.")
- return root
-
- if response.startswith("ERRor"):
- raise ValueError(f"Error response: {response}")
-
- result = parse_structure(response)
- status_val = result["status"]
- result = result["multiline"][0]
- result["status"] = status_val
- return result
-
- async def _read_response(self, timeout=1, read_once=True) -> str:
- try:
- if read_once:
- response_b = await self.io.read(timeout=timeout)
- else:
- response_b = await self.io.read_until_eof(timeout=timeout)
- response = response_b.decode("ascii")
- self.logger.debug("Response received: %s", response)
- return response
- except TimeoutError:
- return ""
- except Exception as e:
- self.logger.error("Error reading from socket: %s", e)
- return ""
+ def _block_backend(self, block_id: int) -> ThermoFisherBlockBackend:
+ if block_id not in self._block_backends:
+ self._block_backends[block_id] = ThermoFisherBlockBackend(self._driver, block_id)
+ return self._block_backends[block_id]
- async def send_command(self, data, response_timeout=1, read_once=True):
- msg = self._build_scpi_msg(data)
- self.logger.debug("Command sent: %s", msg.strip())
-
- await self.io.write(msg.encode("ascii"), timeout=response_timeout)
- return await self._read_response(timeout=response_timeout, read_once=read_once)
-
- async def _scpi_authenticate(self):
- await self.io.setup()
- await self._read_response(timeout=5)
- challenge_res = await self.send_command({"cmd": "CHAL?"})
- challenge = self._parse_scpi_response(challenge_res)["args"][0]
- auth = self._get_auth_token(challenge)
- auth_res = await self.send_command({"cmd": "AUTH", "args": [auth]})
- if self._parse_scpi_response(auth_res)["status"] != "OK":
- raise ValueError("Authentication failed")
- acc_res = await self.send_command(
- {"cmd": "ACCess", "params": {"stealth": True}, "args": ["Controller"]}
- )
- if self._parse_scpi_response(acc_res)["status"] != "OK":
- raise ValueError("Access failed")
-
- async def _load_num_blocks_and_type(self):
- block_present_val = await self.get_block_presence()
- if block_present_val == "0":
- raise ValueError("Block not present")
- self.bid = await self.get_block_id()
- if self.bid == "12":
- self._num_blocks = 1
- self.num_temp_zones = 6
- elif self.bid == "13":
- self._num_blocks = 3
- self.num_temp_zones = 2
- elif self.bid == "31":
- self._num_blocks = 1
- self.num_temp_zones = 3
- else:
- raise NotImplementedError("Only BID 31, 12 and 13 are supported")
-
- async def is_block_running(self, block_id: int) -> bool:
- run_name = await self.get_run_name(block_id=block_id)
- return run_name != "-"
-
- async def _load_available_blocks(self) -> None:
- await (
- self._scpi_authenticate()
- ) # in case users wants to see available blocks without setting them up
- await self._load_num_blocks_and_type()
- assert self._num_blocks is not None, "Number of blocks not set"
- for block_id in range(self._num_blocks):
- block_error = await self.get_error(block_id=block_id)
- if block_error != "0":
- raise ValueError(f"Block {block_id} has error: {block_error}")
- if not await self.is_block_running(block_id=block_id):
- if block_id not in self.available_blocks:
- self.available_blocks.append(block_id)
+ def _lid_backend(self, block_id: int) -> ThermoFisherLidBackend:
+ if block_id not in self._lid_backends:
+ self._lid_backends[block_id] = ThermoFisherLidBackend(self._driver, block_id)
+ return self._lid_backends[block_id]
- async def get_block_current_temperature(self, block_id=1) -> List[float]:
- res = await self.send_command({"cmd": f"TBC{block_id + 1}:TBC:BlockTemperatures?"})
- return cast(List[float], self._parse_scpi_response(res)["args"])
-
- async def get_sample_temps(self, block_id=1) -> List[float]:
- res = await self.send_command({"cmd": f"TBC{block_id + 1}:TBC:SampleTemperatures?"})
- return cast(List[float], self._parse_scpi_response(res)["args"])
+ def _tc_backend(self, block_id: int) -> ThermoFisherThermocyclingBackend:
+ if block_id not in self._tc_backends:
+ self._tc_backends[block_id] = ThermoFisherThermocyclingBackend(self._driver, block_id)
+ return self._tc_backends[block_id]
- async def get_nickname(self) -> str:
- res = await self.send_command({"cmd": "SYST:SETT:NICK?"})
- return cast(str, self._parse_scpi_response(res)["args"][0])
-
- async def set_nickname(self, nickname: str) -> None:
- res = await self.send_command({"cmd": "SYST:SETT:NICK", "args": [nickname]})
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to set nickname")
+ # -- forwarding properties (tests/subclasses access these directly) ----------
- async def get_log_by_runname(self, run_name: str) -> str:
- res = await self.send_command(
- {"cmd": "FILe:READ?", "args": [f"RUNS:{run_name}/{run_name}.log"]},
- response_timeout=5,
- read_once=False,
- )
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to get log")
- res.replace("\n", "")
- # Extract the base64 encoded log content between tags
- encoded_log_match = re.search(r"(.*?)
", res, re.DOTALL)
- if not encoded_log_match:
- raise ValueError("Failed to parse log content")
- encoded_log = encoded_log_match.group(1).strip()
- log = b64decode(encoded_log).decode("utf-8")
- return log
-
- async def get_elapsed_run_time_from_log(self, run_name: str) -> int:
- """
- Parses a log to find the elapsed run time in hh:mm:ss format
- and converts it to total seconds.
- """
- log = await self.get_log_by_runname(run_name)
+ @property
+ def io(self):
+ return self._driver.io
- # Updated regex to capture hours, minutes, and seconds
- elapsed_time_match = re.search(r"Run Time:\s*(\d+):(\d+):(\d+)", log)
+ @io.setter
+ def io(self, value):
+ self._driver.io = value
- if not elapsed_time_match:
- raise ValueError("Failed to parse elapsed time from log. Expected hh:mm:ss format.")
+ @property
+ def num_temp_zones(self) -> int:
+ return self._driver.num_temp_zones
- # Extract h, m, s, and convert them to integers
- hours = int(elapsed_time_match.group(1))
- minutes = int(elapsed_time_match.group(2))
- seconds = int(elapsed_time_match.group(3))
+ @num_temp_zones.setter
+ def num_temp_zones(self, value: int):
+ self._driver.num_temp_zones = value
- # Calculate the total seconds
- total_seconds = (hours * 3600) + (minutes * 60) + seconds
+ @property
+ def use_ssl(self) -> bool:
+ return self._driver.use_ssl
- return total_seconds
+ @property
+ def device_shared_secret(self) -> bytes:
+ return self._driver.device_shared_secret
- async def set_block_idle_temp(
- self, temp: float, block_id: int, control_enabled: bool = True
- ) -> None:
- if block_id not in self.available_blocks:
- raise ValueError(f"Block {block_id} is not available")
- res = await self.send_command(
- {"cmd": f"TBC{block_id + 1}:BLOCK", "args": [1 if control_enabled else 0, temp]}
- )
- if self._parse_scpi_response(res)["status"] != "NEXT":
- raise ValueError("Failed to set block idle temperature")
- follow_up = await self._read_response()
- if self._parse_scpi_response(follow_up)["status"] != "OK":
- raise ValueError("Failed to set block idle temperature")
+ @property
+ def port(self) -> int:
+ return self._driver.port
- async def set_cover_idle_temp(
- self, temp: float, block_id: int, control_enabled: bool = True
- ) -> None:
- if block_id not in self.available_blocks:
- raise ValueError(f"Block {block_id} not available")
- res = await self.send_command(
- {"cmd": f"TBC{block_id + 1}:COVER", "args": [1 if control_enabled else 0, temp]}
- )
- if self._parse_scpi_response(res)["status"] != "NEXT":
- raise ValueError("Failed to set cover idle temperature")
- follow_up = await self._read_response()
- if self._parse_scpi_response(follow_up)["status"] != "OK":
- raise ValueError("Failed to set cover idle temperature")
+ @property
+ def bid(self) -> str:
+ return self._driver.bid
- async def set_block_temperature(
- self, temperature: List[float], block_id: Optional[int] = None, rate: float = 100
- ):
- if block_id not in self.available_blocks:
- raise ValueError(f"Block {block_id} not available")
- res = await self.send_command(
- {"cmd": f"TBC{block_id + 1}:RAMP", "params": {"rate": rate}, "args": temperature},
- response_timeout=60,
- )
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to ramp block temperature")
+ @bid.setter
+ def bid(self, value: str):
+ self._driver.bid = value
- async def block_ramp_single_temp(self, target_temp: float, block_id: int, rate: float = 100):
- """Set a single temperature for the block with a ramp rate.
+ @property
+ def available_blocks(self) -> List[int]:
+ return self._driver.available_blocks
- It might be better to use `set_block_temperature` to set individual temperatures for each zone.
- """
- if block_id not in self.available_blocks:
- raise ValueError(f"Block {block_id} not available")
- res = await self.send_command(
- {"cmd": f"TBC{block_id + 1}:BlockRAMP", "params": {"rate": rate}, "args": [target_temp]},
- response_timeout=60,
- )
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to ramp block temperature")
+ @available_blocks.setter
+ def available_blocks(self, value: List[int]):
+ self._driver.available_blocks = value
- async def set_lid_temperature(self, temperature: List[float], block_id: Optional[int] = None):
- assert block_id is not None, "block_id must be specified"
- assert len(set(temperature)) == 1, "Lid temperature must be the same for all zones"
- target_temp = temperature[0]
- if block_id not in self.available_blocks:
- raise ValueError(f"Block {block_id} not available")
- res = await self.send_command(
- {"cmd": f"TBC{block_id + 1}:CoverRAMP", "params": {}, "args": [target_temp]},
- response_timeout=60,
- )
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to ramp cover temperature")
+ @property
+ def current_runs(self) -> Dict[int, str]:
+ return self._driver.current_runs
- async def buzzer_on(self):
- res = await self.send_command({"cmd": "BUZZer+"})
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to turn on buzzer")
+ @current_runs.setter
+ def current_runs(self, value: Dict[int, str]):
+ self._driver.current_runs = value
- async def buzzer_off(self):
- res = await self.send_command({"cmd": "BUZZer-"})
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to turn off buzzer")
+ @property
+ def _num_blocks(self) -> Optional[int]:
+ return self._driver._num_blocks
- async def send_morse_code(self, morse_code: str):
- short_beep_duration = 0.1
- long_beep_duration = short_beep_duration * 3
- space_duration = short_beep_duration * 3
- assert all(char in ".- " for char in morse_code), "Invalid characters in morse code"
- for char in morse_code:
- if char == ".":
- await self.buzzer_on()
- await asyncio.sleep(short_beep_duration)
- await self.buzzer_off()
- elif char == "-":
- await self.buzzer_on()
- await asyncio.sleep(long_beep_duration)
- await self.buzzer_off()
- elif char == " ":
- await asyncio.sleep(space_duration)
- await asyncio.sleep(short_beep_duration) # between letters is a short unit
+ @_num_blocks.setter
+ def _num_blocks(self, value: Optional[int]):
+ self._driver._num_blocks = value
- async def continue_run(self, block_id: int):
- for _ in range(3):
- await asyncio.sleep(1)
- res = await self.send_command({"cmd": f"TBC{block_id + 1}:CONTinue"})
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to continue from indefinite hold")
-
- async def _write_file(self, filename: str, data: str, encoding="plain"):
- write_res = await self.send_command(
- {
- "cmd": "FILe:WRITe",
- "params": {"encoding": encoding},
- "args": [filename],
- "multiline": [{"cmd": data}],
- "tag": "multiline.write",
- },
- response_timeout=1,
- read_once=False,
- )
- if self._parse_scpi_response(write_res)["status"] != "OK":
- raise ValueError("Failed to write file")
+ @property
+ def num_blocks(self) -> int:
+ return self._driver.num_blocks
- async def get_block_id(self):
- res = await self.send_command({"cmd": "TBC:BID?"})
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to get block ID")
- return self._parse_scpi_response(res)["args"][0]
+ # -- methods needed by subclasses (ATCBackend / ProflexBackend) --------------
- async def get_block_presence(self):
- res = await self.send_command({"cmd": "TBC:BlockPresence?"})
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to get block presence")
- return self._parse_scpi_response(res)["args"][0]
+ def _parse_scpi_response(self, response: str):
+ return self._driver._parse_scpi_response(response)
- async def check_run_exists(self, run_name: str) -> bool:
- res = await self.send_command(
- {"cmd": "RUNS:EXISTS?", "args": [run_name], "params": {"type": "folders"}}
+ async def send_command(self, data, response_timeout=1, read_once=True):
+ return await self._driver.send_command(
+ data, response_timeout=response_timeout, read_once=read_once
)
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to check if run exists")
- return cast(str, self._parse_scpi_response(res)["args"][1]) == "True"
- async def create_run(self, run_name: str):
- res = await self.send_command({"cmd": "RUNS:NEW", "args": [run_name]}, response_timeout=10)
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to create run")
- return self._parse_scpi_response(res)["args"][0]
-
- async def get_run_name(self, block_id: int) -> str:
- res = await self.send_command({"cmd": f"TBC{block_id + 1}:RUNTitle?"})
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to get run title")
- return cast(str, self._parse_scpi_response(res)["args"][0])
-
- async def _get_run_progress(self, block_id: int):
- res = await self.send_command({"cmd": f"TBC{block_id + 1}:RUNProgress?"})
- parsed_res = self._parse_scpi_response(res)
- if parsed_res["status"] != "OK":
- raise ValueError("Failed to get run status")
- if parsed_res["cmd"] == f"TBC{block_id + 1}:RunProtocol":
- await self._read_response()
- return False
- return self._parse_scpi_response(res)["params"]
-
- async def get_estimated_run_time(self, block_id: int):
- res = await self.send_command({"cmd": f"TBC{block_id + 1}:ESTimatedTime?"})
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to get estimated run time")
- return self._parse_scpi_response(res)["args"][0]
-
- async def get_elapsed_run_time(self, block_id: int):
- res = await self.send_command({"cmd": f"TBC{block_id + 1}:ELAPsedTime?"})
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to get elapsed run time")
- return int(self._parse_scpi_response(res)["args"][0])
-
- async def get_remaining_run_time(self, block_id):
- res = await self.send_command({"cmd": f"TBC{block_id + 1}:REMainingTime?"})
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to get remaining run time")
- return int(self._parse_scpi_response(res)["args"][0])
-
- async def get_error(self, block_id):
- res = await self.send_command({"cmd": f"TBC{block_id + 1}:ERROR?"})
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to get error")
- return self._parse_scpi_response(res)["args"][0]
-
- async def power_on(self):
- res = await self.send_command({"cmd": "POWER", "args": ["On"]}, response_timeout=20)
- if res == "" or self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to power on")
-
- async def power_off(self):
- res = await self.send_command({"cmd": "POWER", "args": ["Off"]})
- if self._parse_scpi_response(res)["status"] != "OK":
- raise ValueError("Failed to power off")
-
- async def _scpi_write_run_info(
- self,
- protocol: Protocol,
- run_name: str,
- block_id: int,
- sample_volume: float,
- run_mode: str,
- protocol_name: str,
- cover_temp: float,
- cover_enabled: bool,
- user_name: str,
- ):
- xmlfile, tmpfile = _generate_run_info_files(
- protocol=protocol,
- block_id=block_id,
- sample_volume=sample_volume,
- run_mode=run_mode,
- protocol_name=protocol_name,
- cover_temp=cover_temp,
- cover_enabled=cover_enabled,
- user_name="LifeTechnologies", # for some reason LifeTechnologies is used here
- )
- await self._write_file(f"runs:{run_name}/{protocol_name}.method", xmlfile)
- await self._write_file(f"runs:{run_name}/{run_name}.tmp", tmpfile)
+ # -- ThermocyclerBackend interface -------------------------------------------
- async def _scpi_run_protocol(
- self,
- protocol: Protocol,
- run_name: str,
- block_id: int,
- sample_volume: float,
- run_mode: str,
- protocol_name: str,
- cover_temp: float,
- cover_enabled: bool,
- user_name: str,
- stage_name_prefixes: List[str],
+ async def setup(
+ self, block_idle_temp=25, cover_idle_temp=105, blocks_to_setup: Optional[List[int]] = None
):
- load_res = await self.send_command(
- data=_gen_protocol_data(
- protocol=protocol,
- block_id=block_id,
- sample_volume=sample_volume,
- run_mode=run_mode,
- cover_temp=cover_temp,
- cover_enabled=cover_enabled,
- protocol_name=protocol_name,
- stage_name_prefixes=stage_name_prefixes,
- ),
- response_timeout=5,
- read_once=False,
+ await self._driver.setup(
+ block_idle_temp=block_idle_temp,
+ cover_idle_temp=cover_idle_temp,
+ blocks_to_setup=blocks_to_setup,
)
- if self._parse_scpi_response(load_res)["status"] != "OK":
- self.logger.error(load_res)
- self.logger.error("Protocol failed to load")
- raise ValueError("Protocol failed to load")
-
- start_res = await self.send_command(
- {
- "cmd": f"TBC{block_id + 1}:RunProtocol",
- "params": {
- "User": user_name,
- "CoverTemperature": cover_temp,
- "CoverEnabled": "On" if cover_enabled else "Off",
- },
- "args": [protocol_name, run_name],
- },
- response_timeout=2,
- read_once=False,
- )
-
- if self._parse_scpi_response(start_res)["status"] == "NEXT":
- self.logger.info("Protocol started")
- else:
- self.logger.error(start_res)
- self.logger.error("Protocol failed to start")
- raise ValueError("Protocol failed to start")
- total_time = await self.get_estimated_run_time(block_id=block_id)
- total_time = float(total_time)
- self.logger.info(f"Estimated run time: {total_time}")
- self.current_runs[block_id] = run_name
+ async def stop(self):
+ await self._driver.stop()
- async def abort_run(self, block_id: int):
- if not await self.is_block_running(block_id=block_id):
- self.logger.info("Failed to abort protocol: no run is currently running on this block")
- return
- run_name = await self.get_run_name(block_id=block_id)
- abort_res = await self.send_command({"cmd": f"TBC{block_id + 1}:AbortRun", "args": [run_name]})
- if self._parse_scpi_response(abort_res)["status"] != "OK":
- self.logger.error(abort_res)
- self.logger.error("Failed to abort protocol")
- raise ValueError("Failed to abort protocol")
- self.logger.info("Protocol aborted")
- await asyncio.sleep(10)
-
- @dataclass
- class RunProgress:
- stage: str
- elapsed_time: int
- remaining_time: int
- running: bool
-
- async def get_run_info(self, protocol: Protocol, block_id: int) -> "RunProgress":
- progress = await self._get_run_progress(block_id=block_id)
- run_name = await self.get_run_name(block_id=block_id)
- if not progress:
- self.logger.info("Protocol completed")
- return ThermoFisherThermocyclerBackend.RunProgress(
- running=False,
- stage="completed",
- elapsed_time=await self.get_elapsed_run_time_from_log(run_name=run_name),
- remaining_time=0,
- )
-
- if progress["RunTitle"] == "-":
- await self._read_response(timeout=5)
- self.logger.info("Protocol completed")
- return ThermoFisherThermocyclerBackend.RunProgress(
- running=False,
- stage="completed",
- elapsed_time=await self.get_elapsed_run_time_from_log(run_name=run_name),
- remaining_time=0,
- )
-
- if progress["Stage"] == "POSTRun":
- self.logger.info("Protocol in POSTRun")
- return ThermoFisherThermocyclerBackend.RunProgress(
- running=True,
- stage="POSTRun",
- elapsed_time=await self.get_elapsed_run_time_from_log(run_name=run_name),
- remaining_time=0,
- )
-
- # TODO: move to separate wait method
- time_elapsed = await self.get_elapsed_run_time(block_id=block_id)
- remaining_time = await self.get_remaining_run_time(block_id=block_id)
-
- if progress["Stage"] != "-" and progress["Step"] != "-":
- current_step = protocol.stages[int(progress["Stage"]) - 1].steps[int(progress["Step"]) - 1]
- if current_step.hold_seconds == float("inf"):
- while True:
- block_temps = await self.get_block_current_temperature(block_id=block_id)
- target_temps = current_step.temperature
- if all(
- abs(float(block_temps[i]) - target_temps[i]) < 0.5 for i in range(len(block_temps))
- ):
- break
- await asyncio.sleep(5)
- self.logger.info("Infinite hold")
- return ThermoFisherThermocyclerBackend.RunProgress(
- running=False,
- stage="infinite_hold",
- elapsed_time=time_elapsed,
- remaining_time=remaining_time,
- )
-
- self.logger.info(f"Elapsed time: {time_elapsed}")
- self.logger.info(f"Remaining time: {remaining_time}")
- return ThermoFisherThermocyclerBackend.RunProgress(
- running=True,
- stage=progress["Stage"],
- elapsed_time=time_elapsed,
- remaining_time=remaining_time,
+ async def set_block_temperature(
+ self, temperature: List[float], block_id: Optional[int] = None, rate: float = 100
+ ):
+ assert block_id is not None, "block_id must be specified"
+ if block_id not in self._driver.available_blocks:
+ raise ValueError(f"Block {block_id} not available")
+ res = await self._driver.send_command(
+ {"cmd": f"TBC{block_id + 1}:RAMP", "params": {"rate": rate}, "args": temperature},
+ response_timeout=60,
)
+ if self._driver._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to ramp block temperature")
- # *************Methods implementing ThermocyclerBackend***********************
-
- async def setup(
- self, block_idle_temp=25, cover_idle_temp=105, blocks_to_setup: Optional[List[int]] = None
- ):
- await self._scpi_authenticate()
- await self.power_on()
- await self._load_num_blocks_and_type()
- if blocks_to_setup is None:
- await self._load_available_blocks()
- if len(self.available_blocks) == 0:
- raise ValueError("No available blocks. Set blocks_to_setup to force setup")
- else:
- self.available_blocks = blocks_to_setup
- for block_index in self.available_blocks:
- await self.set_block_idle_temp(temp=block_idle_temp, block_id=block_index)
- await self.set_cover_idle_temp(temp=cover_idle_temp, block_id=block_index)
+ async def set_lid_temperature(self, temperature: List[float], block_id: Optional[int] = None):
+ assert block_id is not None, "block_id must be specified"
+ assert len(set(temperature)) == 1, "Lid temperature must be the same for all zones"
+ await self._lid_backend(block_id).set_temperature(temperature[0])
async def deactivate_lid(self, block_id: Optional[int] = None):
assert block_id is not None, "block_id must be specified"
- return await self.set_cover_idle_temp(temp=105, control_enabled=False, block_id=block_id)
+ await self._driver.deactivate_lid(block_id=block_id)
async def deactivate_block(self, block_id: Optional[int] = None):
assert block_id is not None, "block_id must be specified"
- return await self.set_block_idle_temp(temp=25, control_enabled=False, block_id=block_id)
+ await self._driver.deactivate_block(block_id=block_id)
+
+ async def get_block_current_temperature(self, block_id=1) -> List[float]:
+ res = await self._driver.send_command({"cmd": f"TBC{block_id + 1}:TBC:BlockTemperatures?"})
+ return cast(List[float], self._driver._parse_scpi_response(res)["args"])
async def get_lid_current_temperature(self, block_id: Optional[int] = None) -> List[float]:
assert block_id is not None, "block_id must be specified"
- res = await self.send_command({"cmd": f"TBC{block_id + 1}:TBC:CoverTemperatures?"})
- return cast(List[float], self._parse_scpi_response(res)["args"])
+ res = await self._driver.send_command({"cmd": f"TBC{block_id + 1}:TBC:CoverTemperatures?"})
+ return cast(List[float], self._driver._parse_scpi_response(res)["args"])
async def run_protocol(
self,
@@ -933,102 +212,122 @@ async def run_protocol(
stage_name_prefixes: Optional[List[str]] = None,
):
assert block_id is not None, "block_id must be specified"
-
- if await self.check_run_exists(run_name):
- self.logger.warning(f"Run {run_name} already exists")
- else:
- await self.create_run(run_name)
-
- # wrap all Steps in Stage objects where necessary
+ # Wrap bare Steps in Stages (matching legacy behavior)
for i, stage in enumerate(protocol.stages):
if isinstance(stage, Step):
protocol.stages[i] = Stage(steps=[stage], repeats=1)
-
- for stage in protocol.stages:
- for step in stage.steps:
- if len(step.temperature) != self.num_temp_zones:
- raise ValueError(
- f"Each step in the protocol must have a list of temperatures "
- f"of length {self.num_temp_zones}. "
- f"Step temperatures: {step.temperature} (length {len(step.temperature)})"
- )
-
- stage_name_prefixes = stage_name_prefixes or ["Stage_" for i in range(len(protocol.stages))]
-
- await self._scpi_write_run_info(
- protocol=protocol,
- block_id=block_id,
+ new_protocol = protocol_to_new(protocol)
+ tc = self._tc_backend(block_id)
+ await tc._run_protocol_with_options(
+ protocol=new_protocol,
+ block_max_volume=block_max_volume,
run_name=run_name,
- user_name=user,
- sample_volume=block_max_volume,
+ user=user,
run_mode=run_mode,
cover_temp=cover_temp,
cover_enabled=cover_enabled,
protocol_name=protocol_name,
- )
- await self._scpi_run_protocol(
- protocol=protocol,
- run_name=run_name,
- block_id=block_id,
- sample_volume=block_max_volume,
- run_mode=run_mode,
- cover_temp=cover_temp,
- cover_enabled=cover_enabled,
- protocol_name=protocol_name,
- user_name=user,
stage_name_prefixes=stage_name_prefixes,
)
- async def stop(self):
- for block_id in self.current_runs.keys():
- await self.abort_run(block_id=block_id)
+ async def get_run_info(self, protocol: Protocol, block_id: int) -> RunProgress:
+ new_protocol = protocol_to_new(protocol)
+ return await self._tc_backend(block_id).get_run_info(new_protocol)
- await self.deactivate_lid(block_id=block_id)
- await self.deactivate_block(block_id=block_id)
+ async def abort_run(self, block_id: int):
+ await self._tc_backend(block_id).abort_run()
- await self.io.stop()
+ async def continue_run(self, block_id: int):
+ await self._tc_backend(block_id).continue_run()
- async def get_block_status(self, *args, **kwargs):
- raise NotImplementedError
+ # -- convenience delegations -------------------------------------------------
- async def get_current_cycle_index(self, block_id: Optional[int] = None) -> int:
- assert block_id is not None, "block_id must be specified"
- progress = await self._get_run_progress(block_id=block_id)
- if progress is None:
- raise RuntimeError("No progress information available")
+ async def get_sample_temps(self, block_id=1) -> List[float]:
+ return await self._block_backend(block_id).get_sample_temps()
- if progress["RunTitle"] == "-":
- await self._read_response(timeout=5)
- raise RuntimeError("Protocol completed or not started")
+ async def get_nickname(self) -> str:
+ return await self._driver.get_nickname()
- if progress["Stage"] == "POSTRun":
- raise RuntimeError("Protocol in POSTRun stage, no current cycle index")
+ async def set_nickname(self, nickname: str) -> None:
+ await self._driver.set_nickname(nickname)
- if progress["Stage"] != "-" and progress["Step"] != "-":
- return int(progress["Stage"]) - 1
+ async def get_log_by_runname(self, run_name: str) -> str:
+ # Use block 0's tc_backend; log methods don't depend on block_id.
+ return await self._tc_backend(0).get_log_by_runname(run_name)
- raise RuntimeError("Current cycle index is not available, protocol may not be running")
+ async def get_elapsed_run_time_from_log(self, run_name: str) -> int:
+ return await self._tc_backend(0).get_elapsed_run_time_from_log(run_name)
- async def get_current_step_index(self, block_id: Optional[int] = None) -> int:
+ async def set_block_idle_temp(
+ self, temp: float, block_id: int, control_enabled: bool = True
+ ) -> None:
+ await self._block_backend(block_id).set_block_idle_temp(
+ temp=temp, control_enabled=control_enabled
+ )
+
+ async def set_cover_idle_temp(
+ self, temp: float, block_id: int, control_enabled: bool = True
+ ) -> None:
+ await self._lid_backend(block_id).set_cover_idle_temp(
+ temp=temp, control_enabled=control_enabled
+ )
+
+ async def block_ramp_single_temp(self, target_temp: float, block_id: int, rate: float = 100):
+ await self._block_backend(block_id).block_ramp_single_temp(target_temp=target_temp, rate=rate)
+
+ async def buzzer_on(self):
+ await self._driver.buzzer_on()
+
+ async def buzzer_off(self):
+ await self._driver.buzzer_off()
+
+ async def send_morse_code(self, morse_code: str):
+ await self._driver.send_morse_code(morse_code)
+
+ async def power_on(self):
+ await self._driver.power_on()
+
+ async def power_off(self):
+ await self._driver.power_off()
+
+ async def check_run_exists(self, run_name: str) -> bool:
+ return await self._tc_backend(0).check_run_exists(run_name)
+
+ async def create_run(self, run_name: str):
+ return await self._tc_backend(0).create_run(run_name)
+
+ async def get_run_name(self, block_id: int) -> str:
+ return await self._tc_backend(block_id).get_run_name()
+
+ async def get_estimated_run_time(self, block_id: int):
+ return await self._tc_backend(block_id).get_estimated_run_time()
+
+ async def get_elapsed_run_time(self, block_id: int):
+ return await self._tc_backend(block_id).get_elapsed_run_time()
+
+ async def get_remaining_run_time(self, block_id: int):
+ return await self._tc_backend(block_id).get_remaining_run_time()
+
+ async def get_error(self, block_id):
+ return await self._tc_backend(block_id).get_error()
+
+ async def get_current_cycle_index(self, block_id: Optional[int] = None) -> int:
assert block_id is not None, "block_id must be specified"
- progress = await self._get_run_progress(block_id=block_id)
- if progress is None:
- raise RuntimeError("No progress information available")
+ return await self._tc_backend(block_id).get_current_cycle_index()
- if progress["RunTitle"] == "-":
- await self._read_response(timeout=5)
- raise RuntimeError("Protocol completed or not started")
+ async def get_current_step_index(self, block_id: Optional[int] = None) -> int:
+ assert block_id is not None, "block_id must be specified"
+ return await self._tc_backend(block_id).get_current_step_index()
- if progress["Stage"] == "POSTRun":
- raise RuntimeError("Protocol in POSTRun stage, no current cycle index")
+ async def _get_run_progress(self, block_id: int):
+ return await self._tc_backend(block_id)._get_run_progress()
- if progress["Stage"] != "-" and progress["Step"] != "-":
- return int(progress["Step"]) - 1
+ # -- stubs for abstract methods not implemented on this hardware -------------
- raise RuntimeError("Current step index is not available, protocol may not be running")
+ async def get_block_status(self, *args, **kwargs):
+ raise NotImplementedError
async def get_hold_time(self, *args, **kwargs):
- # deprecated
raise NotImplementedError
async def get_lid_open(self, *args, **kwargs):
@@ -1038,17 +337,13 @@ async def get_lid_status(self, *args, **kwargs) -> LidStatus:
raise NotImplementedError
async def get_lid_target_temperature(self, *args, **kwargs):
- # deprecated
raise NotImplementedError
async def get_total_cycle_count(self, *args, **kwargs):
- # deprecated
raise NotImplementedError
async def get_total_step_count(self, *args, **kwargs):
- # deprecated
raise NotImplementedError
async def get_block_target_temperature(self, *args, **kwargs):
- # deprecated
raise NotImplementedError
diff --git a/pylabrobot/legacy/thermocycling/thermocycler.py b/pylabrobot/legacy/thermocycling/thermocycler.py
index 96e107315bc..11bcf514cf9 100644
--- a/pylabrobot/legacy/thermocycling/thermocycler.py
+++ b/pylabrobot/legacy/thermocycling/thermocycler.py
@@ -1,15 +1,138 @@
-"""High-level Thermocycler resource wrapping a backend."""
+"""High-level Thermocycler resource wrapping a backend.
+
+Internally delegates to ThermocyclingCapability and TemperatureControlCapability
+via adapters. The legacy public API is unchanged.
+"""
-import asyncio
-import time
from typing import List, Optional
+from pylabrobot.capabilities.temperature_controlling import (
+ TemperatureControlCapability,
+ TemperatureControllerBackend as _NewTempBackend,
+)
+from pylabrobot.capabilities.thermocycling import (
+ ThermocyclingBackend as _NewThermocyclingBackend,
+ ThermocyclingCapability,
+)
+from pylabrobot.capabilities.thermocycling import standard as _new_std
from pylabrobot.legacy.machines.machine import Machine
from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend
-from pylabrobot.legacy.thermocycling.standard import BlockStatus, LidStatus, Protocol, Stage, Step
+from pylabrobot.legacy.thermocycling.standard import (
+ BlockStatus,
+ LidStatus,
+ Protocol,
+ Stage,
+ Step,
+ protocol_from_new,
+ protocol_to_new,
+)
from pylabrobot.resources import Coordinate, ResourceHolder
+# ---------------------------------------------------------------------------
+# Adapters: wrap a legacy ThermocyclerBackend for the new capability interfaces
+# ---------------------------------------------------------------------------
+
+
+class _BlockTempAdapter(_NewTempBackend):
+ """Adapts the block side of a legacy ThermocyclerBackend to TemperatureControllerBackend."""
+
+ def __init__(self, legacy: ThermocyclerBackend):
+ self._legacy = legacy
+
+ async def setup(self):
+ pass
+
+ async def stop(self):
+ pass
+
+ @property
+ def supports_active_cooling(self) -> bool:
+ return True
+
+ async def set_temperature(self, temperature: float):
+ await self._legacy.set_block_temperature([temperature])
+
+ async def get_current_temperature(self) -> float:
+ temps = await self._legacy.get_block_current_temperature()
+ return temps[0]
+
+ async def deactivate(self):
+ await self._legacy.deactivate_block()
+
+
+class _LidTempAdapter(_NewTempBackend):
+ """Adapts the lid side of a legacy ThermocyclerBackend to TemperatureControllerBackend."""
+
+ def __init__(self, legacy: ThermocyclerBackend):
+ self._legacy = legacy
+
+ async def setup(self):
+ pass
+
+ async def stop(self):
+ pass
+
+ @property
+ def supports_active_cooling(self) -> bool:
+ return False
+
+ async def set_temperature(self, temperature: float):
+ await self._legacy.set_lid_temperature([temperature])
+
+ async def get_current_temperature(self) -> float:
+ temps = await self._legacy.get_lid_current_temperature()
+ return temps[0]
+
+ async def deactivate(self):
+ await self._legacy.deactivate_lid()
+
+
+class _ThermocyclingAdapter(_NewThermocyclingBackend):
+ """Adapts a legacy ThermocyclerBackend to the new ThermocyclingBackend."""
+
+ def __init__(self, legacy: ThermocyclerBackend):
+ self._legacy = legacy
+
+ async def setup(self):
+ pass
+
+ async def stop(self):
+ pass
+
+ async def open_lid(self) -> None:
+ await self._legacy.open_lid()
+
+ async def close_lid(self) -> None:
+ await self._legacy.close_lid()
+
+ async def get_lid_open(self) -> bool:
+ return await self._legacy.get_lid_open()
+
+ async def run_protocol(self, protocol: _new_std.Protocol, block_max_volume: float) -> None:
+ await self._legacy.run_protocol(protocol_from_new(protocol), block_max_volume)
+
+ async def get_hold_time(self) -> float:
+ return await self._legacy.get_hold_time()
+
+ async def get_current_cycle_index(self) -> int:
+ return await self._legacy.get_current_cycle_index()
+
+ async def get_total_cycle_count(self) -> int:
+ return await self._legacy.get_total_cycle_count()
+
+ async def get_current_step_index(self) -> int:
+ return await self._legacy.get_current_step_index()
+
+ async def get_total_step_count(self) -> int:
+ return await self._legacy.get_total_step_count()
+
+
+# ---------------------------------------------------------------------------
+# Legacy frontend
+# ---------------------------------------------------------------------------
+
+
class Thermocycler(ResourceHolder, Machine):
"""Generic Thermocycler: block + lid + profile + status queries."""
@@ -24,18 +147,6 @@ def __init__(
category: str = "thermocycler",
model: Optional[str] = None,
):
- """Initialize a Thermocycler resource.
-
- Args:
- name: Human-readable name.
- size_x: Footprint in the X dimension (mm).
- size_y: Footprint in the Y dimension (mm).
- size_z: Height in the Z dimension (mm).
- backend: A ThermocyclerBackend instance.
- child_location: Where a plate sits on the block.
- category: Resource category (default: "thermocycler").
- model: Module model string (e.g. "thermocyclerModuleV1").
- """
ResourceHolder.__init__(
self,
name=name,
@@ -49,54 +160,35 @@ def __init__(
Machine.__init__(self, backend=backend)
self.backend: ThermocyclerBackend = backend
+ # Wire up capabilities via adapters
+ block_cap = TemperatureControlCapability(backend=_BlockTempAdapter(backend))
+ lid_cap = TemperatureControlCapability(backend=_LidTempAdapter(backend))
+ self._thermocycling = ThermocyclingCapability(
+ backend=_ThermocyclingAdapter(backend), block=block_cap, lid=lid_cap
+ )
+
+ # --- delegate to capabilities ---
+
async def open_lid(self, **backend_kwargs):
- return await self.backend.open_lid(**backend_kwargs)
+ return await self._thermocycling.open_lid()
async def close_lid(self, **backend_kwargs):
- return await self.backend.close_lid(**backend_kwargs)
+ return await self._thermocycling.close_lid()
async def set_block_temperature(self, temperature: List[float], **backend_kwargs):
- """Set the block temperature.
-
- Args:
- temperature: List of target temperatures in °C for multiple zones.
- """
return await self.backend.set_block_temperature(temperature, **backend_kwargs)
async def set_lid_temperature(self, temperature: List[float], **backend_kwargs):
- """Set the lid temperature.
-
- Args:
- temperature: List of target temperatures in °C for multiple zones.
- """
return await self.backend.set_lid_temperature(temperature, **backend_kwargs)
async def deactivate_block(self, **backend_kwargs):
- """Turn off the block heater."""
return await self.backend.deactivate_block(**backend_kwargs)
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).
-
- Args:
- protocol: Protocol object containing stages with steps and repeats.
- block_max_volume: Maximum block volume (µL) for safety.
- """
-
- 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)
+ await self._thermocycling.run_protocol(protocol_to_new(protocol), block_max_volume)
async def run_pcr_profile(
self,
@@ -117,57 +209,42 @@ async def run_pcr_profile(
storage_time: Optional[float] = None,
**backend_kwargs,
):
- """Run a PCR profile with specified parameters.
-
- Args:
- denaturation_temp: List of denaturation temperatures in °C.
- denaturation_time: Denaturation time in seconds.
- annealing_temp: List of annealing temperatures in °C.
- annealing_time: Annealing time in seconds.
- extension_temp: List of extension temperatures in °C.
- extension_time: Extension time in seconds.
- num_cycles: Number of PCR cycles.
- block_max_volume: Maximum block volume (µL) for safety.
- lid_temperature: List of lid temperatures to set during the profile.
- pre_denaturation_temp: Optional list of pre-denaturation temperatures in °C.
- pre_denaturation_time: Optional pre-denaturation time in seconds.
- final_extension_temp: Optional list of final extension temperatures in °C.
- final_extension_time: Optional final extension time in seconds.
- storage_temp: Optional list of storage temperatures in °C.
- storage_time: Optional storage time in seconds.
- """
-
await self.set_lid_temperature(lid_temperature)
await self.wait_for_lid()
stages: List[Stage] = []
- # Pre-denaturation stage (if specified)
if pre_denaturation_temp is not None and pre_denaturation_time is not None:
- pre_denaturation_step = Step(
- temperature=pre_denaturation_temp, hold_seconds=pre_denaturation_time
+ stages.append(
+ Stage(
+ steps=[Step(temperature=pre_denaturation_temp, hold_seconds=pre_denaturation_time)],
+ repeats=1,
+ )
)
- stages.append(Stage(steps=[pre_denaturation_step], repeats=1))
- # Main PCR cycles stage
- pcr_steps = [
- Step(temperature=denaturation_temp, hold_seconds=denaturation_time),
- Step(temperature=annealing_temp, hold_seconds=annealing_time),
- Step(temperature=extension_temp, hold_seconds=extension_time),
- ]
- stages.append(Stage(steps=pcr_steps, repeats=num_cycles))
+ stages.append(
+ Stage(
+ steps=[
+ Step(temperature=denaturation_temp, hold_seconds=denaturation_time),
+ Step(temperature=annealing_temp, hold_seconds=annealing_time),
+ Step(temperature=extension_temp, hold_seconds=extension_time),
+ ],
+ repeats=num_cycles,
+ )
+ )
- # Final extension stage (if specified)
if final_extension_temp is not None and final_extension_time is not None:
- final_extension_step = Step(
- temperature=final_extension_temp, hold_seconds=final_extension_time
+ stages.append(
+ Stage(
+ steps=[Step(temperature=final_extension_temp, hold_seconds=final_extension_time)],
+ repeats=1,
+ )
)
- stages.append(Stage(steps=[final_extension_step], repeats=1))
- # Storage stage (if specified)
if storage_temp is not None and storage_time is not None:
- storage_step = Step(temperature=storage_temp, hold_seconds=storage_time)
- stages.append(Stage(steps=[storage_step], repeats=1))
+ stages.append(
+ Stage(steps=[Step(temperature=storage_temp, hold_seconds=storage_time)], repeats=1)
+ )
protocol = Protocol(stages=stages)
return await self.run_protocol(
@@ -175,108 +252,58 @@ async def run_pcr_profile(
)
async def get_block_current_temperature(self, **backend_kwargs) -> List[float]:
- """Get the current block temperature(s) (°C)."""
return await self.backend.get_block_current_temperature(**backend_kwargs)
async def get_block_target_temperature(self, **backend_kwargs) -> List[float]:
- """Get the block's target temperature(s) (°C)."""
return await self.backend.get_block_target_temperature(**backend_kwargs)
async def get_lid_current_temperature(self, **backend_kwargs) -> List[float]:
- """Get the current lid temperature(s) (°C)."""
return await self.backend.get_lid_current_temperature(**backend_kwargs)
async def get_lid_target_temperature(self, **backend_kwargs) -> List[float]:
- """Get the lid's target temperature(s) (°C), if supported."""
return await self.backend.get_lid_target_temperature(**backend_kwargs)
async def get_lid_open(self, **backend_kwargs) -> bool:
- """Return ``True`` if the lid is open."""
- return await self.backend.get_lid_open(**backend_kwargs)
+ return await self._thermocycling.get_lid_open()
async def get_lid_status(self, **backend_kwargs) -> LidStatus:
- """Get the lid temperature status."""
return await self.backend.get_lid_status(**backend_kwargs)
async def get_block_status(self, **backend_kwargs) -> BlockStatus:
- """Get the block status."""
return await self.backend.get_block_status(**backend_kwargs)
async def get_hold_time(self, **backend_kwargs) -> float:
- """Get remaining hold time (s) for the current step."""
- return await self.backend.get_hold_time(**backend_kwargs)
+ return await self._thermocycling.get_hold_time()
async def get_current_cycle_index(self, **backend_kwargs) -> int:
- """Get the one-based index of the current cycle."""
- return await self.backend.get_current_cycle_index(**backend_kwargs)
+ return await self._thermocycling.get_current_cycle_index()
async def get_total_cycle_count(self, **backend_kwargs) -> int:
- """Get the total number of cycles."""
- return await self.backend.get_total_cycle_count(**backend_kwargs)
+ return await self._thermocycling.get_total_cycle_count()
async def get_current_step_index(self, **backend_kwargs) -> int:
- """Get the one-based index of the current step."""
- return await self.backend.get_current_step_index(**backend_kwargs)
+ return await self._thermocycling.get_current_step_index()
async def get_total_step_count(self, **backend_kwargs) -> int:
- """Get the total number of steps in the current cycle."""
- return await self.backend.get_total_step_count(**backend_kwargs)
+ return await self._thermocycling.get_total_step_count()
async def wait_for_block(self, timeout: float = 600, tolerance: float = 0.5, **backend_kwargs):
- """Wait until block temp reaches target ± tolerance for all zones."""
- targets = await self.get_block_target_temperature(**backend_kwargs)
- start = time.time()
- while time.time() - start < timeout:
- currents = await self.get_block_current_temperature(**backend_kwargs)
- if all(abs(current - target) < tolerance for current, target in zip(currents, targets)):
- return
- await asyncio.sleep(1)
- raise TimeoutError("Block temperature timeout.")
+ self._thermocycling.block.target_temperature = (
+ await self.backend.get_block_target_temperature()
+ )[0]
+ await self._thermocycling.block.wait_for_temperature(timeout=timeout, tolerance=tolerance)
async def wait_for_lid(self, timeout: float = 1200, tolerance: float = 0.5, **backend_kwargs):
- """Wait until the lid temperature reaches target ± ``tolerance`` or the lid temperature status is idle/holding at target."""
- try:
- targets = await self.get_lid_target_temperature(**backend_kwargs)
- except RuntimeError:
- targets = None
- start = time.time()
- while time.time() - start < timeout:
- if targets is not None:
- currents = await self.get_lid_current_temperature(**backend_kwargs)
- if all(abs(current - target) < tolerance for current, target in zip(currents, targets)):
- return
- else:
- # If no target temperature, check status
- status = await self.get_lid_status(**backend_kwargs)
- if status in ["idle", "holding at target"]:
- return
- await asyncio.sleep(1)
- raise TimeoutError("Lid temperature timeout.")
+ self._thermocycling.lid.target_temperature = (await self.backend.get_lid_target_temperature())[
+ 0
+ ]
+ await self._thermocycling.lid.wait_for_temperature(timeout=timeout, tolerance=tolerance)
async def is_profile_running(self, **backend_kwargs) -> bool:
- """Return True if a profile is still in progress."""
- hold = await self.get_hold_time(**backend_kwargs)
- cycle = await self.get_current_cycle_index(**backend_kwargs)
- total_cycles = await self.get_total_cycle_count(**backend_kwargs)
- step = await self.get_current_step_index(**backend_kwargs)
- total_steps = await self.get_total_step_count(**backend_kwargs)
-
- # if still holding in a step, it's running
- if hold and hold > 0:
- return True
- # if haven't reached last cycle (zero-based indexing)
- if cycle < total_cycles - 1:
- return True
- # last cycle but not last step (zero-based indexing)
- if cycle == total_cycles - 1 and step < total_steps - 1:
- return True
- return False
+ return await self._thermocycling.is_profile_running()
async def wait_for_profile_completion(self, poll_interval: float = 60.0, **backend_kwargs):
- """Block until the profile finishes, polling at `poll_interval` seconds."""
- while await self.is_profile_running(**backend_kwargs):
- await asyncio.sleep(poll_interval)
+ await self._thermocycling.wait_for_profile_completion(poll_interval=poll_interval)
def serialize(self) -> dict:
- """JSON-serializable representation."""
return {**Machine.serialize(self), **ResourceHolder.serialize(self)}
diff --git a/pylabrobot/opentrons/__init__.py b/pylabrobot/opentrons/__init__.py
new file mode 100644
index 00000000000..80ce7a99a79
--- /dev/null
+++ b/pylabrobot/opentrons/__init__.py
@@ -0,0 +1,8 @@
+from .thermocycler import (
+ OpentronsBlockBackend,
+ OpentronsLidBackend,
+ OpentronsThermocyclerDriver,
+ OpentronsThermocyclerV1,
+ OpentronsThermocyclerV2,
+ OpentronsThermocyclingBackend,
+)
diff --git a/pylabrobot/opentrons/thermocycler.py b/pylabrobot/opentrons/thermocycler.py
new file mode 100644
index 00000000000..e9bdab35814
--- /dev/null
+++ b/pylabrobot/opentrons/thermocycler.py
@@ -0,0 +1,317 @@
+"""Opentrons Thermocycler backend and device classes."""
+
+from typing import Optional, cast
+
+from pylabrobot.capabilities.temperature_controlling import (
+ TemperatureControlCapability,
+ TemperatureControllerBackend,
+)
+from pylabrobot.capabilities.thermocycling import (
+ Protocol,
+ ThermocyclingBackend,
+ ThermocyclingCapability,
+)
+from pylabrobot.device import Device, DeviceBackend
+from pylabrobot.resources import Coordinate, ItemizedResource, ResourceHolder
+
+try:
+ from ot_api.modules import (
+ list_connected_modules,
+ thermocycler_close_lid,
+ thermocycler_deactivate_block,
+ thermocycler_deactivate_lid,
+ thermocycler_open_lid,
+ thermocycler_run_profile_no_wait,
+ thermocycler_set_block_temperature,
+ thermocycler_set_lid_temperature,
+ )
+
+ USE_OT = True
+except ImportError as e:
+ USE_OT = False
+ _OT_IMPORT_ERROR = e
+
+
+# ---------------------------------------------------------------------------
+# Driver: single class that talks to the OT HTTP API
+# ---------------------------------------------------------------------------
+
+
+class OpentronsThermocyclerDriver(DeviceBackend):
+ """Low-level driver for the Opentrons Thermocycler HTTP API.
+
+ All OT API calls live here. Capability backends delegate to this.
+ """
+
+ def __init__(self, opentrons_id: str):
+ super().__init__()
+ if not USE_OT:
+ raise RuntimeError(
+ "Opentrons is not installed. Please run pip install pylabrobot[opentrons]."
+ f" Import error: {_OT_IMPORT_ERROR}."
+ )
+ self.opentrons_id = opentrons_id
+
+ async def setup(self):
+ pass
+
+ async def stop(self):
+ thermocycler_deactivate_block(module_id=self.opentrons_id)
+ thermocycler_deactivate_lid(module_id=self.opentrons_id)
+
+ def _find_module(self) -> dict:
+ for m in list_connected_modules():
+ if m["id"] == self.opentrons_id:
+ return cast(dict, m["data"])
+ raise RuntimeError(f"Module '{self.opentrons_id}' not found")
+
+ def open_lid(self) -> None:
+ thermocycler_open_lid(module_id=self.opentrons_id)
+
+ def close_lid(self) -> None:
+ thermocycler_close_lid(module_id=self.opentrons_id)
+
+ def set_block_temperature(self, celsius: float) -> None:
+ thermocycler_set_block_temperature(celsius=celsius, module_id=self.opentrons_id)
+
+ def set_lid_temperature(self, celsius: float) -> None:
+ thermocycler_set_lid_temperature(celsius=celsius, module_id=self.opentrons_id)
+
+ def deactivate_block(self) -> None:
+ thermocycler_deactivate_block(module_id=self.opentrons_id)
+
+ def deactivate_lid(self) -> None:
+ thermocycler_deactivate_lid(module_id=self.opentrons_id)
+
+ def run_profile(self, profile: list, block_max_volume: float) -> None:
+ thermocycler_run_profile_no_wait(
+ profile=profile,
+ block_max_volume=block_max_volume,
+ module_id=self.opentrons_id,
+ )
+
+ def get_block_current_temperature(self) -> float:
+ return cast(float, self._find_module()["currentTemperature"])
+
+ def get_block_target_temperature(self) -> Optional[float]:
+ return self._find_module().get("targetTemperature")
+
+ def get_lid_current_temperature(self) -> float:
+ return cast(float, self._find_module()["lidTemperature"])
+
+ def get_lid_target_temperature(self) -> Optional[float]:
+ return self._find_module().get("lidTargetTemperature")
+
+ def get_lid_status_str(self) -> str:
+ return cast(str, self._find_module()["lidStatus"])
+
+ def get_lid_temperature_status_str(self) -> str:
+ return cast(str, self._find_module().get("lidTemperatureStatus", "idle"))
+
+ def get_block_status_str(self) -> str:
+ return cast(str, self._find_module().get("status", "idle"))
+
+ def get_hold_time(self) -> float:
+ return cast(float, self._find_module().get("holdTime", 0.0))
+
+ def get_current_step_index(self) -> Optional[int]:
+ return self._find_module().get("currentStepIndex")
+
+ def get_total_step_count(self) -> Optional[int]:
+ return self._find_module().get("totalStepCount")
+
+
+# ---------------------------------------------------------------------------
+# Capability backends: each takes a driver reference
+# ---------------------------------------------------------------------------
+
+
+class OpentronsBlockBackend(TemperatureControllerBackend):
+ """Block temperature controller backed by the OT driver."""
+
+ def __init__(self, driver: OpentronsThermocyclerDriver):
+ super().__init__()
+ self._driver = driver
+
+ @property
+ def supports_active_cooling(self) -> bool:
+ return True
+
+ async def setup(self):
+ pass
+
+ async def stop(self):
+ pass
+
+ async def set_temperature(self, temperature: float):
+ self._driver.set_block_temperature(temperature)
+
+ async def get_current_temperature(self) -> float:
+ return self._driver.get_block_current_temperature()
+
+ async def deactivate(self):
+ self._driver.deactivate_block()
+
+
+class OpentronsLidBackend(TemperatureControllerBackend):
+ """Lid temperature controller backed by the OT driver."""
+
+ def __init__(self, driver: OpentronsThermocyclerDriver):
+ super().__init__()
+ self._driver = driver
+
+ @property
+ def supports_active_cooling(self) -> bool:
+ return False
+
+ async def setup(self):
+ pass
+
+ async def stop(self):
+ pass
+
+ async def set_temperature(self, temperature: float):
+ self._driver.set_lid_temperature(temperature)
+
+ async def get_current_temperature(self) -> float:
+ return self._driver.get_lid_current_temperature()
+
+ async def deactivate(self):
+ self._driver.deactivate_lid()
+
+
+class OpentronsThermocyclingBackend(ThermocyclingBackend):
+ """Thermocycling capability backed by the OT driver."""
+
+ def __init__(self, driver: OpentronsThermocyclerDriver):
+ super().__init__()
+ self._driver = driver
+
+ async def setup(self):
+ pass
+
+ async def stop(self):
+ pass
+
+ async def open_lid(self) -> None:
+ self._driver.open_lid()
+
+ async def close_lid(self) -> None:
+ self._driver.close_lid()
+
+ async def get_lid_open(self) -> bool:
+ return self._driver.get_lid_status_str() == "open"
+
+ async def run_protocol(self, protocol: Protocol, block_max_volume: float) -> None:
+ ot_profile = []
+ for stage in protocol.stages:
+ for _ in range(stage.repeats):
+ for step in stage.steps:
+ if len(set(step.temperature)) != 1:
+ raise ValueError(
+ f"Opentrons thermocycler only supports a single unique temperature per step, "
+ f"got {set(step.temperature)}"
+ )
+ ot_profile.append({"celsius": step.temperature[0], "holdSeconds": step.hold_seconds})
+
+ self._driver.run_profile(ot_profile, block_max_volume)
+
+ async def get_hold_time(self) -> float:
+ return self._driver.get_hold_time()
+
+ async def get_current_cycle_index(self) -> int:
+ raise NotImplementedError('Opentrons "cycle" concept is not understood currently.')
+
+ async def get_total_cycle_count(self) -> int:
+ raise NotImplementedError('Opentrons "cycle" concept is not understood currently.')
+
+ async def get_current_step_index(self) -> int:
+ step_index = self._driver.get_current_step_index()
+ if step_index is None:
+ raise RuntimeError("Current step index is not available. Is a profile running?")
+ return step_index - 1 # OT is one-based
+
+ async def get_total_step_count(self) -> int:
+ total = self._driver.get_total_step_count()
+ if total is None:
+ raise RuntimeError("Total step count is not available. Is a profile running?")
+ return total
+
+
+# ---------------------------------------------------------------------------
+# Device classes
+# ---------------------------------------------------------------------------
+
+
+class OpentronsThermocyclerV1(ResourceHolder, Device):
+ """Opentrons Thermocycler GEN1."""
+
+ def __init__(
+ self,
+ name: str,
+ opentrons_id: str,
+ child_location: Coordinate = Coordinate.zero(),
+ child: Optional[ItemizedResource] = None,
+ ):
+ self._driver = OpentronsThermocyclerDriver(opentrons_id=opentrons_id)
+
+ ResourceHolder.__init__(
+ self,
+ name=name,
+ size_x=172.0,
+ size_y=316.0,
+ size_z=154.0,
+ child_location=child_location,
+ category="thermocycler",
+ model="thermocyclerModuleV1",
+ )
+ Device.__init__(self, backend=self._driver)
+
+ tc_backend = OpentronsThermocyclingBackend(self._driver)
+ self.block = TemperatureControlCapability(backend=OpentronsBlockBackend(self._driver))
+ self.lid = TemperatureControlCapability(backend=OpentronsLidBackend(self._driver))
+ self.thermocycling = ThermocyclingCapability(backend=tc_backend, block=self.block, lid=self.lid)
+ self._capabilities = [self.block, self.lid, self.thermocycling]
+
+ if child is not None:
+ self.assign_child_resource(child, location=child_location)
+
+ def serialize(self) -> dict:
+ return {**ResourceHolder.serialize(self), **Device.serialize(self)}
+
+
+class OpentronsThermocyclerV2(ResourceHolder, Device):
+ """Opentrons Thermocycler GEN2."""
+
+ def __init__(
+ self,
+ name: str,
+ opentrons_id: str,
+ child_location: Coordinate = Coordinate.zero(),
+ child: Optional[ItemizedResource] = None,
+ ):
+ self._driver = OpentronsThermocyclerDriver(opentrons_id=opentrons_id)
+
+ ResourceHolder.__init__(
+ self,
+ name=name,
+ size_x=172.0,
+ size_y=244.95,
+ size_z=170.35,
+ child_location=child_location,
+ category="thermocycler",
+ model="thermocyclerModuleV2",
+ )
+ Device.__init__(self, backend=self._driver)
+
+ tc_backend = OpentronsThermocyclingBackend(self._driver)
+ self.block = TemperatureControlCapability(backend=OpentronsBlockBackend(self._driver))
+ self.lid = TemperatureControlCapability(backend=OpentronsLidBackend(self._driver))
+ self.thermocycling = ThermocyclingCapability(backend=tc_backend, block=self.block, lid=self.lid)
+ self._capabilities = [self.block, self.lid, self.thermocycling]
+
+ if child is not None:
+ self.assign_child_resource(child, location=child_location)
+
+ def serialize(self) -> dict:
+ return {**ResourceHolder.serialize(self), **Device.serialize(self)}
diff --git a/pylabrobot/thermo_fisher/__init__.py b/pylabrobot/thermo_fisher/__init__.py
index e69de29bb2d..7a810f58fbe 100644
--- a/pylabrobot/thermo_fisher/__init__.py
+++ b/pylabrobot/thermo_fisher/__init__.py
@@ -0,0 +1,10 @@
+from .thermocyclers import (
+ ATC,
+ ProFlexSingleBlock,
+ ProFlexThreeBlock,
+ ThermoFisherBlockBackend,
+ ThermoFisherLidBackend,
+ ThermoFisherThermocycler,
+ ThermoFisherThermocyclerDriver,
+ ThermoFisherThermocyclingBackend,
+)
diff --git a/pylabrobot/thermo_fisher/atc.py b/pylabrobot/thermo_fisher/atc.py
new file mode 100644
index 00000000000..31ff4110f9d
--- /dev/null
+++ b/pylabrobot/thermo_fisher/atc.py
@@ -0,0 +1 @@
+from .thermocyclers.atc import ATC # noqa: F401
diff --git a/pylabrobot/thermo_fisher/proflex.py b/pylabrobot/thermo_fisher/proflex.py
new file mode 100644
index 00000000000..890706046ed
--- /dev/null
+++ b/pylabrobot/thermo_fisher/proflex.py
@@ -0,0 +1 @@
+from .thermocyclers.proflex import ProFlexSingleBlock, ProFlexThreeBlock # noqa: F401
diff --git a/pylabrobot/thermo_fisher/thermocycler.py b/pylabrobot/thermo_fisher/thermocycler.py
new file mode 100644
index 00000000000..0ef856b3890
--- /dev/null
+++ b/pylabrobot/thermo_fisher/thermocycler.py
@@ -0,0 +1,19 @@
+"""Backward-compatibility shim -- all code now lives in the ``thermocyclers`` sub-package."""
+
+from .thermocyclers.block_backend import ThermoFisherBlockBackend
+from .thermocyclers.driver import ThermoFisherThermocyclerDriver
+from .thermocyclers.lid_backend import ThermoFisherLidBackend
+from .thermocyclers.thermocycler import ThermoFisherThermocycler
+from .thermocyclers.thermocycling_backend import ThermoFisherThermocyclingBackend
+from .thermocyclers.utils import RunProgress, _gen_protocol_data, _generate_run_info_files
+
+__all__ = [
+ "ThermoFisherBlockBackend",
+ "ThermoFisherLidBackend",
+ "ThermoFisherThermocycler",
+ "ThermoFisherThermocyclerDriver",
+ "ThermoFisherThermocyclingBackend",
+ "RunProgress",
+ "_gen_protocol_data",
+ "_generate_run_info_files",
+]
diff --git a/pylabrobot/thermo_fisher/thermocyclers/__init__.py b/pylabrobot/thermo_fisher/thermocyclers/__init__.py
new file mode 100644
index 00000000000..31f0b37706a
--- /dev/null
+++ b/pylabrobot/thermo_fisher/thermocyclers/__init__.py
@@ -0,0 +1,8 @@
+from .atc import ATC
+from .block_backend import ThermoFisherBlockBackend
+from .driver import ThermoFisherThermocyclerDriver
+from .lid_backend import ThermoFisherLidBackend
+from .proflex import ProFlexSingleBlock, ProFlexThreeBlock
+from .thermocycler import ThermoFisherThermocycler
+from .thermocycling_backend import ThermoFisherThermocyclingBackend
+from .utils import RunProgress, _gen_protocol_data, _generate_run_info_files
diff --git a/pylabrobot/thermo_fisher/thermocyclers/atc.py b/pylabrobot/thermo_fisher/thermocyclers/atc.py
new file mode 100644
index 00000000000..d62a569ad9b
--- /dev/null
+++ b/pylabrobot/thermo_fisher/thermocyclers/atc.py
@@ -0,0 +1,8 @@
+from .thermocycler import ThermoFisherThermocycler
+
+
+def ATC(name: str, ip: str, **kwargs) -> ThermoFisherThermocycler:
+ """Create an ATC thermocycler with a single 3-zone block and lid control (BID 31)."""
+ return ThermoFisherThermocycler(
+ name=name, ip=ip, num_blocks=1, num_temp_zones=3, supports_lid_control=True, **kwargs
+ )
diff --git a/pylabrobot/thermo_fisher/thermocyclers/block_backend.py b/pylabrobot/thermo_fisher/thermocyclers/block_backend.py
new file mode 100644
index 00000000000..1addac80a90
--- /dev/null
+++ b/pylabrobot/thermo_fisher/thermocyclers/block_backend.py
@@ -0,0 +1,75 @@
+from typing import List
+
+from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend
+
+from .driver import ThermoFisherThermocyclerDriver
+
+
+class ThermoFisherBlockBackend(TemperatureControllerBackend):
+ """Temperature control backend for a single thermocycler block."""
+
+ def __init__(self, driver: ThermoFisherThermocyclerDriver, block_id: int):
+ super().__init__()
+ self._driver = driver
+ self._block_id = block_id
+
+ @property
+ def supports_active_cooling(self) -> bool:
+ return True
+
+ async def setup(self):
+ pass # driver handles setup
+
+ async def stop(self):
+ pass # driver handles stop
+
+ async def set_temperature(self, temperature: float):
+ temps = [temperature] * self._driver.num_temp_zones
+ await self._driver.send_command(
+ {"cmd": f"TBC{self._block_id + 1}:RAMP", "params": {"rate": 100}, "args": temps},
+ response_timeout=60,
+ )
+
+ async def get_current_temperature(self) -> float:
+ res = await self._driver.send_command(
+ {"cmd": f"TBC{self._block_id + 1}:TBC:BlockTemperatures?"}
+ )
+ temps = self._driver._parse_scpi_response(res)["args"]
+ return float(temps[0])
+
+ async def deactivate(self):
+ await self._driver.set_block_idle_temp(temp=25, control_enabled=False, block_id=self._block_id)
+
+ # ----- Additional public methods -----
+
+ async def set_block_idle_temp(self, temp: float, control_enabled: bool = True) -> None:
+ await self._driver.set_block_idle_temp(
+ temp=temp, block_id=self._block_id, control_enabled=control_enabled
+ )
+
+ async def get_sample_temps(self) -> List[float]:
+ res = await self._driver.send_command(
+ {"cmd": f"TBC{self._block_id + 1}:TBC:SampleTemperatures?"}
+ )
+ from typing import cast
+
+ return cast(List[float], self._driver._parse_scpi_response(res)["args"])
+
+ async def block_ramp_single_temp(self, target_temp: float, rate: float = 100):
+ """Set a single temperature for the block with a ramp rate.
+
+ It might be better to use ``set_temperature`` to set individual temperatures for each
+ zone.
+ """
+ if self._block_id not in self._driver.available_blocks:
+ raise ValueError(f"Block {self._block_id} not available")
+ res = await self._driver.send_command(
+ {
+ "cmd": f"TBC{self._block_id + 1}:BlockRAMP",
+ "params": {"rate": rate},
+ "args": [target_temp],
+ },
+ response_timeout=60,
+ )
+ if self._driver._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to ramp block temperature")
diff --git a/pylabrobot/thermo_fisher/thermocyclers/driver.py b/pylabrobot/thermo_fisher/thermocyclers/driver.py
new file mode 100644
index 00000000000..63db07a0db0
--- /dev/null
+++ b/pylabrobot/thermo_fisher/thermocyclers/driver.py
@@ -0,0 +1,507 @@
+import asyncio
+import hashlib
+import hmac
+import logging
+import re
+import ssl
+import warnings
+from base64 import b64decode
+from typing import Any, Dict, List, Optional, cast
+
+from pylabrobot.device import DeviceBackend
+from pylabrobot.io import Socket
+
+logger = logging.getLogger(__name__)
+
+
+class ThermoFisherThermocyclerDriver(DeviceBackend):
+ """SCPI driver for ThermoFisher thermocyclers (ProFlex / ATC).
+
+ Owns the socket connection and handles SSL/auth, block discovery,
+ power management, file I/O, buzzer, etc.
+ """
+
+ def __init__(
+ self,
+ ip: str,
+ use_ssl: bool = False,
+ serial_number: Optional[str] = None,
+ ):
+ super().__init__()
+ self.ip = ip
+ self.use_ssl = use_ssl
+
+ if use_ssl:
+ self.port = 7443
+ if serial_number is None:
+ raise ValueError("Serial number is required for SSL connection (port 7443)")
+ self.device_shared_secret = f"53rv1c3{serial_number}".encode("utf-8")
+
+ ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+ ssl_context.check_hostname = False
+ ssl_context.verify_mode = ssl.CERT_NONE
+ # TLSv1 is required for legacy ThermoFisher hardware - silence deprecation warning
+ with warnings.catch_warnings():
+ warnings.filterwarnings("ignore", message="ssl.TLSVersion.TLSv1 is deprecated")
+ ssl_context.minimum_version = ssl.TLSVersion.TLSv1
+ ssl_context.maximum_version = ssl.TLSVersion.TLSv1
+ try:
+ # This is required for some legacy devices that use older ciphers or protocols
+ # that are disabled by default in newer OpenSSL versions.
+ ssl_context.set_ciphers("DEFAULT:@SECLEVEL=0")
+ except (ValueError, ssl.SSLError):
+ # This might fail on some systems/implementations, but it's worth a try
+ pass
+ else:
+ self.port = 7000
+ self.device_shared_secret = b"f4ct0rymt55"
+ ssl_context = None
+
+ self.io = Socket(
+ human_readable_device_name="Thermo Fisher Thermocycler",
+ host=ip,
+ port=self.port,
+ ssl_context=ssl_context,
+ server_hostname=serial_number,
+ )
+ self._num_blocks: Optional[int] = None
+ self.num_temp_zones: int = 0
+ self.bid: str = ""
+ self.available_blocks: List[int] = []
+ self.logger = logging.getLogger("pylabrobot.thermo_fisher.thermocycler")
+ self.current_runs: Dict[int, str] = {}
+
+ @property
+ def num_blocks(self) -> int:
+ if self._num_blocks is None:
+ raise ValueError("Number of blocks not set. Call setup() first.")
+ return self._num_blocks
+
+ # ----- Authentication / connection -----
+
+ def _get_auth_token(self, challenge: str):
+ challenge_bytes = challenge.encode("utf-8")
+ return hmac.new(self.device_shared_secret, challenge_bytes, hashlib.md5).hexdigest()
+
+ # ----- SCPI message building / parsing -----
+
+ def _build_scpi_msg(self, data: dict) -> str:
+ def generate_output(data_dict: dict, indent_level=0) -> str:
+ lines = []
+ if indent_level == 0:
+ line = data_dict["cmd"]
+ for k, v in data_dict.get("params", {}).items():
+ if v is True:
+ line += f" -{k}"
+ elif v is False:
+ pass
+ else:
+ line += f" -{k}={v}"
+ for val in data_dict.get("args", []):
+ line += f" {val}"
+ if "multiline" in data_dict:
+ line += f" <{data_dict['tag']}>"
+ lines.append(line)
+
+ if "multiline" in data_dict:
+ lines += generate_multiline(data_dict, indent_level + 1)
+ lines.append(f"{data_dict['tag']}>")
+ return "\n".join(lines)
+
+ def generate_multiline(multi_dict, indent_level=0) -> List[str]:
+ def indent():
+ return " " * 8 * indent_level
+
+ lines = []
+ for element in multi_dict["multiline"]:
+ line = indent() + element["cmd"]
+ for k, v in element.get("params", {}).items():
+ line += f" -{k}={v}"
+ for arg in element.get("args", []):
+ line += f" {arg}"
+
+ if "multiline" in element:
+ line += f" <{element['tag']}>"
+ lines.append(line)
+ lines += generate_multiline(element, indent_level + 1)
+ lines.append(indent() + f"{element['tag']}>")
+ else:
+ lines.append(line)
+ return lines
+
+ return generate_output(data) + "\r\n"
+
+ def _parse_scpi_response(self, response: str):
+ START_TAG_REGEX = re.compile(r"(.*?)<(multiline\.[a-zA-Z0-9_]+)>")
+ END_TAG_REGEX = re.compile(r"(multiline\.[a-zA-Z0-9_]+)>")
+ PARAM_REGEX = re.compile(r"^-([A-Za-z0-9_]+)(?:=(.*))?$")
+
+ def parse_command_line(line):
+ start_match = START_TAG_REGEX.search(line)
+ if start_match:
+ cmd_part = start_match.group(1).strip()
+ tag_name = start_match.group(2)
+ else:
+ cmd_part = line
+ tag_name = None
+
+ if not cmd_part:
+ return None, [], tag_name
+
+ parts = cmd_part.split()
+ command = parts[0]
+ args = parts[1:]
+ return command, args, tag_name
+
+ def process_args(args_list):
+ params: Dict[str, Any] = {}
+ positional_args = []
+ for arg in args_list:
+ match = PARAM_REGEX.match(arg)
+ if match:
+ key = match.group(1)
+ value = match.group(2)
+ if value is None:
+ params[key] = True
+ else:
+ params[key] = value
+ else:
+ positional_args.append(arg)
+ return positional_args, params
+
+ def parse_structure(scpi_resp: str):
+ first_space_idx = scpi_resp.find(" ")
+ status = scpi_resp[:first_space_idx]
+ scpi_resp = scpi_resp[first_space_idx + 1 :]
+ lines = scpi_resp.split("\n")
+
+ root = {"status": status, "multiline": []}
+ stack = [root]
+
+ for original_line in lines:
+ line = original_line.strip()
+ if not line:
+ continue
+ end_match = END_TAG_REGEX.match(line)
+ if end_match:
+ if len(stack) > 1:
+ stack.pop()
+ else:
+ raise ValueError("Unmatched end tag: {}>".format(end_match.group(1)))
+ continue
+
+ command, args, start_tag = parse_command_line(line)
+ if command is not None:
+ pos_args, params = process_args(args)
+ node = {"cmd": command, "args": pos_args, "params": params}
+ if start_tag:
+ node["multiline"] = []
+ stack[-1]["multiline"].append(node) # type: ignore
+ stack.append(node)
+ node["tag"] = start_tag
+ else:
+ stack[-1]["multiline"].append(node) # type: ignore
+
+ if len(stack) != 1:
+ raise ValueError("Unbalanced tags in response.")
+ return root
+
+ if response.startswith("ERRor"):
+ raise ValueError(f"Error response: {response}")
+
+ result = parse_structure(response)
+ status_val = result["status"]
+ result = result["multiline"][0]
+ result["status"] = status_val
+ return result
+
+ # ----- Low-level I/O -----
+
+ async def _read_response(self, timeout=1, read_once=True) -> str:
+ try:
+ if read_once:
+ response_b = await self.io.read(timeout=timeout)
+ else:
+ response_b = await self.io.read_until_eof(timeout=timeout)
+ response = response_b.decode("ascii")
+ self.logger.debug("Response received: %s", response)
+ return response
+ except TimeoutError:
+ return ""
+ except Exception as e:
+ self.logger.error("Error reading from socket: %s", e)
+ return ""
+
+ async def send_command(self, data, response_timeout=1, read_once=True):
+ msg = self._build_scpi_msg(data)
+ self.logger.debug("Command sent: %s", msg.strip())
+
+ await self.io.write(msg.encode("ascii"), timeout=response_timeout)
+ return await self._read_response(timeout=response_timeout, read_once=read_once)
+
+ async def _scpi_authenticate(self):
+ await self.io.setup()
+ await self._read_response(timeout=5)
+ challenge_res = await self.send_command({"cmd": "CHAL?"})
+ challenge = self._parse_scpi_response(challenge_res)["args"][0]
+ auth = self._get_auth_token(challenge)
+ auth_res = await self.send_command({"cmd": "AUTH", "args": [auth]})
+ if self._parse_scpi_response(auth_res)["status"] != "OK":
+ raise ValueError("Authentication failed")
+ acc_res = await self.send_command(
+ {"cmd": "ACCess", "params": {"stealth": True}, "args": ["Controller"]}
+ )
+ if self._parse_scpi_response(acc_res)["status"] != "OK":
+ raise ValueError("Access failed")
+
+ # ----- Block discovery -----
+
+ async def _load_num_blocks_and_type(self):
+ block_present_val = await self.get_block_presence()
+ if block_present_val == "0":
+ raise ValueError("Block not present")
+ self.bid = await self.get_block_id()
+ if self.bid == "12":
+ self._num_blocks = 1
+ self.num_temp_zones = 6
+ elif self.bid == "13":
+ self._num_blocks = 3
+ self.num_temp_zones = 2
+ elif self.bid == "31":
+ self._num_blocks = 1
+ self.num_temp_zones = 3
+ else:
+ raise NotImplementedError("Only BID 31, 12 and 13 are supported")
+
+ async def _load_available_blocks(self) -> None:
+ await self._scpi_authenticate()
+ await self._load_num_blocks_and_type()
+ assert self._num_blocks is not None, "Number of blocks not set"
+ for block_id in range(self._num_blocks):
+ block_error = await self.get_error(block_id=block_id)
+ if block_error != "0":
+ raise ValueError(f"Block {block_id} has error: {block_error}")
+ if not await self.is_block_running(block_id=block_id):
+ if block_id not in self.available_blocks:
+ self.available_blocks.append(block_id)
+
+ # ----- Block helpers -----
+
+ async def get_block_id(self):
+ res = await self.send_command({"cmd": "TBC:BID?"})
+ if self._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to get block ID")
+ return self._parse_scpi_response(res)["args"][0]
+
+ async def get_block_presence(self):
+ res = await self.send_command({"cmd": "TBC:BlockPresence?"})
+ if self._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to get block presence")
+ return self._parse_scpi_response(res)["args"][0]
+
+ async def is_block_running(self, block_id: int) -> bool:
+ run_name = await self.get_run_name(block_id=block_id)
+ return run_name != "-"
+
+ async def get_run_name(self, block_id: int) -> str:
+ res = await self.send_command({"cmd": f"TBC{block_id + 1}:RUNTitle?"})
+ if self._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to get run title")
+ return cast(str, self._parse_scpi_response(res)["args"][0])
+
+ async def get_error(self, block_id: int):
+ res = await self.send_command({"cmd": f"TBC{block_id + 1}:ERROR?"})
+ if self._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to get error")
+ return self._parse_scpi_response(res)["args"][0]
+
+ # ----- Power -----
+
+ async def power_on(self):
+ res = await self.send_command({"cmd": "POWER", "args": ["On"]}, response_timeout=20)
+ if res == "" or self._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to power on")
+
+ async def power_off(self):
+ res = await self.send_command({"cmd": "POWER", "args": ["Off"]})
+ if self._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to power off")
+
+ # ----- File I/O -----
+
+ async def _write_file(self, filename: str, data: str, encoding="plain"):
+ write_res = await self.send_command(
+ {
+ "cmd": "FILe:WRITe",
+ "params": {"encoding": encoding},
+ "args": [filename],
+ "multiline": [{"cmd": data}],
+ "tag": "multiline.write",
+ },
+ response_timeout=1,
+ read_once=False,
+ )
+ if self._parse_scpi_response(write_res)["status"] != "OK":
+ raise ValueError("Failed to write file")
+
+ # ----- Nickname -----
+
+ async def get_nickname(self) -> str:
+ res = await self.send_command({"cmd": "SYST:SETT:NICK?"})
+ return cast(str, self._parse_scpi_response(res)["args"][0])
+
+ async def set_nickname(self, nickname: str) -> None:
+ res = await self.send_command({"cmd": "SYST:SETT:NICK", "args": [nickname]})
+ if self._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to set nickname")
+
+ # ----- Buzzer -----
+
+ async def buzzer_on(self):
+ res = await self.send_command({"cmd": "BUZZer+"})
+ if self._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to turn on buzzer")
+
+ async def buzzer_off(self):
+ res = await self.send_command({"cmd": "BUZZer-"})
+ if self._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to turn off buzzer")
+
+ async def send_morse_code(self, morse_code: str):
+ short_beep_duration = 0.1
+ long_beep_duration = short_beep_duration * 3
+ space_duration = short_beep_duration * 3
+ assert all(char in ".- " for char in morse_code), "Invalid characters in morse code"
+ for char in morse_code:
+ if char == ".":
+ await self.buzzer_on()
+ await asyncio.sleep(short_beep_duration)
+ await self.buzzer_off()
+ elif char == "-":
+ await self.buzzer_on()
+ await asyncio.sleep(long_beep_duration)
+ await self.buzzer_off()
+ elif char == " ":
+ await asyncio.sleep(space_duration)
+ await asyncio.sleep(short_beep_duration)
+
+ # ----- Idle temperature control (used during setup) -----
+
+ async def set_block_idle_temp(
+ self, temp: float, block_id: int, control_enabled: bool = True
+ ) -> None:
+ if block_id not in self.available_blocks:
+ raise ValueError(f"Block {block_id} is not available")
+ res = await self.send_command(
+ {"cmd": f"TBC{block_id + 1}:BLOCK", "args": [1 if control_enabled else 0, temp]}
+ )
+ if self._parse_scpi_response(res)["status"] != "NEXT":
+ raise ValueError("Failed to set block idle temperature")
+ follow_up = await self._read_response()
+ if self._parse_scpi_response(follow_up)["status"] != "OK":
+ raise ValueError("Failed to set block idle temperature")
+
+ async def set_cover_idle_temp(
+ self, temp: float, block_id: int, control_enabled: bool = True
+ ) -> None:
+ if block_id not in self.available_blocks:
+ raise ValueError(f"Block {block_id} not available")
+ res = await self.send_command(
+ {"cmd": f"TBC{block_id + 1}:COVER", "args": [1 if control_enabled else 0, temp]}
+ )
+ if self._parse_scpi_response(res)["status"] != "NEXT":
+ raise ValueError("Failed to set cover idle temperature")
+ follow_up = await self._read_response()
+ if self._parse_scpi_response(follow_up)["status"] != "OK":
+ raise ValueError("Failed to set cover idle temperature")
+
+ # ----- Setup / stop (lifecycle) -----
+
+ async def setup(
+ self,
+ block_idle_temp: float = 25.0,
+ cover_idle_temp: float = 105.0,
+ blocks_to_setup: Optional[List[int]] = None,
+ ):
+ await self._scpi_authenticate()
+ await self.power_on()
+ await self._load_num_blocks_and_type()
+ if blocks_to_setup is None:
+ await self._load_available_blocks()
+ if len(self.available_blocks) == 0:
+ raise ValueError("No available blocks. Set blocks_to_setup to force setup")
+ else:
+ self.available_blocks = blocks_to_setup
+ for block_index in self.available_blocks:
+ await self.set_block_idle_temp(temp=block_idle_temp, block_id=block_index)
+ await self.set_cover_idle_temp(temp=cover_idle_temp, block_id=block_index)
+
+ async def stop(self):
+ for block_id in list(self.current_runs.keys()):
+ await self.abort_run(block_id=block_id)
+ await self.deactivate_lid(block_id=block_id)
+ await self.deactivate_block(block_id=block_id)
+ await self.io.stop()
+
+ # ----- Run management (device-level, not per-block) -----
+
+ async def check_run_exists(self, run_name: str) -> bool:
+ res = await self.send_command(
+ {"cmd": "RUNS:EXISTS?", "args": [run_name], "params": {"type": "folders"}}
+ )
+ if self._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to check if run exists")
+ return cast(str, self._parse_scpi_response(res)["args"][1]) == "True"
+
+ async def create_run(self, run_name: str):
+ res = await self.send_command({"cmd": "RUNS:NEW", "args": [run_name]}, response_timeout=10)
+ if self._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to create run")
+ return self._parse_scpi_response(res)["args"][0]
+
+ async def get_log_by_runname(self, run_name: str) -> str:
+ res = await self.send_command(
+ {"cmd": "FILe:READ?", "args": [f"RUNS:{run_name}/{run_name}.log"]},
+ response_timeout=5,
+ read_once=False,
+ )
+ if self._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to get log")
+ res.replace("\n", "")
+ encoded_log_match = re.search(r"(.*?)
", res, re.DOTALL)
+ if not encoded_log_match:
+ raise ValueError("Failed to parse log content")
+ encoded_log = encoded_log_match.group(1).strip()
+ return b64decode(encoded_log).decode("utf-8")
+
+ async def get_elapsed_run_time_from_log(self, run_name: str) -> int:
+ """Parse a log to find the elapsed run time in hh:mm:ss format and convert to total seconds."""
+ log = await self.get_log_by_runname(run_name)
+ elapsed_time_match = re.search(r"Run Time:\s*(\d+):(\d+):(\d+)", log)
+ if not elapsed_time_match:
+ raise ValueError("Failed to parse elapsed time from log. Expected hh:mm:ss format.")
+ hours = int(elapsed_time_match.group(1))
+ minutes = int(elapsed_time_match.group(2))
+ seconds = int(elapsed_time_match.group(3))
+ return (hours * 3600) + (minutes * 60) + seconds
+
+ # ----- Methods used by stop() -----
+
+ async def abort_run(self, block_id: int):
+ if not await self.is_block_running(block_id=block_id):
+ self.logger.info("Failed to abort protocol: no run is currently running on this block")
+ return
+ run_name = await self.get_run_name(block_id=block_id)
+ abort_res = await self.send_command({"cmd": f"TBC{block_id + 1}:AbortRun", "args": [run_name]})
+ if self._parse_scpi_response(abort_res)["status"] != "OK":
+ self.logger.error(abort_res)
+ self.logger.error("Failed to abort protocol")
+ raise ValueError("Failed to abort protocol")
+ self.logger.info("Protocol aborted")
+ await asyncio.sleep(10)
+
+ async def deactivate_lid(self, block_id: int):
+ return await self.set_cover_idle_temp(temp=105, control_enabled=False, block_id=block_id)
+
+ async def deactivate_block(self, block_id: int):
+ return await self.set_block_idle_temp(temp=25, control_enabled=False, block_id=block_id)
diff --git a/pylabrobot/thermo_fisher/thermocyclers/lid_backend.py b/pylabrobot/thermo_fisher/thermocyclers/lid_backend.py
new file mode 100644
index 00000000000..13d19e4d908
--- /dev/null
+++ b/pylabrobot/thermo_fisher/thermocyclers/lid_backend.py
@@ -0,0 +1,47 @@
+from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend
+
+from .driver import ThermoFisherThermocyclerDriver
+
+
+class ThermoFisherLidBackend(TemperatureControllerBackend):
+ """Temperature control backend for a single thermocycler lid (cover heater)."""
+
+ def __init__(self, driver: ThermoFisherThermocyclerDriver, block_id: int):
+ super().__init__()
+ self._driver = driver
+ self._block_id = block_id
+
+ @property
+ def supports_active_cooling(self) -> bool:
+ return False
+
+ async def setup(self):
+ pass # driver handles setup
+
+ async def stop(self):
+ pass # driver handles stop
+
+ async def set_temperature(self, temperature: float):
+ res = await self._driver.send_command(
+ {"cmd": f"TBC{self._block_id + 1}:CoverRAMP", "params": {}, "args": [temperature]},
+ response_timeout=60,
+ )
+ if self._driver._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to ramp cover temperature")
+
+ async def get_current_temperature(self) -> float:
+ res = await self._driver.send_command(
+ {"cmd": f"TBC{self._block_id + 1}:TBC:CoverTemperatures?"}
+ )
+ temps = self._driver._parse_scpi_response(res)["args"]
+ return float(temps[0])
+
+ async def deactivate(self):
+ await self._driver.set_cover_idle_temp(temp=105, control_enabled=False, block_id=self._block_id)
+
+ # ----- Additional public methods -----
+
+ async def set_cover_idle_temp(self, temp: float, control_enabled: bool = True) -> None:
+ await self._driver.set_cover_idle_temp(
+ temp=temp, block_id=self._block_id, control_enabled=control_enabled
+ )
diff --git a/pylabrobot/thermo_fisher/thermocyclers/proflex.py b/pylabrobot/thermo_fisher/thermocyclers/proflex.py
new file mode 100644
index 00000000000..3311191d7dc
--- /dev/null
+++ b/pylabrobot/thermo_fisher/thermocyclers/proflex.py
@@ -0,0 +1,11 @@
+from .thermocycler import ThermoFisherThermocycler
+
+
+def ProFlexSingleBlock(name: str, ip: str, **kwargs) -> ThermoFisherThermocycler:
+ """Create a ProFlex with a single 6-zone block (BID 12)."""
+ return ThermoFisherThermocycler(name=name, ip=ip, num_blocks=1, num_temp_zones=6, **kwargs)
+
+
+def ProFlexThreeBlock(name: str, ip: str, **kwargs) -> ThermoFisherThermocycler:
+ """Create a ProFlex with three 2-zone blocks (BID 13)."""
+ return ThermoFisherThermocycler(name=name, ip=ip, num_blocks=3, num_temp_zones=2, **kwargs)
diff --git a/pylabrobot/thermo_fisher/thermocyclers/thermocycler.py b/pylabrobot/thermo_fisher/thermocyclers/thermocycler.py
new file mode 100644
index 00000000000..e839af7c75b
--- /dev/null
+++ b/pylabrobot/thermo_fisher/thermocyclers/thermocycler.py
@@ -0,0 +1,93 @@
+from typing import List, Optional
+
+from pylabrobot.capabilities.temperature_controlling import (
+ TemperatureControlCapability,
+)
+from pylabrobot.capabilities.thermocycling import (
+ ThermocyclingCapability,
+)
+from pylabrobot.device import Device
+from pylabrobot.resources import Coordinate, ResourceHolder
+
+from .block_backend import ThermoFisherBlockBackend
+from .driver import ThermoFisherThermocyclerDriver
+from .lid_backend import ThermoFisherLidBackend
+from .thermocycling_backend import ThermoFisherThermocyclingBackend
+
+
+class ThermoFisherThermocycler(ResourceHolder, Device):
+ """ThermoFisher thermocycler device using the capability composition architecture.
+
+ Creates a shared SCPI driver and exposes per-block capabilities for
+ temperature control (block + lid) and thermocycling (protocol execution).
+ """
+
+ def __init__(
+ self,
+ name: str,
+ ip: str,
+ num_blocks: int,
+ num_temp_zones: int,
+ supports_lid_control: bool = False,
+ use_ssl: bool = False,
+ serial_number: Optional[str] = None,
+ block_idle_temp: float = 25.0,
+ cover_idle_temp: float = 105.0,
+ child_location: Coordinate = Coordinate.zero(),
+ size_x: float = 300.0,
+ size_y: float = 300.0,
+ size_z: float = 200.0,
+ ):
+ self._driver = ThermoFisherThermocyclerDriver(
+ ip=ip,
+ use_ssl=use_ssl,
+ serial_number=serial_number,
+ )
+
+ ResourceHolder.__init__(
+ self,
+ name=name,
+ size_x=size_x,
+ size_y=size_y,
+ size_z=size_z,
+ child_location=child_location,
+ )
+ Device.__init__(self, backend=self._driver)
+
+ self._block_idle_temp = block_idle_temp
+ self._cover_idle_temp = cover_idle_temp
+
+ self.blocks: List[TemperatureControlCapability] = []
+ self.lids: List[TemperatureControlCapability] = []
+ self.thermocycling: List[ThermocyclingCapability] = []
+
+ for block_id in range(num_blocks):
+ block_be = ThermoFisherBlockBackend(driver=self._driver, block_id=block_id)
+ lid_be = ThermoFisherLidBackend(driver=self._driver, block_id=block_id)
+ tc_be = ThermoFisherThermocyclingBackend(
+ driver=self._driver,
+ block_id=block_id,
+ supports_lid_control=supports_lid_control,
+ )
+
+ block_cap = TemperatureControlCapability(backend=block_be)
+ lid_cap = TemperatureControlCapability(backend=lid_be)
+ tc_cap = ThermocyclingCapability(backend=tc_be, block=block_cap, lid=lid_cap)
+
+ self.blocks.append(block_cap)
+ self.lids.append(lid_cap)
+ self.thermocycling.append(tc_cap)
+
+ self._capabilities = [
+ cap for triple in zip(self.blocks, self.lids, self.thermocycling) for cap in triple
+ ]
+
+ async def setup(self, **backend_kwargs):
+ """Set up the thermocycler: authenticate, power on, discover blocks, set idle temps."""
+ await self._driver.setup(
+ block_idle_temp=self._block_idle_temp,
+ cover_idle_temp=self._cover_idle_temp,
+ )
+ for cap in self._capabilities:
+ await cap._on_setup()
+ self._setup_finished = True
diff --git a/pylabrobot/thermo_fisher/thermocyclers/thermocycling_backend.py b/pylabrobot/thermo_fisher/thermocyclers/thermocycling_backend.py
new file mode 100644
index 00000000000..d69d28f73be
--- /dev/null
+++ b/pylabrobot/thermo_fisher/thermocyclers/thermocycling_backend.py
@@ -0,0 +1,415 @@
+import asyncio
+import logging
+from typing import List, Optional, cast
+
+from pylabrobot.capabilities.thermocycling import (
+ Protocol,
+ Stage,
+ Step,
+ ThermocyclingBackend,
+)
+
+from .driver import ThermoFisherThermocyclerDriver
+from .utils import RunProgress, _gen_protocol_data, _generate_run_info_files
+
+logger = logging.getLogger(__name__)
+
+
+class ThermoFisherThermocyclingBackend(ThermocyclingBackend):
+ """Thermocycling backend for a single block, delegating to the shared driver."""
+
+ def __init__(
+ self,
+ driver: ThermoFisherThermocyclerDriver,
+ block_id: int,
+ supports_lid_control: bool = False,
+ ):
+ super().__init__()
+ self._driver = driver
+ self._block_id = block_id
+ self._supports_lid_control = supports_lid_control
+
+ async def setup(self):
+ pass # driver handles setup
+
+ async def stop(self):
+ pass # driver handles stop
+
+ # ----- Lid control -----
+
+ async def open_lid(self) -> None:
+ if not self._supports_lid_control:
+ raise NotImplementedError("Lid control is not supported on this thermocycler model")
+ res = await self._driver.send_command({"cmd": "lidopen"}, response_timeout=25, read_once=True)
+ if self._driver._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to open lid")
+
+ async def close_lid(self) -> None:
+ if not self._supports_lid_control:
+ raise NotImplementedError("Lid control is not supported on this thermocycler model")
+ res = await self._driver.send_command({"cmd": "lidclose"}, response_timeout=20, read_once=True)
+ if self._driver._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to close lid")
+
+ async def get_lid_open(self) -> bool:
+ raise NotImplementedError(
+ "ThermoFisher thermocycler hardware does not support lid open status check"
+ )
+
+ # ----- Protocol execution -----
+
+ async def run_protocol(self, protocol: Protocol, block_max_volume: float) -> None:
+ await self._run_protocol_with_options(protocol=protocol, block_max_volume=block_max_volume)
+
+ async def _run_protocol_with_options(
+ self,
+ protocol: Protocol,
+ block_max_volume: float,
+ run_name: str = "testrun",
+ user: str = "Admin",
+ run_mode: str = "Fast",
+ cover_temp: float = 105,
+ cover_enabled: bool = True,
+ protocol_name: str = "PCR_Protocol",
+ stage_name_prefixes: Optional[List[str]] = None,
+ ) -> None:
+ block_id = self._block_id
+
+ if await self.check_run_exists(run_name):
+ logger.warning(f"Run {run_name} already exists")
+ else:
+ await self.create_run(run_name)
+
+ # wrap all Steps in Stage objects where necessary
+ for i, stage in enumerate(protocol.stages):
+ if isinstance(stage, Step):
+ protocol.stages[i] = Stage(steps=[stage], repeats=1)
+
+ for stage in protocol.stages:
+ for step in stage.steps:
+ if len(step.temperature) != self._driver.num_temp_zones:
+ raise ValueError(
+ f"Each step in the protocol must have a list of temperatures "
+ f"of length {self._driver.num_temp_zones}. "
+ f"Step temperatures: {step.temperature} (length {len(step.temperature)})"
+ )
+
+ stage_name_prefixes = stage_name_prefixes or ["Stage_" for _ in range(len(protocol.stages))]
+
+ await self._scpi_write_run_info(
+ protocol=protocol,
+ block_id=block_id,
+ run_name=run_name,
+ user_name=user,
+ sample_volume=block_max_volume,
+ run_mode=run_mode,
+ cover_temp=cover_temp,
+ cover_enabled=cover_enabled,
+ protocol_name=protocol_name,
+ )
+ await self._scpi_run_protocol(
+ protocol=protocol,
+ run_name=run_name,
+ block_id=block_id,
+ sample_volume=block_max_volume,
+ run_mode=run_mode,
+ cover_temp=cover_temp,
+ cover_enabled=cover_enabled,
+ protocol_name=protocol_name,
+ user_name=user,
+ stage_name_prefixes=stage_name_prefixes,
+ )
+
+ async def _scpi_write_run_info(
+ self,
+ protocol: Protocol,
+ run_name: str,
+ block_id: int,
+ sample_volume: float,
+ run_mode: str,
+ protocol_name: str,
+ cover_temp: float,
+ cover_enabled: bool,
+ user_name: str,
+ ):
+ xmlfile, tmpfile = _generate_run_info_files(
+ protocol=protocol,
+ block_id=block_id,
+ sample_volume=sample_volume,
+ run_mode=run_mode,
+ protocol_name=protocol_name,
+ cover_temp=cover_temp,
+ cover_enabled=cover_enabled,
+ user_name="LifeTechnologies",
+ )
+ await self._driver._write_file(f"runs:{run_name}/{protocol_name}.method", xmlfile)
+ await self._driver._write_file(f"runs:{run_name}/{run_name}.tmp", tmpfile)
+
+ async def _scpi_run_protocol(
+ self,
+ protocol: Protocol,
+ run_name: str,
+ block_id: int,
+ sample_volume: float,
+ run_mode: str,
+ protocol_name: str,
+ cover_temp: float,
+ cover_enabled: bool,
+ user_name: str,
+ stage_name_prefixes: List[str],
+ ):
+ load_res = await self._driver.send_command(
+ data=_gen_protocol_data(
+ protocol=protocol,
+ block_id=block_id,
+ sample_volume=sample_volume,
+ run_mode=run_mode,
+ cover_temp=cover_temp,
+ cover_enabled=cover_enabled,
+ protocol_name=protocol_name,
+ stage_name_prefixes=stage_name_prefixes,
+ ),
+ response_timeout=5,
+ read_once=False,
+ )
+ if self._driver._parse_scpi_response(load_res)["status"] != "OK":
+ logger.error(load_res)
+ logger.error("Protocol failed to load")
+ raise ValueError("Protocol failed to load")
+
+ start_res = await self._driver.send_command(
+ {
+ "cmd": f"TBC{block_id + 1}:RunProtocol",
+ "params": {
+ "User": user_name,
+ "CoverTemperature": cover_temp,
+ "CoverEnabled": "On" if cover_enabled else "Off",
+ },
+ "args": [protocol_name, run_name],
+ },
+ response_timeout=2,
+ read_once=False,
+ )
+
+ if self._driver._parse_scpi_response(start_res)["status"] == "NEXT":
+ logger.info("Protocol started")
+ else:
+ logger.error(start_res)
+ logger.error("Protocol failed to start")
+ raise ValueError("Protocol failed to start")
+
+ total_time = await self.get_estimated_run_time()
+ total_time = float(total_time)
+ logger.info(f"Estimated run time: {total_time}")
+ self._driver.current_runs[block_id] = run_name
+
+ # ----- Abort / continue -----
+
+ async def abort_run(self):
+ block_id = self._block_id
+ if not await self.is_block_running():
+ logger.info("Failed to abort protocol: no run is currently running on this block")
+ return
+ run_name = await self.get_run_name()
+ abort_res = await self._driver.send_command(
+ {"cmd": f"TBC{block_id + 1}:AbortRun", "args": [run_name]}
+ )
+ if self._driver._parse_scpi_response(abort_res)["status"] != "OK":
+ logger.error(abort_res)
+ logger.error("Failed to abort protocol")
+ raise ValueError("Failed to abort protocol")
+ logger.info("Protocol aborted")
+ await asyncio.sleep(10)
+
+ async def continue_run(self):
+ block_id = self._block_id
+ for _ in range(3):
+ await asyncio.sleep(1)
+ res = await self._driver.send_command({"cmd": f"TBC{block_id + 1}:CONTinue"})
+ if self._driver._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to continue from indefinite hold")
+
+ # ----- Block running status -----
+
+ async def is_block_running(self) -> bool:
+ run_name = await self.get_run_name()
+ return run_name != "-"
+
+ async def get_run_name(self) -> str:
+ block_id = self._block_id
+ res = await self._driver.send_command({"cmd": f"TBC{block_id + 1}:RUNTitle?"})
+ if self._driver._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to get run title")
+ return cast(str, self._driver._parse_scpi_response(res)["args"][0])
+
+ async def get_error(self) -> str:
+ block_id = self._block_id
+ res = await self._driver.send_command({"cmd": f"TBC{block_id + 1}:ERROR?"})
+ if self._driver._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to get error")
+ return self._driver._parse_scpi_response(res)["args"][0]
+
+ # ----- Run progress -----
+
+ async def get_current_cycle_index(self) -> int:
+ progress = await self._get_run_progress()
+ if progress is None:
+ raise RuntimeError("No progress information available")
+
+ if progress["RunTitle"] == "-":
+ await self._driver._read_response(timeout=5)
+ raise RuntimeError("Protocol completed or not started")
+
+ if progress["Stage"] == "POSTRun":
+ raise RuntimeError("Protocol in POSTRun stage, no current cycle index")
+
+ if progress["Stage"] != "-" and progress["Step"] != "-":
+ return int(progress["Stage"]) - 1
+
+ raise RuntimeError("Current cycle index is not available, protocol may not be running")
+
+ async def get_current_step_index(self) -> int:
+ progress = await self._get_run_progress()
+ if progress is None:
+ raise RuntimeError("No progress information available")
+
+ if progress["RunTitle"] == "-":
+ await self._driver._read_response(timeout=5)
+ raise RuntimeError("Protocol completed or not started")
+
+ if progress["Stage"] == "POSTRun":
+ raise RuntimeError("Protocol in POSTRun stage, no current cycle index")
+
+ if progress["Stage"] != "-" and progress["Step"] != "-":
+ return int(progress["Step"]) - 1
+
+ raise RuntimeError("Current step index is not available, protocol may not be running")
+
+ async def get_hold_time(self) -> float:
+ raise NotImplementedError(
+ "get_hold_time is not supported by ThermoFisher thermocycler hardware"
+ )
+
+ async def get_total_cycle_count(self) -> int:
+ raise NotImplementedError(
+ "get_total_cycle_count is not supported by ThermoFisher thermocycler hardware"
+ )
+
+ async def get_total_step_count(self) -> int:
+ raise NotImplementedError(
+ "get_total_step_count is not supported by ThermoFisher thermocycler hardware"
+ )
+
+ # ----- Run info / progress -----
+
+ async def _get_run_progress(self):
+ block_id = self._block_id
+ res = await self._driver.send_command({"cmd": f"TBC{block_id + 1}:RUNProgress?"})
+ parsed_res = self._driver._parse_scpi_response(res)
+ if parsed_res["status"] != "OK":
+ raise ValueError("Failed to get run status")
+ if parsed_res["cmd"] == f"TBC{block_id + 1}:RunProtocol":
+ await self._driver._read_response()
+ return False
+ return self._driver._parse_scpi_response(res)["params"]
+
+ async def get_run_info(self, protocol: Protocol) -> RunProgress:
+ block_id = self._block_id
+ progress = await self._get_run_progress()
+ run_name = await self.get_run_name()
+ if not progress:
+ logger.info("Protocol completed")
+ return RunProgress(
+ running=False,
+ stage="completed",
+ elapsed_time=await self.get_elapsed_run_time_from_log(run_name=run_name),
+ remaining_time=0,
+ )
+
+ if progress["RunTitle"] == "-":
+ await self._driver._read_response(timeout=5)
+ logger.info("Protocol completed")
+ return RunProgress(
+ running=False,
+ stage="completed",
+ elapsed_time=await self.get_elapsed_run_time_from_log(run_name=run_name),
+ remaining_time=0,
+ )
+
+ if progress["Stage"] == "POSTRun":
+ logger.info("Protocol in POSTRun")
+ return RunProgress(
+ running=True,
+ stage="POSTRun",
+ elapsed_time=await self.get_elapsed_run_time_from_log(run_name=run_name),
+ remaining_time=0,
+ )
+
+ time_elapsed = await self.get_elapsed_run_time()
+ remaining_time = await self.get_remaining_run_time()
+
+ if progress["Stage"] != "-" and progress["Step"] != "-":
+ current_step = protocol.stages[int(progress["Stage"]) - 1].steps[int(progress["Step"]) - 1]
+ if current_step.hold_seconds == float("inf"):
+ while True:
+ block_temps_res = await self._driver.send_command(
+ {"cmd": f"TBC{block_id + 1}:TBC:BlockTemperatures?"}
+ )
+ block_temps = self._driver._parse_scpi_response(block_temps_res)["args"]
+ target_temps = current_step.temperature
+ if all(
+ abs(float(block_temps[i]) - target_temps[i]) < 0.5 for i in range(len(block_temps))
+ ):
+ break
+ await asyncio.sleep(5)
+ logger.info("Infinite hold")
+ return RunProgress(
+ running=False,
+ stage="infinite_hold",
+ elapsed_time=time_elapsed,
+ remaining_time=remaining_time,
+ )
+
+ logger.info(f"Elapsed time: {time_elapsed}")
+ logger.info(f"Remaining time: {remaining_time}")
+ return RunProgress(
+ running=True,
+ stage=progress["Stage"],
+ elapsed_time=time_elapsed,
+ remaining_time=remaining_time,
+ )
+
+ async def get_estimated_run_time(self):
+ block_id = self._block_id
+ res = await self._driver.send_command({"cmd": f"TBC{block_id + 1}:ESTimatedTime?"})
+ if self._driver._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to get estimated run time")
+ return self._driver._parse_scpi_response(res)["args"][0]
+
+ async def get_elapsed_run_time(self):
+ block_id = self._block_id
+ res = await self._driver.send_command({"cmd": f"TBC{block_id + 1}:ELAPsedTime?"})
+ if self._driver._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to get elapsed run time")
+ return int(self._driver._parse_scpi_response(res)["args"][0])
+
+ async def get_remaining_run_time(self):
+ block_id = self._block_id
+ res = await self._driver.send_command({"cmd": f"TBC{block_id + 1}:REMainingTime?"})
+ if self._driver._parse_scpi_response(res)["status"] != "OK":
+ raise ValueError("Failed to get remaining run time")
+ return int(self._driver._parse_scpi_response(res)["args"][0])
+
+ # ----- Log / run file management -----
+
+ async def get_log_by_runname(self, run_name: str) -> str:
+ return await self._driver.get_log_by_runname(run_name)
+
+ async def get_elapsed_run_time_from_log(self, run_name: str) -> int:
+ return await self._driver.get_elapsed_run_time_from_log(run_name)
+
+ async def check_run_exists(self, run_name: str) -> bool:
+ return await self._driver.check_run_exists(run_name)
+
+ async def create_run(self, run_name: str):
+ return await self._driver.create_run(run_name)
diff --git a/pylabrobot/thermo_fisher/thermocyclers/utils.py b/pylabrobot/thermo_fisher/thermocyclers/utils.py
new file mode 100644
index 00000000000..60095566787
--- /dev/null
+++ b/pylabrobot/thermo_fisher/thermocyclers/utils.py
@@ -0,0 +1,205 @@
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass
+from typing import List
+from xml.dom import minidom
+
+from pylabrobot.capabilities.thermocycling import (
+ Protocol,
+ Stage,
+ Step,
+)
+
+
+@dataclass
+class RunProgress:
+ stage: str
+ elapsed_time: int
+ remaining_time: int
+ running: bool
+
+
+def _generate_run_info_files(
+ protocol: Protocol,
+ block_id: int,
+ sample_volume: float,
+ run_mode: str,
+ protocol_name: str,
+ cover_enabled: bool,
+ cover_temp: float,
+ user_name: str,
+ file_version="1.0.1",
+ remote_run="true",
+ hub="testhub",
+ user="Guest",
+ notes="",
+ default_ramp_rate=100,
+ ramp_rate_unit="DEGREES_PER_SECOND",
+):
+ root = ET.Element("TCProtocol")
+ file_version_el = ET.SubElement(root, "FileVersion")
+ file_version_el.text = file_version
+
+ protocol_name_el = ET.SubElement(root, "ProtocolName")
+ protocol_name_el.text = protocol_name
+
+ user_name_el = ET.SubElement(root, "UserName")
+ user_name_el.text = user_name
+
+ block_id_el = ET.SubElement(root, "BlockID")
+ block_id_el.text = str(block_id + 1)
+
+ sample_volume_el = ET.SubElement(root, "SampleVolume")
+ sample_volume_el.text = str(sample_volume)
+
+ run_mode_el = ET.SubElement(root, "RunMode")
+ run_mode_el.text = str(run_mode)
+
+ cover_temp_el = ET.SubElement(root, "CoverTemperature")
+ cover_temp_el.text = str(cover_temp)
+
+ cover_setting_el = ET.SubElement(root, "CoverSetting")
+ cover_setting_el.text = "On" if cover_enabled else "Off"
+
+ for stage_obj in protocol.stages:
+ if isinstance(stage_obj, Step):
+ stage = Stage(steps=[stage_obj], repeats=1)
+ else:
+ stage = stage_obj
+
+ stage_el = ET.SubElement(root, "TCStage")
+ stage_flag_el = ET.SubElement(stage_el, "StageFlag")
+ stage_flag_el.text = "CYCLING"
+
+ num_repetitions_el = ET.SubElement(stage_el, "NumOfRepetitions")
+ num_repetitions_el.text = str(stage.repeats)
+
+ for step in stage.steps:
+ step_el = ET.SubElement(stage_el, "TCStep")
+
+ ramp_rate_el = ET.SubElement(step_el, "RampRate")
+ ramp_rate_el.text = str(
+ int(step.rate if step.rate is not None else default_ramp_rate) / 100 * 6
+ )
+
+ ramp_rate_unit_el = ET.SubElement(step_el, "RampRateUnit")
+ ramp_rate_unit_el.text = ramp_rate_unit
+
+ for t_val in step.temperature:
+ temp_el = ET.SubElement(step_el, "Temperature")
+ temp_el.text = str(t_val)
+
+ hold_time_el = ET.SubElement(step_el, "HoldTime")
+ if step.hold_seconds == float("inf"):
+ hold_time_el.text = "-1"
+ elif step.hold_seconds == 0:
+ hold_time_el.text = "0"
+ else:
+ hold_time_el.text = str(step.hold_seconds)
+
+ ext_temp_el = ET.SubElement(step_el, "ExtTemperature")
+ ext_temp_el.text = "0"
+
+ ext_hold_el = ET.SubElement(step_el, "ExtHoldTime")
+ ext_hold_el.text = "0"
+
+ ext_start_cycle_el = ET.SubElement(step_el, "ExtStartingCycle")
+ ext_start_cycle_el.text = "1"
+
+ rough_string = ET.tostring(root, encoding="utf-8")
+ reparsed = minidom.parseString(rough_string)
+
+ xml_declaration = '\n'
+ pretty_xml_as_string = (
+ xml_declaration + reparsed.toprettyxml(indent=" ")[len('') :]
+ )
+
+ output2_lines = [
+ f"-remoterun= {remote_run}",
+ f"-hub= {hub}",
+ f"-user= {user}",
+ f"-method= {protocol_name}",
+ f"-volume= {sample_volume}",
+ f"-cover= {cover_temp}",
+ f"-mode= {run_mode}",
+ f"-coverEnabled= {'On' if cover_enabled else 'Off'}",
+ f"-notes= {notes}",
+ ]
+ output2_string = "\n".join(output2_lines)
+
+ return pretty_xml_as_string, output2_string
+
+
+def _gen_protocol_data(
+ protocol: Protocol,
+ block_id: int,
+ sample_volume: float,
+ run_mode: str,
+ cover_temp: float,
+ cover_enabled: bool,
+ protocol_name: str,
+ stage_name_prefixes: List[str],
+):
+ def step_to_scpi(step: Step, step_index: int) -> dict:
+ multiline: List[dict] = []
+
+ infinite_hold = step.hold_seconds == float("inf")
+
+ if infinite_hold and min(step.temperature) < 20:
+ multiline.append({"cmd": "CoverRAMP", "params": {}, "args": ["30"]})
+
+ multiline.append(
+ {
+ "cmd": "RAMP",
+ "params": {"rate": str(step.rate if step.rate is not None else 100)},
+ "args": [str(t) for t in step.temperature],
+ }
+ )
+
+ if infinite_hold:
+ multiline.append({"cmd": "HOLD", "params": {}, "args": []})
+ elif step.hold_seconds > 0:
+ multiline.append({"cmd": "HOLD", "params": {}, "args": [str(step.hold_seconds)]})
+
+ return {
+ "cmd": "STEP",
+ "params": {},
+ "args": [str(step_index)],
+ "tag": "multiline.step",
+ "multiline": multiline,
+ }
+
+ def stage_to_scpi(stage: Stage, stage_index: int, stage_name_prefix: str) -> dict:
+ return {
+ "cmd": "STAGe",
+ "params": {"repeat": str(stage.repeats)},
+ "args": [stage_index, f"{stage_name_prefix}_{stage_index}"],
+ "tag": "multiline.stage",
+ "multiline": [step_to_scpi(step, i + 1) for i, step in enumerate(stage.steps)],
+ }
+
+ stages = protocol.stages
+ assert len(stages) == len(stage_name_prefixes), (
+ "Number of stages must match number of stage names"
+ )
+
+ data = {
+ "cmd": f"TBC{block_id + 1}:Protocol",
+ "params": {"Volume": str(sample_volume), "RunMode": run_mode},
+ "args": [protocol_name],
+ "tag": "multiline.outer",
+ "multiline": [
+ stage_to_scpi(stage, stage_index=i + 1, stage_name_prefix=stage_name_prefix)
+ for i, (stage, stage_name_prefix) in enumerate(zip(stages, stage_name_prefixes))
+ ],
+ "_blockId": block_id + 1,
+ "_coverTemp": cover_temp,
+ "_coverEnabled": "On" if cover_enabled else "Off",
+ "_infinite_holds": [
+ [stage_index, step_index]
+ for stage_index, stage in enumerate(stages)
+ for step_index, step in enumerate(stage.steps)
+ if step.hold_seconds == float("inf")
+ ],
+ }
+
+ return data