From 08e5a3b88976e836b612330f681d8946e1d8e609 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 23 Mar 2026 16:08:00 -0700 Subject: [PATCH 1/9] Add thermocycling capability with legacy adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capability: - ThermocyclingBackend(DeviceBackend) — thin: lid control, protocol execution, and profile progress queries only. Block/lid temperature handled by separate TemperatureControllerBackend instances. - ThermocyclingCapability composes with block/lid TemperatureControlCapability - Own copies of Step/Stage/Protocol data types - run_pcr_profile convenience method Legacy: - Translation functions (protocol_to_new, protocol_from_new) in legacy standard.py - Three adapters in legacy thermocycler.py: _BlockTempAdapter, _LidTempAdapter, _ThermocyclingAdapter — wrap legacy ThermocyclerBackend for the new capability interfaces - Legacy Thermocycler frontend delegates to capabilities via adapters - All 4 legacy tests pass Co-Authored-By: Claude Opus 4.6 (1M context) --- .../capabilities/thermocycling/__init__.py | 3 + .../capabilities/thermocycling/backend.py | 55 +++ .../capabilities/thermocycling/standard.py | 65 ++++ .../thermocycling/thermocycling.py | 152 ++++++++ pylabrobot/legacy/thermocycling/standard.py | 47 +++ .../legacy/thermocycling/thermocycler.py | 330 ++++++++++-------- 6 files changed, 501 insertions(+), 151 deletions(-) create mode 100644 pylabrobot/capabilities/thermocycling/__init__.py create mode 100644 pylabrobot/capabilities/thermocycling/backend.py create mode 100644 pylabrobot/capabilities/thermocycling/standard.py create mode 100644 pylabrobot/capabilities/thermocycling/thermocycling.py 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/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/thermocycler.py b/pylabrobot/legacy/thermocycling/thermocycler.py index 96e107315bc..9568f20a9f0 100644 --- a/pylabrobot/legacy/thermocycling/thermocycler.py +++ b/pylabrobot/legacy/thermocycling/thermocycler.py @@ -1,15 +1,139 @@ -"""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_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: + from pylabrobot.legacy.thermocycling.standard import protocol_from_new + + 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 +148,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 +161,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 +210,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 +253,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)} From ad443ef94fa41efc589e7b21018a07267f64459e Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 23 Mar 2026 16:11:12 -0700 Subject: [PATCH 2/9] Add Opentrons thermocycler vendor module - OpentronsThermocyclerDriver: single class for all OT HTTP API calls - Three capability backends backed by the driver: - OpentronsBlockBackend (TemperatureControllerBackend) - OpentronsLidBackend (TemperatureControllerBackend) - OpentronsThermocyclingBackend (ThermocyclingBackend) - OpentronsThermocyclerV1 and V2 device classes with self._driver - Legacy opentrons_backend.py delegates to new module via driver Co-Authored-By: Claude Opus 4.6 (1M context) --- .../legacy/thermocycling/opentrons_backend.py | 192 +++-------- pylabrobot/opentrons/__init__.py | 8 + pylabrobot/opentrons/thermocycler.py | 304 ++++++++++++++++++ 3 files changed, 365 insertions(+), 139 deletions(-) create mode 100644 pylabrobot/opentrons/__init__.py create mode 100644 pylabrobot/opentrons/thermocycler.py diff --git a/pylabrobot/legacy/thermocycling/opentrons_backend.py b/pylabrobot/legacy/thermocycling/opentrons_backend.py index 9cd569bd2c0..22e0204148c 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._find_module().get("lidTemperatureStatus", "idle") 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._find_module().get("status", "idle") 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/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..ea345c55602 --- /dev/null +++ b/pylabrobot/opentrons/thermocycler.py @@ -0,0 +1,304 @@ +"""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 +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: + """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): + 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 + + 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_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): + self._driver.deactivate_block() + self._driver.deactivate_lid() + + 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) + tc_backend = OpentronsThermocyclingBackend(self._driver) + + 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=tc_backend) + + 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) + tc_backend = OpentronsThermocyclingBackend(self._driver) + + 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=tc_backend) + + 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)} From 601ce868b6d159accde93057db7250bb9b276649 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 23 Mar 2026 16:45:15 -0700 Subject: [PATCH 3/9] Add Thermo Fisher thermocycler vendor module (ProFlex/ATC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThermoFisherThermocyclerDriver: single class for all SCPI I/O, auth, block discovery, power, temperature control, protocol execution - Three capability backends per block: - ThermoFisherBlockBackend (TemperatureControllerBackend) - ThermoFisherLidBackend (TemperatureControllerBackend) - ThermoFisherThermocyclingBackend (ThermocyclingBackend) - ThermoFisherThermocycler device with factory classmethods: proflex_single_block (1 block/6 zones), proflex_three_block (3 blocks/2 zones), atc (1 block/3 zones + lid control) - Each block gets its own ThermocyclingCapability — run 3 PCR protocols simultaneously - Legacy ThermoFisherThermocyclerBackend gutted to thin adapter (~275 lines, was ~1050) - All 7 legacy tests pass Co-Authored-By: Claude Opus 4.6 (1M context) --- .../thermo_fisher_thermocycler.py | 1133 +++----------- pylabrobot/thermo_fisher/__init__.py | 7 + pylabrobot/thermo_fisher/thermocycler.py | 1334 +++++++++++++++++ 3 files changed, 1535 insertions(+), 939 deletions(-) create mode 100644 pylabrobot/thermo_fisher/thermocycler.py diff --git a/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py b/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py index 79aac61d343..e6cf693e81a 100644 --- a/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py +++ b/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py @@ -1,212 +1,39 @@ -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 :class:`ThermoFisherThermocyclerDriver` +(``pylabrobot.thermo_fisher.thermocycler``). 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 -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.thermocycler import ( + RunProgress, + ThermoFisherThermocyclerDriver, + _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 :class:`ThermoFisherThermocyclerDriver`. + """ + + RunProgress = RunProgress # keep nested reference for backward compat def __init__( self, @@ -217,707 +44,124 @@ 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._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] = {} - - @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"") - 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"") - 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"") - 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 "" - - 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"]} + self._driver = ThermoFisherThermocyclerDriver( + ip=ip, use_ssl=use_ssl, serial_number=serial_number ) - 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) - - 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"]) - - 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 + @property + def io(self): + return self._driver.io - 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) + @io.setter + def io(self, value): + self._driver.io = value - # Updated regex to capture hours, minutes, and seconds - elapsed_time_match = re.search(r"Run Time:\s*(\d+):(\d+):(\d+)", log) + @property + def num_temp_zones(self) -> int: + return self._driver.num_temp_zones - if not elapsed_time_match: - raise ValueError("Failed to parse elapsed time from log. Expected hh:mm:ss format.") + @num_temp_zones.setter + def num_temp_zones(self, value: int): + self._driver.num_temp_zones = value - # 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)) + @property + def use_ssl(self) -> bool: + return self._driver.use_ssl - # Calculate the total seconds - total_seconds = (hours * 3600) + (minutes * 60) + seconds + @property + def device_shared_secret(self) -> bytes: + return self._driver.device_shared_secret - return total_seconds + @property + def port(self) -> int: + return self._driver.port - 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 bid(self) -> str: + return self._driver.bid - 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") + @bid.setter + def bid(self, value: str): + self._driver.bid = value - 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") + @property + def available_blocks(self) -> List[int]: + return self._driver.available_blocks - 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. - - 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] + # -- ThermocyclerBackend interface ------------------------------------------- - 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) - - 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 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, - ) - - # *************Methods implementing ThermocyclerBackend*********************** + async def stop(self): + await self._driver.stop() - async def setup( - self, block_idle_temp=25, cover_idle_temp=105, blocks_to_setup: Optional[List[int]] = None + async def set_block_temperature( + self, temperature: List[float], block_id: Optional[int] = None, rate: float = 100 ): - 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) + assert block_id is not None, "block_id must be specified" + await self._driver.set_block_temperature(temperature=temperature, block_id=block_id, rate=rate) + + 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" + await self._driver.set_lid_temperature(temperature=temperature, block_id=block_id) 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]: + return await self._driver.get_block_current_temperature(block_id=block_id) 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"]) + return await self._driver.get_lid_current_temperature(block_id=block_id) async def run_protocol( self, @@ -933,102 +177,117 @@ 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, + new_protocol = protocol_to_new(protocol) + await self._driver.run_protocol( + protocol=new_protocol, + block_max_volume=block_max_volume, block_id=block_id, 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: + return await self._driver.get_run_info(protocol=protocol_to_new(protocol), block_id=block_id) - 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._driver.abort_run(block_id=block_id) - await self.io.stop() + async def continue_run(self, block_id: int): + await self._driver.continue_run(block_id=block_id) - 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._driver.get_sample_temps(block_id=block_id) + + async def get_nickname(self) -> str: + return await self._driver.get_nickname() + + async def set_nickname(self, nickname: str) -> None: + await self._driver.set_nickname(nickname) + + async def get_log_by_runname(self, run_name: str) -> str: + return await self._driver.get_log_by_runname(run_name) - if progress["RunTitle"] == "-": - await self._read_response(timeout=5) - raise RuntimeError("Protocol completed or not started") + 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) - if progress["Stage"] == "POSTRun": - raise RuntimeError("Protocol in POSTRun stage, no current cycle index") + async def set_block_idle_temp( + self, temp: float, block_id: int, control_enabled: bool = True + ) -> None: + await self._driver.set_block_idle_temp( + temp=temp, block_id=block_id, control_enabled=control_enabled + ) + + async def set_cover_idle_temp( + self, temp: float, block_id: int, control_enabled: bool = True + ) -> None: + await self._driver.set_cover_idle_temp( + temp=temp, block_id=block_id, control_enabled=control_enabled + ) - if progress["Stage"] != "-" and progress["Step"] != "-": - return int(progress["Stage"]) - 1 + async def block_ramp_single_temp(self, target_temp: float, block_id: int, rate: float = 100): + await self._driver.block_ramp_single_temp(target_temp=target_temp, block_id=block_id, rate=rate) - raise RuntimeError("Current cycle index is not available, protocol may not be running") + async def buzzer_on(self): + await self._driver.buzzer_on() - async def get_current_step_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 buzzer_off(self): + await self._driver.buzzer_off() - if progress["RunTitle"] == "-": - await self._read_response(timeout=5) - raise RuntimeError("Protocol completed or not started") + async def send_morse_code(self, morse_code: str): + await self._driver.send_morse_code(morse_code) - if progress["Stage"] == "POSTRun": - raise RuntimeError("Protocol in POSTRun stage, no current cycle index") + async def power_on(self): + await self._driver.power_on() - if progress["Stage"] != "-" and progress["Step"] != "-": - return int(progress["Step"]) - 1 + async def power_off(self): + await self._driver.power_off() - raise RuntimeError("Current step index is not available, protocol may not be running") + 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) + + async def get_run_name(self, block_id: int) -> str: + return await self._driver.get_run_name(block_id=block_id) + + async def get_estimated_run_time(self, block_id: int): + return await self._driver.get_estimated_run_time(block_id=block_id) + + async def get_elapsed_run_time(self, block_id: int): + return await self._driver.get_elapsed_run_time(block_id=block_id) + + async def get_remaining_run_time(self, block_id: int): + return await self._driver.get_remaining_run_time(block_id=block_id) + + async def get_error(self, block_id): + return await self._driver.get_error(block_id=block_id) + + async def get_current_cycle_index(self, block_id: Optional[int] = None) -> int: + assert block_id is not None, "block_id must be specified" + return await self._driver.get_current_cycle_index(block_id=block_id) + + 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._driver.get_current_step_index(block_id=block_id) + + # -- stubs for abstract methods not implemented on this hardware ------------- + + 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 +297,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/thermo_fisher/__init__.py b/pylabrobot/thermo_fisher/__init__.py index e69de29bb2d..2df251015a0 100644 --- a/pylabrobot/thermo_fisher/__init__.py +++ b/pylabrobot/thermo_fisher/__init__.py @@ -0,0 +1,7 @@ +from .thermocycler import ( + ThermoFisherBlockBackend, + ThermoFisherLidBackend, + ThermoFisherThermocycler, + ThermoFisherThermocyclerDriver, + ThermoFisherThermocyclingBackend, +) diff --git a/pylabrobot/thermo_fisher/thermocycler.py b/pylabrobot/thermo_fisher/thermocycler.py new file mode 100644 index 00000000000..5da85c5d3f4 --- /dev/null +++ b/pylabrobot/thermo_fisher/thermocycler.py @@ -0,0 +1,1334 @@ +import asyncio +import hashlib +import hmac +import logging +import re +import ssl +import warnings +import xml.etree.ElementTree as ET +from base64 import b64decode +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, cast +from xml.dom import minidom + +from pylabrobot.capabilities.temperature_controlling import ( + TemperatureControlCapability, + TemperatureControllerBackend, +) +from pylabrobot.capabilities.thermocycling import ( + Protocol, + Stage, + Step, + ThermocyclingBackend, + ThermocyclingCapability, +) +from pylabrobot.device import Device +from pylabrobot.io import Socket +from pylabrobot.resources import Coordinate, ResourceHolder + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Module-level helpers +# --------------------------------------------------------------------------- + + +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 + + +# --------------------------------------------------------------------------- +# ThermoFisherThermocyclerDriver — all SCPI I/O lives here +# --------------------------------------------------------------------------- + + +@dataclass +class RunProgress: + stage: str + elapsed_time: int + remaining_time: int + running: bool + + +class ThermoFisherThermocyclerDriver: + """Low-level SCPI driver for ThermoFisher thermocyclers (ProFlex / ATC). + + This is a plain class, NOT a DeviceBackend. It owns the socket, handles + SSL/auth, block discovery, power management, temperature control, protocol + execution, run management, file I/O, buzzer, etc. + """ + + def __init__( + self, + ip: str, + use_ssl: bool = False, + serial_number: Optional[str] = None, + ): + 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"") + 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"") + 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"") + 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 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() + 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) + + # ----- Temperature queries ----- + + async def get_block_current_temperature(self, block_id: int) -> 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: int) -> 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"]) + + async def get_lid_current_temperature(self, block_id: int) -> List[float]: + res = await self.send_command({"cmd": f"TBC{block_id + 1}:TBC:CoverTemperatures?"}) + return cast(List[float], self._parse_scpi_response(res)["args"]) + + # ----- 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") + + # ----- Log / run file management ----- + + 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() + log = b64decode(encoded_log).decode("utf-8") + return log + + 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)) + total_seconds = (hours * 3600) + (minutes * 60) + seconds + return total_seconds + + # ----- Idle temperature control ----- + + 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") + + # ----- Active temperature ramping ----- + + async def set_block_temperature(self, temperature: List[float], block_id: int, 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") + + 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. + + 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") + + async def set_lid_temperature(self, temperature: List[float], block_id: int): + 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") + + # ----- Deactivation ----- + + 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) + + # ----- 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) + + # ----- Run management ----- + + 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") + + 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 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_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: int): + 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: 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") + + # ----- Protocol write / run helpers ----- + + 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._write_file(f"runs:{run_name}/{protocol_name}.method", xmlfile) + await self._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.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._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 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) + + # ----- Run info / progress ----- + + 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 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 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 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(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 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 RunProgress( + running=True, + stage=progress["Stage"], + elapsed_time=time_elapsed, + remaining_time=remaining_time, + ) + + # ----- Protocol execution (public) ----- + + async def run_protocol( + self, + protocol: Protocol, + block_max_volume: float, + block_id: int, + 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, + ): + 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 + 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 _ 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, + ) + + # ----- Run progress queries ----- + + async def get_current_cycle_index(self, block_id: int) -> int: + progress = await self._get_run_progress(block_id=block_id) + if progress is None: + raise RuntimeError("No progress information available") + + if progress["RunTitle"] == "-": + await self._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, block_id: int) -> int: + progress = await self._get_run_progress(block_id=block_id) + if progress is None: + raise RuntimeError("No progress information available") + + if progress["RunTitle"] == "-": + await self._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") + + # ----- Lid control (SCPI) ----- + + async def open_lid(self, block_id: int): + if self.bid != "31": + raise NotImplementedError("Lid control is only available for BID 31 (ATC)") + res = await self.send_command({"cmd": "lidopen"}, response_timeout=25, read_once=True) + if self._parse_scpi_response(res)["status"] != "OK": + raise ValueError("Failed to open lid") + + async def close_lid(self, block_id: int): + if self.bid != "31": + raise NotImplementedError("Lid control is only available for BID 31 (ATC)") + res = await self.send_command({"cmd": "lidclose"}, response_timeout=20, read_once=True) + if self._parse_scpi_response(res)["status"] != "OK": + raise ValueError("Failed to close lid") + + # ----- 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() + + +# --------------------------------------------------------------------------- +# Capability backends +# --------------------------------------------------------------------------- + + +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.set_block_temperature(temperature=temps, block_id=self._block_id) + + async def get_current_temperature(self) -> float: + temps = await self._driver.get_block_current_temperature(block_id=self._block_id) + return float(temps[0]) + + async def deactivate(self): + await self._driver.deactivate_block(block_id=self._block_id) + + +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): + temps = [temperature] * self._driver.num_temp_zones + await self._driver.set_lid_temperature(temperature=temps, block_id=self._block_id) + + async def get_current_temperature(self) -> float: + temps = await self._driver.get_lid_current_temperature(block_id=self._block_id) + return float(temps[0]) + + async def deactivate(self): + await self._driver.deactivate_lid(block_id=self._block_id) + + +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 + + async def open_lid(self) -> None: + if not self._supports_lid_control: + raise NotImplementedError("Lid control is not supported on this thermocycler model") + await self._driver.open_lid(block_id=self._block_id) + + async def close_lid(self) -> None: + if not self._supports_lid_control: + raise NotImplementedError("Lid control is not supported on this thermocycler model") + await self._driver.close_lid(block_id=self._block_id) + + async def get_lid_open(self) -> bool: + raise NotImplementedError( + "ThermoFisher thermocycler hardware does not support lid open status check" + ) + + async def run_protocol(self, protocol: Protocol, block_max_volume: float) -> None: + await self._driver.run_protocol( + protocol=protocol, + block_max_volume=block_max_volume, + block_id=self._block_id, + ) + + async def get_current_cycle_index(self) -> int: + return await self._driver.get_current_cycle_index(block_id=self._block_id) + + async def get_current_step_index(self) -> int: + return await self._driver.get_current_step_index(block_id=self._block_id) + + 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" + ) + + +# --------------------------------------------------------------------------- +# Device class +# --------------------------------------------------------------------------- + + +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, + ): + # Build the shared driver (not a DeviceBackend — lifecycle managed manually). + self._driver = ThermoFisherThermocyclerDriver( + ip=ip, + use_ssl=use_ssl, + serial_number=serial_number, + ) + + # We need a DeviceBackend for the Device.__init__ call. The thermocycling + # backend for block 0 serves double duty here. + first_tc_backend = ThermoFisherThermocyclingBackend( + driver=self._driver, + block_id=0, + supports_lid_control=supports_lid_control, + ) + + 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=first_tc_backend) + + self._ip = ip + self._num_blocks = num_blocks + self._num_temp_zones = num_temp_zones + self._supports_lid_control = supports_lid_control + self._block_idle_temp = block_idle_temp + self._cover_idle_temp = cover_idle_temp + + # Build per-block capability backends and frontends. + self.blocks: List[TemperatureControlCapability] = [] + self.lids: List[TemperatureControlCapability] = [] + self.thermocycling: List[ThermocyclingCapability] = [] + + all_capabilities = [] + + 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) + + if block_id == 0: + tc_be = first_tc_backend + else: + 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) + + all_capabilities.extend([block_cap, lid_cap, tc_cap]) + + self._capabilities = all_capabilities + + 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 + + async def stop(self): + """Stop all blocks and tear down the driver connection.""" + for cap in reversed(self._capabilities): + await cap._on_stop() + await self._driver.stop() + self._setup_finished = False + + # ----- Factory class methods ----- + + @classmethod + def proflex_single_block(cls, name: str, ip: str, **kwargs) -> "ThermoFisherThermocycler": + """Create a ProFlex with a single 6-zone block (BID 12).""" + return cls(name=name, ip=ip, num_blocks=1, num_temp_zones=6, **kwargs) + + @classmethod + def proflex_three_block(cls, name: str, ip: str, **kwargs) -> "ThermoFisherThermocycler": + """Create a ProFlex with three 2-zone blocks (BID 13).""" + return cls(name=name, ip=ip, num_blocks=3, num_temp_zones=2, **kwargs) + + @classmethod + def atc(cls, name: str, ip: str, **kwargs) -> "ThermoFisherThermocycler": + """Create an ATC thermocycler with a single 3-zone block and lid control (BID 31).""" + return cls( + name=name, ip=ip, num_blocks=1, num_temp_zones=3, supports_lid_control=True, **kwargs + ) From 31b553179661af28c0f8f58b95bb3a51196d2387 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 23 Mar 2026 16:57:21 -0700 Subject: [PATCH 4/9] Make driver a DeviceBackend, split factory functions into separate files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThermoFisherThermocyclerDriver now extends DeviceBackend — passed directly to Device.__init__, no first_tc_backend hack - Remove _supports_lid_control from device class (backend concern only) - Split factory functions into separate files: - proflex.py: ProFlexSingleBlock(), ProFlexThreeBlock() - atc.py: ATC() - Simplify _capabilities list construction Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/thermo_fisher/__init__.py | 2 + pylabrobot/thermo_fisher/atc.py | 8 +++ pylabrobot/thermo_fisher/proflex.py | 11 ++++ pylabrobot/thermo_fisher/thermocycler.py | 77 +++++------------------- 4 files changed, 37 insertions(+), 61 deletions(-) create mode 100644 pylabrobot/thermo_fisher/atc.py create mode 100644 pylabrobot/thermo_fisher/proflex.py diff --git a/pylabrobot/thermo_fisher/__init__.py b/pylabrobot/thermo_fisher/__init__.py index 2df251015a0..da969b51c96 100644 --- a/pylabrobot/thermo_fisher/__init__.py +++ b/pylabrobot/thermo_fisher/__init__.py @@ -1,3 +1,5 @@ +from .atc import ATC +from .proflex import ProFlexSingleBlock, ProFlexThreeBlock from .thermocycler import ( ThermoFisherBlockBackend, ThermoFisherLidBackend, diff --git a/pylabrobot/thermo_fisher/atc.py b/pylabrobot/thermo_fisher/atc.py new file mode 100644 index 00000000000..d62a569ad9b --- /dev/null +++ b/pylabrobot/thermo_fisher/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/proflex.py b/pylabrobot/thermo_fisher/proflex.py new file mode 100644 index 00000000000..3311191d7dc --- /dev/null +++ b/pylabrobot/thermo_fisher/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/thermocycler.py b/pylabrobot/thermo_fisher/thermocycler.py index 5da85c5d3f4..a4860d0aa71 100644 --- a/pylabrobot/thermo_fisher/thermocycler.py +++ b/pylabrobot/thermo_fisher/thermocycler.py @@ -22,7 +22,7 @@ ThermocyclingBackend, ThermocyclingCapability, ) -from pylabrobot.device import Device +from pylabrobot.device import Device, DeviceBackend from pylabrobot.io import Socket from pylabrobot.resources import Coordinate, ResourceHolder @@ -234,12 +234,12 @@ class RunProgress: running: bool -class ThermoFisherThermocyclerDriver: - """Low-level SCPI driver for ThermoFisher thermocyclers (ProFlex / ATC). +class ThermoFisherThermocyclerDriver(DeviceBackend): + """SCPI driver for ThermoFisher thermocyclers (ProFlex / ATC). - This is a plain class, NOT a DeviceBackend. It owns the socket, handles - SSL/auth, block discovery, power management, temperature control, protocol - execution, run management, file I/O, buzzer, etc. + Owns the socket connection and handles SSL/auth, block discovery, + power management, temperature control, protocol execution, run management, + file I/O, buzzer, etc. """ def __init__( @@ -248,6 +248,7 @@ def __init__( use_ssl: bool = False, serial_number: Optional[str] = None, ): + super().__init__() self.ip = ip self.use_ssl = use_ssl @@ -1233,21 +1234,12 @@ def __init__( size_y: float = 300.0, size_z: float = 200.0, ): - # Build the shared driver (not a DeviceBackend — lifecycle managed manually). self._driver = ThermoFisherThermocyclerDriver( ip=ip, use_ssl=use_ssl, serial_number=serial_number, ) - # We need a DeviceBackend for the Device.__init__ call. The thermocycling - # backend for block 0 serves double duty here. - first_tc_backend = ThermoFisherThermocyclingBackend( - driver=self._driver, - block_id=0, - supports_lid_control=supports_lid_control, - ) - ResourceHolder.__init__( self, name=name, @@ -1256,34 +1248,23 @@ def __init__( size_z=size_z, child_location=child_location, ) - Device.__init__(self, backend=first_tc_backend) + Device.__init__(self, backend=self._driver) - self._ip = ip - self._num_blocks = num_blocks - self._num_temp_zones = num_temp_zones - self._supports_lid_control = supports_lid_control self._block_idle_temp = block_idle_temp self._cover_idle_temp = cover_idle_temp - # Build per-block capability backends and frontends. self.blocks: List[TemperatureControlCapability] = [] self.lids: List[TemperatureControlCapability] = [] self.thermocycling: List[ThermocyclingCapability] = [] - all_capabilities = [] - 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) - - if block_id == 0: - tc_be = first_tc_backend - else: - tc_be = ThermoFisherThermocyclingBackend( - driver=self._driver, - block_id=block_id, - supports_lid_control=supports_lid_control, - ) + 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) @@ -1293,9 +1274,9 @@ def __init__( self.lids.append(lid_cap) self.thermocycling.append(tc_cap) - all_capabilities.extend([block_cap, lid_cap, tc_cap]) - - self._capabilities = all_capabilities + 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.""" @@ -1306,29 +1287,3 @@ async def setup(self, **backend_kwargs): for cap in self._capabilities: await cap._on_setup() self._setup_finished = True - - async def stop(self): - """Stop all blocks and tear down the driver connection.""" - for cap in reversed(self._capabilities): - await cap._on_stop() - await self._driver.stop() - self._setup_finished = False - - # ----- Factory class methods ----- - - @classmethod - def proflex_single_block(cls, name: str, ip: str, **kwargs) -> "ThermoFisherThermocycler": - """Create a ProFlex with a single 6-zone block (BID 12).""" - return cls(name=name, ip=ip, num_blocks=1, num_temp_zones=6, **kwargs) - - @classmethod - def proflex_three_block(cls, name: str, ip: str, **kwargs) -> "ThermoFisherThermocycler": - """Create a ProFlex with three 2-zone blocks (BID 13).""" - return cls(name=name, ip=ip, num_blocks=3, num_temp_zones=2, **kwargs) - - @classmethod - def atc(cls, name: str, ip: str, **kwargs) -> "ThermoFisherThermocycler": - """Create an ATC thermocycler with a single 3-zone block and lid control (BID 31).""" - return cls( - name=name, ip=ip, num_blocks=1, num_temp_zones=3, supports_lid_control=True, **kwargs - ) From 3397ac2b73a66685bc637c5b4710dfde04cb43b1 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 23 Mar 2026 17:13:00 -0700 Subject: [PATCH 5/9] Add Inheco ODTC thermocycler vendor module - ODTCDriver(DeviceBackend): SiLA communication, setup/stop, sensor reading - ODTCBlockBackend: block temperature control via PreMethod - ODTCLidBackend: lid temperature control (coupled with block via PreMethod) - ODTCThermocyclingBackend: protocol execution, lid open/close - ODTC device class with block, lid, and thermocycling capabilities - Legacy ExperimentalODTCBackend delegates to new module Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/inheco/__init__.py | 1 + pylabrobot/inheco/odtc/__init__.py | 7 + pylabrobot/inheco/odtc/odtc.py | 438 ++++++++++++++++++ .../thermocycling/inheco/odtc_backend.py | 358 ++------------ 4 files changed, 494 insertions(+), 310 deletions(-) create mode 100644 pylabrobot/inheco/odtc/__init__.py create mode 100644 pylabrobot/inheco/odtc/odtc.py 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..1defe1ed8c1 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 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,27 @@ 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) + # Use the block backend's _run_pre_method logic directly via driver + from pylabrobot.inheco.odtc.odtc import ODTCBlockBackend + + 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 +72,27 @@ 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) + from pylabrobot.inheco.odtc.odtc import ODTCBlockBackend + + 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 +103,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() From f04e0aa155716892854acee3bdd9b5edaf9ee816 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 23 Mar 2026 17:21:39 -0700 Subject: [PATCH 6/9] Restructure ThermoFisher thermocycler into multi-file package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split pylabrobot/thermo_fisher/thermocycler.py into: - thermocyclers/driver.py — raw SCPI IO, auth, block discovery, power - thermocyclers/block_backend.py — per-block temp control via send_command - thermocyclers/lid_backend.py — per-block lid temp control via send_command - thermocyclers/thermocycling_backend.py — protocol execution, run progress - thermocyclers/thermocycler.py — device class - thermocyclers/utils.py — XML/SCPI helpers, RunProgress - thermocyclers/proflex.py, atc.py — factory functions Backends call driver.send_command() directly — driver is pure IO layer. Backward-compat shim at thermocycler.py re-exports from package. All 3 legacy proflex tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../thermo_fisher_thermocycler.py | 289 +++- pylabrobot/thermo_fisher/__init__.py | 7 +- pylabrobot/thermo_fisher/atc.py | 8 - pylabrobot/thermo_fisher/proflex.py | 11 - pylabrobot/thermo_fisher/thermocycler.py | 1308 +---------------- .../thermo_fisher/thermocyclers/__init__.py | 8 + pylabrobot/thermo_fisher/thermocyclers/atc.py | 8 + .../thermocyclers/block_backend.py | 75 + .../thermo_fisher/thermocyclers/driver.py | 465 ++++++ .../thermocyclers/lid_backend.py | 47 + .../thermo_fisher/thermocyclers/proflex.py | 11 + .../thermocyclers/thermocycler.py | 93 ++ .../thermocyclers/thermocycling_backend.py | 442 ++++++ .../thermo_fisher/thermocyclers/utils.py | 205 +++ 14 files changed, 1646 insertions(+), 1331 deletions(-) create mode 100644 pylabrobot/thermo_fisher/thermocyclers/__init__.py create mode 100644 pylabrobot/thermo_fisher/thermocyclers/atc.py create mode 100644 pylabrobot/thermo_fisher/thermocyclers/block_backend.py create mode 100644 pylabrobot/thermo_fisher/thermocyclers/driver.py create mode 100644 pylabrobot/thermo_fisher/thermocyclers/lid_backend.py create mode 100644 pylabrobot/thermo_fisher/thermocyclers/proflex.py create mode 100644 pylabrobot/thermo_fisher/thermocyclers/thermocycler.py create mode 100644 pylabrobot/thermo_fisher/thermocyclers/thermocycling_backend.py create mode 100644 pylabrobot/thermo_fisher/thermocyclers/utils.py diff --git a/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py b/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py index e6cf693e81a..3300dc03e5d 100644 --- a/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py +++ b/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py @@ -5,8 +5,11 @@ that :class:`ATCBackend`, :class:`ProflexBackend` and their tests continue to work unchanged. """ +import asyncio +import re from abc import ABCMeta -from typing import Dict, List, Optional +from base64 import b64decode +from typing import Dict, List, Optional, cast from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend from pylabrobot.legacy.thermocycling.standard import ( @@ -16,7 +19,7 @@ Step, protocol_to_new, ) -from pylabrobot.thermo_fisher.thermocycler import ( +from pylabrobot.thermo_fisher.thermocyclers import ( RunProgress, ThermoFisherThermocyclerDriver, _gen_protocol_data, @@ -142,11 +145,27 @@ 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" - await self._driver.set_block_temperature(temperature=temperature, block_id=block_id, rate=rate) + 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") 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" - await self._driver.set_lid_temperature(temperature=temperature, block_id=block_id) + assert len(set(temperature)) == 1, "Lid temperature must be the same for all zones" + target_temp = temperature[0] + 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}:CoverRAMP", "params": {}, "args": [target_temp]}, + response_timeout=60, + ) + if self._driver._parse_scpi_response(res)["status"] != "OK": + raise ValueError("Failed to ramp cover temperature") async def deactivate_lid(self, block_id: Optional[int] = None): assert block_id is not None, "block_id must be specified" @@ -157,11 +176,13 @@ async def deactivate_block(self, block_id: Optional[int] = None): await self._driver.deactivate_block(block_id=block_id) async def get_block_current_temperature(self, block_id=1) -> List[float]: - return await self._driver.get_block_current_temperature(block_id=block_id) + 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" - return await self._driver.get_lid_current_temperature(block_id=block_id) + 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, @@ -182,7 +203,7 @@ async def run_protocol( if isinstance(stage, Step): protocol.stages[i] = Stage(steps=[stage], repeats=1) new_protocol = protocol_to_new(protocol) - await self._driver.run_protocol( + await self._run_protocol_impl( protocol=new_protocol, block_max_volume=block_max_volume, block_id=block_id, @@ -195,19 +216,169 @@ async def run_protocol( stage_name_prefixes=stage_name_prefixes, ) + async def _run_protocol_impl( + self, + protocol, + block_max_volume: float, + block_id: int, + 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, + ): + from pylabrobot.capabilities.thermocycling import Stage as NewStage, Step as NewStep + + if await self.check_run_exists(run_name): + pass # run 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, NewStep): + protocol.stages[i] = NewStage(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))] + + # write run info files + xmlfile, tmpfile = _generate_run_info_files( + protocol=protocol, + block_id=block_id, + sample_volume=block_max_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) + + # load and run protocol via SCPI + load_res = await self._driver.send_command( + data=_gen_protocol_data( + protocol=protocol, + 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, + stage_name_prefixes=stage_name_prefixes, + ), + response_timeout=5, + read_once=False, + ) + if self._driver._parse_scpi_response(load_res)["status"] != "OK": + raise ValueError("Protocol failed to load") + + start_res = await self._driver.send_command( + { + "cmd": f"TBC{block_id + 1}:RunProtocol", + "params": { + "User": user, + "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": + raise ValueError("Protocol failed to start") + + total_time = await self.get_estimated_run_time(block_id=block_id) + total_time = float(total_time) + self._driver.current_runs[block_id] = run_name + async def get_run_info(self, protocol: Protocol, block_id: int) -> RunProgress: - return await self._driver.get_run_info(protocol=protocol_to_new(protocol), block_id=block_id) + new_protocol = protocol_to_new(protocol) + progress = await self._get_run_progress(block_id=block_id) + run_name = await self.get_run_name(block_id=block_id) + if not progress: + 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) + 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": + 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(block_id=block_id) + remaining_time = await self.get_remaining_run_time(block_id=block_id) + + if progress["Stage"] != "-" and progress["Step"] != "-": + current_step = new_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) + return RunProgress( + running=False, + stage="infinite_hold", + elapsed_time=time_elapsed, + remaining_time=remaining_time, + ) + + return RunProgress( + running=True, + stage=progress["Stage"], + elapsed_time=time_elapsed, + remaining_time=remaining_time, + ) async def abort_run(self, block_id: int): await self._driver.abort_run(block_id=block_id) async def continue_run(self, block_id: int): - await self._driver.continue_run(block_id=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") # -- convenience delegations ------------------------------------------------- async def get_sample_temps(self, block_id=1) -> List[float]: - return await self._driver.get_sample_temps(block_id=block_id) + res = await self._driver.send_command({"cmd": f"TBC{block_id + 1}:TBC:SampleTemperatures?"}) + return cast(List[float], self._driver._parse_scpi_response(res)["args"]) async def get_nickname(self) -> str: return await self._driver.get_nickname() @@ -216,10 +387,32 @@ async def set_nickname(self, nickname: str) -> None: await self._driver.set_nickname(nickname) async def get_log_by_runname(self, run_name: str) -> str: - return await self._driver.get_log_by_runname(run_name) + res = await self._driver.send_command( + {"cmd": "FILe:READ?", "args": [f"RUNS:{run_name}/{run_name}.log"]}, + response_timeout=5, + read_once=False, + ) + if self._driver._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() + log = b64decode(encoded_log).decode("utf-8") + return log 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) + """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)) + total_seconds = (hours * 3600) + (minutes * 60) + seconds + return total_seconds async def set_block_idle_temp( self, temp: float, block_id: int, control_enabled: bool = True @@ -236,7 +429,14 @@ async def set_cover_idle_temp( ) async def block_ramp_single_temp(self, target_temp: float, block_id: int, rate: float = 100): - await self._driver.block_ramp_single_temp(target_temp=target_temp, block_id=block_id, rate=rate) + 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}: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") async def buzzer_on(self): await self._driver.buzzer_on() @@ -254,33 +454,82 @@ async def power_off(self): await self._driver.power_off() async def check_run_exists(self, run_name: str) -> bool: - return await self._driver.check_run_exists(run_name) + res = await self._driver.send_command( + {"cmd": "RUNS:EXISTS?", "args": [run_name], "params": {"type": "folders"}} + ) + if self._driver._parse_scpi_response(res)["status"] != "OK": + raise ValueError("Failed to check if run exists") + return cast(str, self._driver._parse_scpi_response(res)["args"][1]) == "True" async def create_run(self, run_name: str): - return await self._driver.create_run(run_name) + res = await self._driver.send_command( + {"cmd": "RUNS:NEW", "args": [run_name]}, response_timeout=10 + ) + if self._driver._parse_scpi_response(res)["status"] != "OK": + raise ValueError("Failed to create run") + return self._driver._parse_scpi_response(res)["args"][0] async def get_run_name(self, block_id: int) -> str: return await self._driver.get_run_name(block_id=block_id) async def get_estimated_run_time(self, block_id: int): - return await self._driver.get_estimated_run_time(block_id=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: int): - return await self._driver.get_elapsed_run_time(block_id=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: int): - return await self._driver.get_remaining_run_time(block_id=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]) async def get_error(self, block_id): return await self._driver.get_error(block_id=block_id) async def get_current_cycle_index(self, block_id: Optional[int] = None) -> int: assert block_id is not None, "block_id must be specified" - return await self._driver.get_current_cycle_index(block_id=block_id) + progress = await self._get_run_progress(block_id=block_id) + 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, block_id: Optional[int] = None) -> int: assert block_id is not None, "block_id must be specified" - return await self._driver.get_current_step_index(block_id=block_id) + progress = await self._get_run_progress(block_id=block_id) + 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_run_progress(self, block_id: int): + 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"] # -- stubs for abstract methods not implemented on this hardware ------------- diff --git a/pylabrobot/thermo_fisher/__init__.py b/pylabrobot/thermo_fisher/__init__.py index da969b51c96..7a810f58fbe 100644 --- a/pylabrobot/thermo_fisher/__init__.py +++ b/pylabrobot/thermo_fisher/__init__.py @@ -1,6 +1,7 @@ -from .atc import ATC -from .proflex import ProFlexSingleBlock, ProFlexThreeBlock -from .thermocycler import ( +from .thermocyclers import ( + ATC, + ProFlexSingleBlock, + ProFlexThreeBlock, ThermoFisherBlockBackend, ThermoFisherLidBackend, ThermoFisherThermocycler, diff --git a/pylabrobot/thermo_fisher/atc.py b/pylabrobot/thermo_fisher/atc.py index d62a569ad9b..e69de29bb2d 100644 --- a/pylabrobot/thermo_fisher/atc.py +++ b/pylabrobot/thermo_fisher/atc.py @@ -1,8 +0,0 @@ -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/proflex.py b/pylabrobot/thermo_fisher/proflex.py index 3311191d7dc..e69de29bb2d 100644 --- a/pylabrobot/thermo_fisher/proflex.py +++ b/pylabrobot/thermo_fisher/proflex.py @@ -1,11 +0,0 @@ -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/thermocycler.py b/pylabrobot/thermo_fisher/thermocycler.py index a4860d0aa71..0ef856b3890 100644 --- a/pylabrobot/thermo_fisher/thermocycler.py +++ b/pylabrobot/thermo_fisher/thermocycler.py @@ -1,1289 +1,19 @@ -import asyncio -import hashlib -import hmac -import logging -import re -import ssl -import warnings -import xml.etree.ElementTree as ET -from base64 import b64decode -from dataclasses import dataclass -from typing import Any, Dict, List, Optional, cast -from xml.dom import minidom - -from pylabrobot.capabilities.temperature_controlling import ( - TemperatureControlCapability, - TemperatureControllerBackend, -) -from pylabrobot.capabilities.thermocycling import ( - Protocol, - Stage, - Step, - ThermocyclingBackend, - ThermocyclingCapability, -) -from pylabrobot.device import Device, DeviceBackend -from pylabrobot.io import Socket -from pylabrobot.resources import Coordinate, ResourceHolder - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Module-level helpers -# --------------------------------------------------------------------------- - - -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 - - -# --------------------------------------------------------------------------- -# ThermoFisherThermocyclerDriver — all SCPI I/O lives here -# --------------------------------------------------------------------------- - - -@dataclass -class RunProgress: - stage: str - elapsed_time: int - remaining_time: int - running: bool - - -class ThermoFisherThermocyclerDriver(DeviceBackend): - """SCPI driver for ThermoFisher thermocyclers (ProFlex / ATC). - - Owns the socket connection and handles SSL/auth, block discovery, - power management, temperature control, protocol execution, run 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"") - 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"") - 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"") - 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 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() - 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) - - # ----- Temperature queries ----- - - async def get_block_current_temperature(self, block_id: int) -> 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: int) -> 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"]) - - async def get_lid_current_temperature(self, block_id: int) -> List[float]: - res = await self.send_command({"cmd": f"TBC{block_id + 1}:TBC:CoverTemperatures?"}) - return cast(List[float], self._parse_scpi_response(res)["args"]) - - # ----- 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") - - # ----- Log / run file management ----- - - 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() - log = b64decode(encoded_log).decode("utf-8") - return log - - 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)) - total_seconds = (hours * 3600) + (minutes * 60) + seconds - return total_seconds - - # ----- Idle temperature control ----- - - 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") - - # ----- Active temperature ramping ----- - - async def set_block_temperature(self, temperature: List[float], block_id: int, 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") - - 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. - - 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") - - async def set_lid_temperature(self, temperature: List[float], block_id: int): - 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") - - # ----- Deactivation ----- - - 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) - - # ----- 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) - - # ----- Run management ----- - - 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") - - 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 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_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: int): - 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: 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") - - # ----- Protocol write / run helpers ----- - - 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._write_file(f"runs:{run_name}/{protocol_name}.method", xmlfile) - await self._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.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._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 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) - - # ----- Run info / progress ----- - - 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 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 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 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(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 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 RunProgress( - running=True, - stage=progress["Stage"], - elapsed_time=time_elapsed, - remaining_time=remaining_time, - ) - - # ----- Protocol execution (public) ----- - - async def run_protocol( - self, - protocol: Protocol, - block_max_volume: float, - block_id: int, - 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, - ): - 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 - 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 _ 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, - ) - - # ----- Run progress queries ----- - - async def get_current_cycle_index(self, block_id: int) -> int: - progress = await self._get_run_progress(block_id=block_id) - if progress is None: - raise RuntimeError("No progress information available") - - if progress["RunTitle"] == "-": - await self._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, block_id: int) -> int: - progress = await self._get_run_progress(block_id=block_id) - if progress is None: - raise RuntimeError("No progress information available") - - if progress["RunTitle"] == "-": - await self._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") - - # ----- Lid control (SCPI) ----- - - async def open_lid(self, block_id: int): - if self.bid != "31": - raise NotImplementedError("Lid control is only available for BID 31 (ATC)") - res = await self.send_command({"cmd": "lidopen"}, response_timeout=25, read_once=True) - if self._parse_scpi_response(res)["status"] != "OK": - raise ValueError("Failed to open lid") - - async def close_lid(self, block_id: int): - if self.bid != "31": - raise NotImplementedError("Lid control is only available for BID 31 (ATC)") - res = await self.send_command({"cmd": "lidclose"}, response_timeout=20, read_once=True) - if self._parse_scpi_response(res)["status"] != "OK": - raise ValueError("Failed to close lid") - - # ----- 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() - - -# --------------------------------------------------------------------------- -# Capability backends -# --------------------------------------------------------------------------- - - -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.set_block_temperature(temperature=temps, block_id=self._block_id) - - async def get_current_temperature(self) -> float: - temps = await self._driver.get_block_current_temperature(block_id=self._block_id) - return float(temps[0]) - - async def deactivate(self): - await self._driver.deactivate_block(block_id=self._block_id) - - -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): - temps = [temperature] * self._driver.num_temp_zones - await self._driver.set_lid_temperature(temperature=temps, block_id=self._block_id) - - async def get_current_temperature(self) -> float: - temps = await self._driver.get_lid_current_temperature(block_id=self._block_id) - return float(temps[0]) - - async def deactivate(self): - await self._driver.deactivate_lid(block_id=self._block_id) - - -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 - - async def open_lid(self) -> None: - if not self._supports_lid_control: - raise NotImplementedError("Lid control is not supported on this thermocycler model") - await self._driver.open_lid(block_id=self._block_id) - - async def close_lid(self) -> None: - if not self._supports_lid_control: - raise NotImplementedError("Lid control is not supported on this thermocycler model") - await self._driver.close_lid(block_id=self._block_id) - - async def get_lid_open(self) -> bool: - raise NotImplementedError( - "ThermoFisher thermocycler hardware does not support lid open status check" - ) - - async def run_protocol(self, protocol: Protocol, block_max_volume: float) -> None: - await self._driver.run_protocol( - protocol=protocol, - block_max_volume=block_max_volume, - block_id=self._block_id, - ) - - async def get_current_cycle_index(self) -> int: - return await self._driver.get_current_cycle_index(block_id=self._block_id) - - async def get_current_step_index(self) -> int: - return await self._driver.get_current_step_index(block_id=self._block_id) - - 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" - ) - - -# --------------------------------------------------------------------------- -# Device class -# --------------------------------------------------------------------------- - - -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 +"""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..ead1382de14 --- /dev/null +++ b/pylabrobot/thermo_fisher/thermocyclers/driver.py @@ -0,0 +1,465 @@ +import asyncio +import hashlib +import hmac +import logging +import re +import ssl +import warnings +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"") + 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"") + 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"") + 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() + + # ----- Methods used by stop() that delegate to backend-level logic ----- + # These are thin wrappers so that the driver's stop() can abort runs and deactivate. + + 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..5bdf49ae9e5 --- /dev/null +++ b/pylabrobot/thermo_fisher/thermocyclers/thermocycling_backend.py @@ -0,0 +1,442 @@ +import asyncio +import logging +import re +from base64 import b64decode +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: + block_id = self._block_id + run_name = "testrun" + user = "Admin" + run_mode = "Fast" + cover_temp = 105 + cover_enabled = True + protocol_name = "PCR_Protocol" + stage_name_prefixes: Optional[List[str]] = None + + 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: + res = await self._driver.send_command( + {"cmd": "FILe:READ?", "args": [f"RUNS:{run_name}/{run_name}.log"]}, + response_timeout=5, + read_once=False, + ) + if self._driver._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() + log = b64decode(encoded_log).decode("utf-8") + return log + + 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)) + total_seconds = (hours * 3600) + (minutes * 60) + seconds + return total_seconds + + async def check_run_exists(self, run_name: str) -> bool: + res = await self._driver.send_command( + {"cmd": "RUNS:EXISTS?", "args": [run_name], "params": {"type": "folders"}} + ) + if self._driver._parse_scpi_response(res)["status"] != "OK": + raise ValueError("Failed to check if run exists") + return cast(str, self._driver._parse_scpi_response(res)["args"][1]) == "True" + + async def create_run(self, run_name: str): + res = await self._driver.send_command( + {"cmd": "RUNS:NEW", "args": [run_name]}, response_timeout=10 + ) + if self._driver._parse_scpi_response(res)["status"] != "OK": + raise ValueError("Failed to create run") + return self._driver._parse_scpi_response(res)["args"][0] 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 From ae0308bf0d8933c5e320f888769f2697eba9c665 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 23 Mar 2026 17:53:22 -0700 Subject: [PATCH 7/9] Make all legacy backends thin adapters, move universal methods to driver - OT legacy backend: now delegates to new driver/backend (was self-contained) - OT tests: updated patch targets to new module location - TF legacy backend: delegates to per-block backends via lazy cache - Move check_run_exists, create_run, get_log_by_runname, get_elapsed_run_time_from_log to TF driver (device-level, not per-block) - All 19 legacy tests pass Co-Authored-By: Claude Opus 4.6 (1M context) --- .../legacy/thermocycling/opentrons_backend.py | 4 +- .../thermocycling/opentrons_backend_tests.py | 18 +- .../thermo_fisher_thermocycler.py | 315 +++--------------- pylabrobot/opentrons/thermocycler.py | 6 + .../thermo_fisher/thermocyclers/driver.py | 46 ++- .../thermocyclers/thermocycling_backend.py | 63 +--- 6 files changed, 132 insertions(+), 320 deletions(-) diff --git a/pylabrobot/legacy/thermocycling/opentrons_backend.py b/pylabrobot/legacy/thermocycling/opentrons_backend.py index 22e0204148c..33b7e328746 100644 --- a/pylabrobot/legacy/thermocycling/opentrons_backend.py +++ b/pylabrobot/legacy/thermocycling/opentrons_backend.py @@ -88,13 +88,13 @@ async def get_lid_open(self) -> bool: return await self._new.get_lid_open() async def get_lid_status(self) -> LidStatus: - status = self._driver._find_module().get("lidTemperatureStatus", "idle") + status = self._driver.get_lid_temperature_status_str() if status == "holding at target": return LidStatus.HOLDING_AT_TARGET return LidStatus.IDLE async def get_block_status(self) -> BlockStatus: - status = self._driver._find_module().get("status", "idle") + status = self._driver.get_block_status_str() if status == "holding at target": return BlockStatus.HOLDING_AT_TARGET return BlockStatus.IDLE 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/thermo_fisher/thermo_fisher_thermocycler.py b/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py index 3300dc03e5d..03ca869842e 100644 --- a/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py +++ b/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py @@ -1,14 +1,11 @@ """Legacy ThermoFisher thermocycler backend -- thin delegation layer. -All real SCPI logic lives in :class:`ThermoFisherThermocyclerDriver` -(``pylabrobot.thermo_fisher.thermocycler``). This module keeps the original public interface so +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. """ -import asyncio -import re from abc import ABCMeta -from base64 import b64decode from typing import Dict, List, Optional, cast from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend @@ -21,7 +18,10 @@ ) from pylabrobot.thermo_fisher.thermocyclers import ( RunProgress, + ThermoFisherBlockBackend, + ThermoFisherLidBackend, ThermoFisherThermocyclerDriver, + ThermoFisherThermocyclingBackend, _gen_protocol_data, _generate_run_info_files, ) @@ -33,7 +33,9 @@ class ThermoFisherThermocyclerBackend(ThermocyclerBackend, metaclass=ABCMeta): """Legacy backend for ThermoFisher thermocyclers (ProFlex / ATC). - Delegates all real work to :class:`ThermoFisherThermocyclerDriver`. + 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 @@ -50,6 +52,26 @@ def __init__( self._driver = ThermoFisherThermocyclerDriver( ip=ip, use_ssl=use_ssl, serial_number=serial_number ) + self._block_backends: Dict[int, ThermoFisherBlockBackend] = {} + self._lid_backends: Dict[int, ThermoFisherLidBackend] = {} + self._tc_backends: Dict[int, ThermoFisherThermocyclingBackend] = {} + + # -- per-block backend accessors (lazy-cached) -------------------------------- + + 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] + + 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] + + 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] # -- forwarding properties (tests/subclasses access these directly) ---------- @@ -157,15 +179,7 @@ async def set_block_temperature( 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._driver.available_blocks: - raise ValueError(f"Block {block_id} not available") - res = await self._driver.send_command( - {"cmd": f"TBC{block_id + 1}:CoverRAMP", "params": {}, "args": [target_temp]}, - response_timeout=60, - ) - if self._driver._parse_scpi_response(res)["status"] != "OK": - raise ValueError("Failed to ramp cover temperature") + 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" @@ -203,10 +217,10 @@ async def run_protocol( if isinstance(stage, Step): protocol.stages[i] = Stage(steps=[stage], repeats=1) new_protocol = protocol_to_new(protocol) - await self._run_protocol_impl( + tc = self._tc_backend(block_id) + await tc._run_protocol_with_options( protocol=new_protocol, block_max_volume=block_max_volume, - block_id=block_id, run_name=run_name, user=user, run_mode=run_mode, @@ -216,169 +230,20 @@ async def run_protocol( stage_name_prefixes=stage_name_prefixes, ) - async def _run_protocol_impl( - self, - protocol, - block_max_volume: float, - block_id: int, - 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, - ): - from pylabrobot.capabilities.thermocycling import Stage as NewStage, Step as NewStep - - if await self.check_run_exists(run_name): - pass # run 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, NewStep): - protocol.stages[i] = NewStage(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))] - - # write run info files - xmlfile, tmpfile = _generate_run_info_files( - protocol=protocol, - block_id=block_id, - sample_volume=block_max_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) - - # load and run protocol via SCPI - load_res = await self._driver.send_command( - data=_gen_protocol_data( - protocol=protocol, - 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, - stage_name_prefixes=stage_name_prefixes, - ), - response_timeout=5, - read_once=False, - ) - if self._driver._parse_scpi_response(load_res)["status"] != "OK": - raise ValueError("Protocol failed to load") - - start_res = await self._driver.send_command( - { - "cmd": f"TBC{block_id + 1}:RunProtocol", - "params": { - "User": user, - "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": - raise ValueError("Protocol failed to start") - - total_time = await self.get_estimated_run_time(block_id=block_id) - total_time = float(total_time) - self._driver.current_runs[block_id] = run_name - async def get_run_info(self, protocol: Protocol, block_id: int) -> RunProgress: new_protocol = protocol_to_new(protocol) - progress = await self._get_run_progress(block_id=block_id) - run_name = await self.get_run_name(block_id=block_id) - if not progress: - 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) - 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": - 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(block_id=block_id) - remaining_time = await self.get_remaining_run_time(block_id=block_id) - - if progress["Stage"] != "-" and progress["Step"] != "-": - current_step = new_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) - return RunProgress( - running=False, - stage="infinite_hold", - elapsed_time=time_elapsed, - remaining_time=remaining_time, - ) - - return RunProgress( - running=True, - stage=progress["Stage"], - elapsed_time=time_elapsed, - remaining_time=remaining_time, - ) + return await self._tc_backend(block_id).get_run_info(new_protocol) async def abort_run(self, block_id: int): - await self._driver.abort_run(block_id=block_id) + await self._tc_backend(block_id).abort_run() async def continue_run(self, block_id: int): - 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") + await self._tc_backend(block_id).continue_run() # -- convenience delegations ------------------------------------------------- async def get_sample_temps(self, block_id=1) -> List[float]: - res = await self._driver.send_command({"cmd": f"TBC{block_id + 1}:TBC:SampleTemperatures?"}) - return cast(List[float], self._driver._parse_scpi_response(res)["args"]) + return await self._block_backend(block_id).get_sample_temps() async def get_nickname(self) -> str: return await self._driver.get_nickname() @@ -387,56 +252,28 @@ async def set_nickname(self, nickname: str) -> None: await self._driver.set_nickname(nickname) async def get_log_by_runname(self, run_name: str) -> str: - res = await self._driver.send_command( - {"cmd": "FILe:READ?", "args": [f"RUNS:{run_name}/{run_name}.log"]}, - response_timeout=5, - read_once=False, - ) - if self._driver._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() - log = b64decode(encoded_log).decode("utf-8") - return log + # 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) 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)) - total_seconds = (hours * 3600) + (minutes * 60) + seconds - return total_seconds + return await self._tc_backend(0).get_elapsed_run_time_from_log(run_name) async def set_block_idle_temp( self, temp: float, block_id: int, control_enabled: bool = True ) -> None: - await self._driver.set_block_idle_temp( - temp=temp, block_id=block_id, control_enabled=control_enabled + 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._driver.set_cover_idle_temp( - temp=temp, block_id=block_id, control_enabled=control_enabled + 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): - 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}: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") + 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() @@ -454,82 +291,36 @@ async def power_off(self): await self._driver.power_off() async def check_run_exists(self, run_name: str) -> bool: - res = await self._driver.send_command( - {"cmd": "RUNS:EXISTS?", "args": [run_name], "params": {"type": "folders"}} - ) - if self._driver._parse_scpi_response(res)["status"] != "OK": - raise ValueError("Failed to check if run exists") - return cast(str, self._driver._parse_scpi_response(res)["args"][1]) == "True" + return await self._tc_backend(0).check_run_exists(run_name) async def create_run(self, run_name: str): - res = await self._driver.send_command( - {"cmd": "RUNS:NEW", "args": [run_name]}, response_timeout=10 - ) - if self._driver._parse_scpi_response(res)["status"] != "OK": - raise ValueError("Failed to create run") - return self._driver._parse_scpi_response(res)["args"][0] + return await self._tc_backend(0).create_run(run_name) async def get_run_name(self, block_id: int) -> str: - return await self._driver.get_run_name(block_id=block_id) + return await self._tc_backend(block_id).get_run_name() async def get_estimated_run_time(self, block_id: int): - 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] + return await self._tc_backend(block_id).get_estimated_run_time() async def get_elapsed_run_time(self, block_id: int): - 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]) + return await self._tc_backend(block_id).get_elapsed_run_time() async def get_remaining_run_time(self, block_id: int): - 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]) + return await self._tc_backend(block_id).get_remaining_run_time() async def get_error(self, block_id): - return await self._driver.get_error(block_id=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") - 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") + return await self._tc_backend(block_id).get_current_cycle_index() async def get_current_step_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") - 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") + return await self._tc_backend(block_id).get_current_step_index() async def _get_run_progress(self, block_id: int): - 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"] + return await self._tc_backend(block_id)._get_run_progress() # -- stubs for abstract methods not implemented on this hardware ------------- diff --git a/pylabrobot/opentrons/thermocycler.py b/pylabrobot/opentrons/thermocycler.py index ea345c55602..f02032eb41e 100644 --- a/pylabrobot/opentrons/thermocycler.py +++ b/pylabrobot/opentrons/thermocycler.py @@ -97,6 +97,12 @@ def get_lid_target_temperature(self) -> Optional[float]: 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)) diff --git a/pylabrobot/thermo_fisher/thermocyclers/driver.py b/pylabrobot/thermo_fisher/thermocyclers/driver.py index ead1382de14..63db07a0db0 100644 --- a/pylabrobot/thermo_fisher/thermocyclers/driver.py +++ b/pylabrobot/thermo_fisher/thermocyclers/driver.py @@ -5,6 +5,7 @@ import re import ssl import warnings +from base64 import b64decode from typing import Any, Dict, List, Optional, cast from pylabrobot.device import DeviceBackend @@ -442,8 +443,49 @@ async def stop(self): await self.deactivate_block(block_id=block_id) await self.io.stop() - # ----- Methods used by stop() that delegate to backend-level logic ----- - # These are thin wrappers so that the driver's stop() can abort runs and deactivate. + # ----- 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): diff --git a/pylabrobot/thermo_fisher/thermocyclers/thermocycling_backend.py b/pylabrobot/thermo_fisher/thermocyclers/thermocycling_backend.py index 5bdf49ae9e5..d69d28f73be 100644 --- a/pylabrobot/thermo_fisher/thermocyclers/thermocycling_backend.py +++ b/pylabrobot/thermo_fisher/thermocyclers/thermocycling_backend.py @@ -1,7 +1,5 @@ import asyncio import logging -import re -from base64 import b64decode from typing import List, Optional, cast from pylabrobot.capabilities.thermocycling import ( @@ -61,14 +59,21 @@ async def get_lid_open(self) -> bool: # ----- 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 - run_name = "testrun" - user = "Admin" - run_mode = "Fast" - cover_temp = 105 - cover_enabled = True - protocol_name = "PCR_Protocol" - stage_name_prefixes: Optional[List[str]] = None if await self.check_run_exists(run_name): logger.warning(f"Run {run_name} already exists") @@ -398,45 +403,13 @@ async def get_remaining_run_time(self): # ----- Log / run file management ----- async def get_log_by_runname(self, run_name: str) -> str: - res = await self._driver.send_command( - {"cmd": "FILe:READ?", "args": [f"RUNS:{run_name}/{run_name}.log"]}, - response_timeout=5, - read_once=False, - ) - if self._driver._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() - log = b64decode(encoded_log).decode("utf-8") - return log + return await self._driver.get_log_by_runname(run_name) 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)) - total_seconds = (hours * 3600) + (minutes * 60) + seconds - return total_seconds + return await self._driver.get_elapsed_run_time_from_log(run_name) async def check_run_exists(self, run_name: str) -> bool: - res = await self._driver.send_command( - {"cmd": "RUNS:EXISTS?", "args": [run_name], "params": {"type": "folders"}} - ) - if self._driver._parse_scpi_response(res)["status"] != "OK": - raise ValueError("Failed to check if run exists") - return cast(str, self._driver._parse_scpi_response(res)["args"][1]) == "True" + return await self._driver.check_run_exists(run_name) async def create_run(self, run_name: str): - res = await self._driver.send_command( - {"cmd": "RUNS:NEW", "args": [run_name]}, response_timeout=10 - ) - if self._driver._parse_scpi_response(res)["status"] != "OK": - raise ValueError("Failed to create run") - return self._driver._parse_scpi_response(res)["args"][0] + return await self._driver.create_run(run_name) From 780c446cdadcf06893defb67e32bac4fd6bef59d Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 23 Mar 2026 18:28:57 -0700 Subject: [PATCH 8/9] Fix issues from review - Fix empty backward compat shims (thermo_fisher/atc.py, proflex.py) - Make OT driver a DeviceBackend (consistent with TF) - OT device classes now pass driver to Device.__init__() - Remove duplicate deactivate calls from OT thermocycling backend stop() - Move dynamic imports to module level (ODTC legacy, thermocycler frontend) - All 19 tests pass Co-Authored-By: Claude Opus 4.6 (1M context) --- .../thermocycling/inheco/odtc_backend.py | 7 +----- .../legacy/thermocycling/thermocycler.py | 3 +-- pylabrobot/opentrons/thermocycler.py | 23 ++++++++++++------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/pylabrobot/legacy/thermocycling/inheco/odtc_backend.py b/pylabrobot/legacy/thermocycling/inheco/odtc_backend.py index 1defe1ed8c1..fcf1f6ecf38 100644 --- a/pylabrobot/legacy/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/legacy/thermocycling/inheco/odtc_backend.py @@ -2,7 +2,7 @@ from typing import Dict, List, Optional -from pylabrobot.inheco.odtc.odtc import ODTCDriver, ODTCThermocyclingBackend +from pylabrobot.inheco.odtc.odtc import ODTCBlockBackend, ODTCDriver, ODTCThermocyclingBackend from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend from pylabrobot.legacy.thermocycling.standard import ( BlockStatus, @@ -52,9 +52,6 @@ async def set_block_temperature(self, temperature: List[float], dynamic_time: bo return self._block_target_temp = temperature[0] lid = self._lid_target_temp if self._lid_target_temp is not None else 105.0 - # Use the block backend's _run_pre_method logic directly via driver - from pylabrobot.inheco.odtc.odtc import ODTCBlockBackend - block_be = ODTCBlockBackend(self._driver) block_be._lid_target = lid await block_be._run_pre_method(self._block_target_temp, lid) @@ -77,8 +74,6 @@ async def set_lid_temperature(self, temperature: List[float], dynamic_time: bool return self._lid_target_temp = temperature[0] block = self._block_target_temp if self._block_target_temp is not None else 25.0 - from pylabrobot.inheco.odtc.odtc import ODTCBlockBackend - block_be = ODTCBlockBackend(self._driver) block_be._lid_target = self._lid_target_temp await block_be._run_pre_method(block, self._lid_target_temp) diff --git a/pylabrobot/legacy/thermocycling/thermocycler.py b/pylabrobot/legacy/thermocycling/thermocycler.py index 9568f20a9f0..11bcf514cf9 100644 --- a/pylabrobot/legacy/thermocycling/thermocycler.py +++ b/pylabrobot/legacy/thermocycling/thermocycler.py @@ -23,6 +23,7 @@ Protocol, Stage, Step, + protocol_from_new, protocol_to_new, ) from pylabrobot.resources import Coordinate, ResourceHolder @@ -109,8 +110,6 @@ 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: - from pylabrobot.legacy.thermocycling.standard import protocol_from_new - await self._legacy.run_protocol(protocol_from_new(protocol), block_max_volume) async def get_hold_time(self) -> float: diff --git a/pylabrobot/opentrons/thermocycler.py b/pylabrobot/opentrons/thermocycler.py index f02032eb41e..e9bdab35814 100644 --- a/pylabrobot/opentrons/thermocycler.py +++ b/pylabrobot/opentrons/thermocycler.py @@ -11,7 +11,7 @@ ThermocyclingBackend, ThermocyclingCapability, ) -from pylabrobot.device import Device +from pylabrobot.device import Device, DeviceBackend from pylabrobot.resources import Coordinate, ItemizedResource, ResourceHolder try: @@ -37,13 +37,14 @@ # --------------------------------------------------------------------------- -class OpentronsThermocyclerDriver: +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]." @@ -51,6 +52,13 @@ def __init__(self, opentrons_id: str): ) 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: @@ -183,8 +191,7 @@ async def setup(self): pass async def stop(self): - self._driver.deactivate_block() - self._driver.deactivate_lid() + pass async def open_lid(self) -> None: self._driver.open_lid() @@ -247,7 +254,6 @@ def __init__( child: Optional[ItemizedResource] = None, ): self._driver = OpentronsThermocyclerDriver(opentrons_id=opentrons_id) - tc_backend = OpentronsThermocyclingBackend(self._driver) ResourceHolder.__init__( self, @@ -259,8 +265,9 @@ def __init__( category="thermocycler", model="thermocyclerModuleV1", ) - Device.__init__(self, backend=tc_backend) + 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) @@ -284,7 +291,6 @@ def __init__( child: Optional[ItemizedResource] = None, ): self._driver = OpentronsThermocyclerDriver(opentrons_id=opentrons_id) - tc_backend = OpentronsThermocyclingBackend(self._driver) ResourceHolder.__init__( self, @@ -296,8 +302,9 @@ def __init__( category="thermocycler", model="thermocyclerModuleV2", ) - Device.__init__(self, backend=tc_backend) + 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) From 5732d7b8e48c047d1c5207e738c039d4f338cc81 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 23 Mar 2026 18:30:02 -0700 Subject: [PATCH 9/9] Fix empty backward compat shims for TF atc.py and proflex.py Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/thermo_fisher/atc.py | 1 + pylabrobot/thermo_fisher/proflex.py | 1 + 2 files changed, 2 insertions(+) diff --git a/pylabrobot/thermo_fisher/atc.py b/pylabrobot/thermo_fisher/atc.py index e69de29bb2d..31ff4110f9d 100644 --- a/pylabrobot/thermo_fisher/atc.py +++ 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 index e69de29bb2d..890706046ed 100644 --- a/pylabrobot/thermo_fisher/proflex.py +++ b/pylabrobot/thermo_fisher/proflex.py @@ -0,0 +1 @@ +from .thermocyclers.proflex import ProFlexSingleBlock, ProFlexThreeBlock # noqa: F401