From 3d5cd967a6f931cad5d0875ae11657724312eb2e Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 23 Feb 2026 17:30:16 -0800 Subject: [PATCH] Revert "Refactor shaker API and move BioShake to shaking module (#905)" This reverts commit 8adab4d13f9daea759d069e20a465c72e6246330. --- .../heating_shaking/bioshake_backend.py | 258 +++++++++++++++++- .../heating_shaking/hamilton_backend.py | 22 +- .../inheco/thermoshake_backend.py | 17 +- pylabrobot/shaking/__init__.py | 1 - pylabrobot/shaking/backend.py | 4 +- pylabrobot/shaking/bioshake_backend.py | 202 -------------- pylabrobot/shaking/chatterbox.py | 12 +- pylabrobot/shaking/shaker.py | 2 +- 8 files changed, 253 insertions(+), 265 deletions(-) delete mode 100644 pylabrobot/shaking/bioshake_backend.py diff --git a/pylabrobot/heating_shaking/bioshake_backend.py b/pylabrobot/heating_shaking/bioshake_backend.py index 44c3f58dd84..4a5912511e9 100644 --- a/pylabrobot/heating_shaking/bioshake_backend.py +++ b/pylabrobot/heating_shaking/bioshake_backend.py @@ -1,19 +1,251 @@ -import warnings +import asyncio -from pylabrobot.shaking.bioshake_backend import BioShake as _BioShake +from pylabrobot.heating_shaking.backend import HeaterShakerBackend +from pylabrobot.io.serial import Serial +from pylabrobot.machines.backend import MachineBackend +try: + import serial -class BioShake(_BioShake): - """Deprecated import path for BioShake. + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e - BioShake is a shaker-only backend and lives in ``pylabrobot.shaking``. - """ - def __init__(self, *args, **kwargs): - warnings.warn( - "pylabrobot.heating_shaking.bioshake_backend.BioShake is deprecated. " - "Use pylabrobot.shaking.bioshake_backend.BioShake instead.", - DeprecationWarning, - stacklevel=2, +class BioShake(HeaterShakerBackend): + def __init__(self, port: str, timeout: int = 60): + if not HAS_SERIAL: + raise RuntimeError( + f"pyserial is required for the BioShake module backend. Import error: {_SERIAL_IMPORT_ERROR}" + ) + + self.setup_finished = False + self.port = port + self.timeout = timeout + self.io = Serial( + port=self.port, + baudrate=9600, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + write_timeout=10, + timeout=self.timeout, ) - super().__init__(*args, **kwargs) + + async def _send_command(self, cmd: str, delay: float = 0.5, timeout: float = 2): + try: + # Flush serial buffers for a clean start + await self.io.reset_input_buffer() + await self.io.reset_output_buffer() + + # Send the command + await self.io.write((cmd + "\r").encode("ascii")) + await asyncio.sleep(delay) + + # Read and decode the response with a timeout + try: + response = await asyncio.wait_for(self.io.readline(), timeout=timeout) + + except asyncio.TimeoutError: + raise RuntimeError(f"Timed out waiting for response to '{cmd}'") + + decoded = response.decode("ascii", errors="ignore").strip() + + # Parsing the response from the BioShake + + # No response at all + if not decoded: + raise RuntimeError(f"No response for '{cmd}'") + + # Device-specific errors + if decoded.startswith("e"): + raise RuntimeError(f"Device returned error for '{cmd}': '{decoded}'") + + if decoded.startswith("u ->"): + raise NotImplementedError(f"'{cmd}' not supported: '{decoded}'") + + # Standard OK + if decoded.lower().startswith("ok"): + return None + + # All other valid responses (e.g. temperature and remaining time) + return decoded + + except Exception as e: + raise RuntimeError(f"Unexpected error while sending '{cmd}': {type(e).__name__}: {e}") from e + + async def setup(self, skip_home: bool = False): + await MachineBackend.setup(self) + await self.io.setup() + if not skip_home: + # Reset first before homing it to ensure the device is ready for run + await self.reset() + # Additional seconds until next command can be send after reset + await asyncio.sleep(4) + # Now home the device + await self.home() + + async def stop(self): + await MachineBackend.stop(self) + await self.io.stop() + + async def reset(self): + # Reset the BioShake if stuck in "e" state + # Flush serial buffers for a clean start + await self.io.reset_input_buffer() + await self.io.reset_output_buffer() + + # Send the command + await self.io.write(("resetDevice\r").encode("ascii")) + + start = asyncio.get_event_loop().time() + max_seconds = 30 # How long a reset typically last + + while True: + # Break the loop if process takes longer than 30 seconds + if asyncio.get_event_loop().time() - start > max_seconds: + raise TimeoutError("Reset did not complete in time") + + try: + # Wait for each line with a timeout + response = await asyncio.wait_for(self.io.readline(), timeout=2) + decoded = response.decode("ascii", errors="ignore").strip() + await asyncio.sleep(0.1) + + if len(decoded) > 0: + # Stop when the final message arrives + if "Initialization complete" in decoded: + break + + except asyncio.TimeoutError: + # Keep polling if nothing arrives within timeout + continue + + async def home(self): + # Initialize the BioShake into home position + await self._send_command(cmd="shakeGoHome", delay=5) + + async def shake(self, speed: float, acceleration: int = 0): + # Check if speed is an integer + if isinstance(speed, float): + if not speed.is_integer(): + raise ValueError(f"Speed must be a whole number, not {speed}") + speed = int(speed) + if not isinstance(speed, int): + raise TypeError( + f"Speed must be an integer or a whole number float, not {type(speed).__name__}" + ) + + # Get the min and max speed of the device to assert speed + min_speed = int(float(await self._send_command(cmd="getShakeMinRpm", delay=0.2))) + max_speed = int(float(await self._send_command(cmd="getShakeMaxRpm", delay=0.2))) + + assert ( + min_speed <= speed <= max_speed + ), f"Speed {speed} RPM is out of range. Allowed range is {min_speed}{max_speed} RPM" + + # Set the speed of the shaker + set_speed_cmd = f"setShakeTargetSpeed{speed}" + await self._send_command(cmd=set_speed_cmd) + + # Check if accel is an integer + if isinstance(acceleration, float): + if not acceleration.is_integer(): # type: ignore[attr-defined] # mypy is retarded + raise ValueError(f"Acceleration must be a whole number, not {acceleration}") + acceleration = int(acceleration) + if not isinstance(acceleration, int): + raise TypeError( + f"Acceleration must be an integer or a whole number float, not {type(acceleration).__name__}" + ) + + # Get the min and max acceleration of the device to check bounds + min_accel = int(float(await self._send_command(cmd="getShakeAccelerationMin", delay=0.2))) + max_accel = int(float(await self._send_command(cmd="getShakeAccelerationMax", delay=0.2))) + + assert ( + min_accel <= acceleration <= max_accel + ), f"Acceleration {acceleration} seconds is out of range. Allowed range is {min_accel}-{max_accel} seconds" + + # Set the acceleration of the shaker + set_accel_cmd = f"setShakeAcceleration{acceleration}" + await self._send_command(cmd=set_accel_cmd, delay=0.2) + + # Send the command to start shaking, either with or without duration + + await self._send_command(cmd="shakeOn", delay=0.2) + + async def stop_shaking(self, deceleration: int = 0): + # Check if decel is an integer + if isinstance(deceleration, float): + if not deceleration.is_integer(): # type: ignore[attr-defined] # mypy is retarded + raise ValueError(f"Deceleration must be a whole number, not {deceleration}") + deceleration = int(deceleration) + if not isinstance(deceleration, int): + raise TypeError( + f"Deceleration must be an integer or a whole number float, not {type(deceleration).__name__}" + ) + + # Get the min and max decel of the device to asset decel + min_decel = int(float(await self._send_command(cmd="getShakeAccelerationMin", delay=0.2))) + max_decel = int(float(await self._send_command(cmd="getShakeAccelerationMax", delay=0.2))) + + assert ( + min_decel <= deceleration <= max_decel + ), f"Deceleration {deceleration} seconds is out of range. Allowed range is {min_decel}-{max_decel} seconds" + + # Set the deceleration of the shaker + set_decel_cmd = f"setShakeAcceleration{deceleration}" + await self._send_command(cmd=set_decel_cmd, delay=0.2) + + # stop shaking + await self._send_command(cmd="shakeOff", delay=0.2) + + @property + def supports_locking(self) -> bool: + return True + + async def lock_plate(self): + await self._send_command(cmd="setElmLockPos", delay=0.3) + + async def unlock_plate(self): + await self._send_command(cmd="setElmUnlockPos", delay=0.3) + + @property + def supports_active_cooling(self) -> bool: + return True + + async def set_temperature(self, temperature: float): + # Get the min and max set points of the device to assert temperature + min_temp = int(float(await self._send_command(cmd="getTempMin", delay=0.2))) + max_temp = int(float(await self._send_command(cmd="getTempMax", delay=0.2))) + + assert ( + min_temp <= temperature <= max_temp + ), f"Temperature {temperature} C is out of range. Allowed range is {min_temp}–{max_temp} C." + + temperature = temperature * 10 + + # Check if temperature is an integer + if isinstance(temperature, float): + if not temperature.is_integer(): + raise ValueError(f"Temperature must be a whole number, not {temperature} (1/10 C)") + temperature = int(temperature) + if not isinstance(temperature, int): + raise TypeError( + f"Temperature must be an integer or a whole number float, not {type(temperature).__name__} (1/10 C)" + ) + + set_temp_cmd = f"setTempTarget{temperature}" + await self._send_command(cmd=set_temp_cmd, delay=0.2) + + # Start temperature control + await self._send_command(cmd="tempOn", delay=0.2) + + async def get_current_temperature(self) -> float: + response = await self._send_command(cmd="getTempActual", delay=0.2) + return float(response) + + async def deactivate(self): + # Stop temperature control + await self._send_command(cmd="tempOff", delay=0.2) diff --git a/pylabrobot/heating_shaking/hamilton_backend.py b/pylabrobot/heating_shaking/hamilton_backend.py index 4ea7401cdff..afb5fc2478d 100644 --- a/pylabrobot/heating_shaking/hamilton_backend.py +++ b/pylabrobot/heating_shaking/hamilton_backend.py @@ -96,7 +96,7 @@ def serialize(self) -> dict: "interface": None, # TODO: implement serialization } - async def start_shaking( + async def shake( self, speed: float = 800, direction: Literal[0, 1] = 0, @@ -126,26 +126,6 @@ async def start_shaking( if timeout is not None and time.time() - now > timeout: raise TimeoutError("Failed to start shaking within timeout") - async def shake( - self, - speed: float = 800, - direction: Literal[0, 1] = 0, - acceleration: int = 1_000, - timeout: Optional[float] = 30, - ): - """Deprecated alias for ``start_shaking``.""" - warnings.warn( - "HamiltonHeaterShakerBackend.shake() is deprecated. Use start_shaking() instead.", - DeprecationWarning, - stacklevel=2, - ) - await self.start_shaking( - speed=speed, - direction=direction, - acceleration=acceleration, - timeout=timeout, - ) - async def stop_shaking(self): await self._stop_shaking() await self._wait_for_stop() diff --git a/pylabrobot/heating_shaking/inheco/thermoshake_backend.py b/pylabrobot/heating_shaking/inheco/thermoshake_backend.py index fcb25c341c2..c2c4c5e1cb8 100644 --- a/pylabrobot/heating_shaking/inheco/thermoshake_backend.py +++ b/pylabrobot/heating_shaking/inheco/thermoshake_backend.py @@ -1,5 +1,3 @@ -import warnings - from pylabrobot.heating_shaking.backend import HeaterShakerBackend from pylabrobot.temperature_controlling.inheco.temperature_controller import ( InhecoTemperatureControllerBackend, @@ -16,7 +14,7 @@ async def stop(self): await self.stop_shaking() await super().stop() - async def _start_shaking_command(self): + async def start_shaking(self): """Start shaking the device at the speed set by `set_shaker_speed`""" return await self.interface.send_command(f"{self.index}ASE1") @@ -53,7 +51,7 @@ async def set_shaker_shape(self, shape: int): return await self.interface.send_command(f"1SSS{shape}") - async def start_shaking(self, speed: float, shape: int = 0): + async def shake(self, speed: float, shape: int = 0): """Shake the shaker at the given speed Args: @@ -62,16 +60,7 @@ async def start_shaking(self, speed: float, shape: int = 0): await self.set_shaker_speed(speed=speed) await self.set_shaker_shape(shape=shape) - await self._start_shaking_command() - - async def shake(self, speed: float, shape: int = 0): - """Deprecated alias for ``start_shaking``.""" - warnings.warn( - "InhecoThermoshakeBackend.shake() is deprecated. Use start_shaking() instead.", - DeprecationWarning, - stacklevel=2, - ) - await self.start_shaking(speed=speed, shape=shape) + await self.start_shaking() @property def supports_locking(self) -> bool: diff --git a/pylabrobot/shaking/__init__.py b/pylabrobot/shaking/__init__.py index d71f56c3f11..f17a298d2d8 100644 --- a/pylabrobot/shaking/__init__.py +++ b/pylabrobot/shaking/__init__.py @@ -1,4 +1,3 @@ from .backend import ShakerBackend -from .bioshake_backend import BioShake from .chatterbox import ShakerChatterboxBackend from .shaker import Shaker diff --git a/pylabrobot/shaking/backend.py b/pylabrobot/shaking/backend.py index 8d3b92f422b..bbd66d72e36 100644 --- a/pylabrobot/shaking/backend.py +++ b/pylabrobot/shaking/backend.py @@ -7,8 +7,8 @@ class ShakerBackend(MachineBackend, metaclass=ABCMeta): """Backend for a shaker machine""" @abstractmethod - async def start_shaking(self, speed: float): - """Start shaking at the given speed. + async def shake(self, speed: float): + """Shake the shaker at the given speed Args: speed: Speed of shaking in revolutions per minute (RPM) diff --git a/pylabrobot/shaking/bioshake_backend.py b/pylabrobot/shaking/bioshake_backend.py deleted file mode 100644 index edd20584ba9..00000000000 --- a/pylabrobot/shaking/bioshake_backend.py +++ /dev/null @@ -1,202 +0,0 @@ -import asyncio -import warnings - -from pylabrobot.io.serial import Serial -from pylabrobot.machines.backend import MachineBackend -from pylabrobot.shaking.backend import ShakerBackend - -try: - import serial - - HAS_SERIAL = True -except ImportError as e: - HAS_SERIAL = False - _SERIAL_IMPORT_ERROR = e - - -class BioShake(ShakerBackend): - """Backend for BioShake devices. - - This backend models BioShake as a pure shaker with plate locking support. - """ - - def __init__(self, port: str, timeout: int = 60): - if not HAS_SERIAL: - raise RuntimeError( - f"pyserial is required for the BioShake module backend. Import error: {_SERIAL_IMPORT_ERROR}" - ) - - self.setup_finished = False - self.port = port - self.timeout = timeout - self.io = Serial( - port=self.port, - baudrate=9600, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - write_timeout=10, - timeout=self.timeout, - ) - - def serialize(self) -> dict: - return { - **super().serialize(), - "port": self.port, - "timeout": self.timeout, - } - - async def _send_command(self, cmd: str, delay: float = 0.5, timeout: float = 2): - try: - # Flush serial buffers for a clean start - await self.io.reset_input_buffer() - await self.io.reset_output_buffer() - - # Send the command - await self.io.write((cmd + "\r").encode("ascii")) - await asyncio.sleep(delay) - - # Read and decode the response with a timeout - try: - response = await asyncio.wait_for(self.io.readline(), timeout=timeout) - except asyncio.TimeoutError: - raise RuntimeError(f"Timed out waiting for response to '{cmd}'") - - decoded = response.decode("ascii", errors="ignore").strip() - - # No response at all - if not decoded: - raise RuntimeError(f"No response for '{cmd}'") - - # Device-specific errors - if decoded.startswith("e"): - raise RuntimeError(f"Device returned error for '{cmd}': '{decoded}'") - - if decoded.startswith("u ->"): - raise NotImplementedError(f"'{cmd}' not supported: '{decoded}'") - - # Standard OK - if decoded.lower().startswith("ok"): - return None - - return decoded - - except Exception as e: - raise RuntimeError(f"Unexpected error while sending '{cmd}': {type(e).__name__}: {e}") from e - - async def setup(self, skip_home: bool = False): - await MachineBackend.setup(self) - await self.io.setup() - if not skip_home: - # Reset first before homing to ensure the device is ready for use. - await self.reset() - # Additional time until next command can be sent after reset. - await asyncio.sleep(4) - await self.home() - - async def stop(self): - await MachineBackend.stop(self) - await self.io.stop() - - async def reset(self): - # Reset the BioShake if stuck in "e" state. - await self.io.reset_input_buffer() - await self.io.reset_output_buffer() - await self.io.write(("resetDevice\r").encode("ascii")) - - start = asyncio.get_event_loop().time() - max_seconds = 30 # Typical reset duration. - - while True: - if asyncio.get_event_loop().time() - start > max_seconds: - raise TimeoutError("Reset did not complete in time") - - try: - response = await asyncio.wait_for(self.io.readline(), timeout=2) - decoded = response.decode("ascii", errors="ignore").strip() - await asyncio.sleep(0.1) - if decoded and "Initialization complete" in decoded: - break - except asyncio.TimeoutError: - continue - - async def home(self): - # Initialize BioShake into home position. - await self._send_command(cmd="shakeGoHome", delay=5) - - async def start_shaking(self, speed: float, acceleration: int = 0): - # Check speed value type. - if isinstance(speed, float): - if not speed.is_integer(): - raise ValueError(f"Speed must be a whole number, not {speed}") - speed = int(speed) - if not isinstance(speed, int): - raise TypeError( - f"Speed must be an integer or a whole number float, not {type(speed).__name__}" - ) - - min_speed = int(float(await self._send_command(cmd="getShakeMinRpm", delay=0.2))) - max_speed = int(float(await self._send_command(cmd="getShakeMaxRpm", delay=0.2))) - assert ( - min_speed <= speed <= max_speed - ), f"Speed {speed} RPM is out of range. Allowed range is {min_speed}{max_speed} RPM" - - await self._send_command(cmd=f"setShakeTargetSpeed{speed}") - - if isinstance(acceleration, float): - if not acceleration.is_integer(): # type: ignore[attr-defined] - raise ValueError(f"Acceleration must be a whole number, not {acceleration}") - acceleration = int(acceleration) - if not isinstance(acceleration, int): - raise TypeError( - "Acceleration must be an integer or a whole number float, not " - f"{type(acceleration).__name__}" - ) - - min_accel = int(float(await self._send_command(cmd="getShakeAccelerationMin", delay=0.2))) - max_accel = int(float(await self._send_command(cmd="getShakeAccelerationMax", delay=0.2))) - assert ( - min_accel <= acceleration <= max_accel - ), f"Acceleration {acceleration} seconds is out of range. Allowed range is {min_accel}-{max_accel} seconds" - - await self._send_command(cmd=f"setShakeAcceleration{acceleration}", delay=0.2) - await self._send_command(cmd="shakeOn", delay=0.2) - - async def shake(self, speed: float, acceleration: int = 0): - """Deprecated alias for ``start_shaking``.""" - warnings.warn( - "BioShake.shake() is deprecated. Use start_shaking() instead.", - DeprecationWarning, - stacklevel=2, - ) - await self.start_shaking(speed=speed, acceleration=acceleration) - - async def stop_shaking(self, deceleration: int = 0): - if isinstance(deceleration, float): - if not deceleration.is_integer(): # type: ignore[attr-defined] - raise ValueError(f"Deceleration must be a whole number, not {deceleration}") - deceleration = int(deceleration) - if not isinstance(deceleration, int): - raise TypeError( - "Deceleration must be an integer or a whole number float, not " - f"{type(deceleration).__name__}" - ) - - min_decel = int(float(await self._send_command(cmd="getShakeAccelerationMin", delay=0.2))) - max_decel = int(float(await self._send_command(cmd="getShakeAccelerationMax", delay=0.2))) - assert ( - min_decel <= deceleration <= max_decel - ), f"Deceleration {deceleration} seconds is out of range. Allowed range is {min_decel}-{max_decel} seconds" - - await self._send_command(cmd=f"setShakeAcceleration{deceleration}", delay=0.2) - await self._send_command(cmd="shakeOff", delay=0.2) - - @property - def supports_locking(self) -> bool: - return True - - async def lock_plate(self): - await self._send_command(cmd="setElmLockPos", delay=0.3) - - async def unlock_plate(self): - await self._send_command(cmd="setElmUnlockPos", delay=0.3) diff --git a/pylabrobot/shaking/chatterbox.py b/pylabrobot/shaking/chatterbox.py index 63a317641cc..c04db2d5a89 100644 --- a/pylabrobot/shaking/chatterbox.py +++ b/pylabrobot/shaking/chatterbox.py @@ -1,5 +1,3 @@ -import warnings - from pylabrobot.shaking import ShakerBackend @@ -14,16 +12,8 @@ async def setup(self): async def stop(self): print("Stopping shaker") - async def start_shaking(self, speed: float): - print("Shaking at speed", speed) - async def shake(self, speed: float): - warnings.warn( - "ShakerChatterboxBackend.shake() is deprecated. Use start_shaking() instead.", - DeprecationWarning, - stacklevel=2, - ) - await self.start_shaking(speed=speed) + print("Shaking at speed", speed) async def stop_shaking(self): print("Stopping shaking") diff --git a/pylabrobot/shaking/shaker.py b/pylabrobot/shaking/shaker.py index a503279e5d4..2f004adebb3 100644 --- a/pylabrobot/shaking/shaker.py +++ b/pylabrobot/shaking/shaker.py @@ -43,7 +43,7 @@ async def shake(self, speed: float, duration: Optional[float] = None, **backend_ """ if self.backend.supports_locking: await self.backend.lock_plate() - await self.backend.start_shaking(speed=speed, **backend_kwargs) + await self.backend.shake(speed=speed, **backend_kwargs) if duration is None: return