diff --git a/docs/api/pylabrobot.kbiosystems.rst b/docs/api/pylabrobot.kbiosystems.rst index cc301145480..1a7da0b36c5 100644 --- a/docs/api/pylabrobot.kbiosystems.rst +++ b/docs/api/pylabrobot.kbiosystems.rst @@ -23,6 +23,16 @@ pylabrobot.kbiosystems package KBiosystemsUltrasealEPRO UltrasealEPROStatus +.. currentmodule:: pylabrobot.kbiosystems.ultraseal_pro + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + KBiosystemsUltrasealPRO + UltrasealPROStatus + .. currentmodule:: pylabrobot.kbiosystems.ultraseal_xt_pro .. autosummary:: diff --git a/docs/user_guide/kbiosystems/index.md b/docs/user_guide/kbiosystems/index.md index 1842f5ae857..328a9c58abb 100644 --- a/docs/user_guide/kbiosystems/index.md +++ b/docs/user_guide/kbiosystems/index.md @@ -4,5 +4,6 @@ :maxdepth: 1 ultraseal-epro/hello-world +ultraseal-pro/hello-world ultraseal-xt-pro/hello-world ``` diff --git a/docs/user_guide/kbiosystems/ultraseal-pro/hello-world.ipynb b/docs/user_guide/kbiosystems/ultraseal-pro/hello-world.ipynb new file mode 100644 index 00000000000..16e72dca893 --- /dev/null +++ b/docs/user_guide/kbiosystems/ultraseal-pro/hello-world.ipynb @@ -0,0 +1,98 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ultraseal-pro-intro", + "source": "# KBiosystems Ultraseal PRO (formerly WASP)\n\nThe Ultraseal PRO - formerly sold as the WASP - is an inline, pneumatically driven heat sealer for microplates from KBiosystems, controlled over an RS-232 serial interface. A plate is placed on a shuttle that extends from the front of the unit; the seal command draws the shuttle in, seals, and returns it automatically. The Ultraseal PRO advances and cuts its own film internally and has no foil-length, force, or distance parameters. It requires a compressed air supply.\n\n| Property | Value |\n|---|---|\n| [OEM link](https://kbiosystems.com/product/ultraseal-pro) (formerly the WASP) | |\n| Communication | Serial / RS-232, ASCII command protocol |\n| Serial settings | 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake |\n| Terminator | CR (`\\r`) |\n| Temperature | 25-200 °C |\n| Sealing time | 0.5-9.9 s |", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "ultraseal-pro-setup-md", + "source": "## Physical setup\n\nConnect the sealer's RS-232 port to your computer, typically through a USB-to-serial adapter, and connect the compressed air supply. Make sure the sealer's serial parameters match the driver defaults (9600 baud, 8 data bits, no parity, 1 stop bit, no handshake).", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "ultraseal-pro-connect-md", + "source": "## Connect\n\n`setup()` opens the serial port, waits for the device to be ready, and pre-heats to the preheating temperature.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-pro-connect-code", + "source": "from pylabrobot.kbiosystems import KBiosystemsUltrasealPRO\n\nsealer = KBiosystemsUltrasealPRO(port=\"/dev/ttyUSB0\") # replace with your port\nawait sealer.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-pro-seal-md", + "source": "## Seal a plate\n\nPlace a plate on the extended shuttle. `seal()` applies the time and temperature, waits for the heater to reach the setpoint, then draws the shuttle in, seals the plate, and returns the shuttle to the start position automatically before returning to the idle temperature. Sealing time is 0.5-9.9 s; temperature is 25-200 °C.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-pro-seal-code", + "source": "await sealer.seal(temperature=170, duration=2.5)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-pro-temp-md", + "source": "## Temperature\n\nSet the sealing temperature setpoint and read the current heater temperature.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-pro-temp-code", + "source": "await sealer.set_temperature(160)\nprint(\"Current temperature:\", await sealer.request_temperature(), \"C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-pro-status-md", + "source": "## Status\n\nRead the status byte as a set of flags. On the Ultraseal PRO this includes `LowAir`, which indicates the compressed air supply is insufficient, and `ParkMode`, which indicates the shuttle is parked inside.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-pro-status-code", + "source": "print(\"Status:\", await sealer.request_status())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-pro-teardown-md", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-pro-teardown-code", + "source": "await sealer.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/pylabrobot/kbiosystems/__init__.py b/pylabrobot/kbiosystems/__init__.py index 065153bc081..7e30915214a 100644 --- a/pylabrobot/kbiosystems/__init__.py +++ b/pylabrobot/kbiosystems/__init__.py @@ -1,3 +1,4 @@ from .sealer import KBiosystemsError, KBiosystemsSealer from .ultraseal_epro import KBiosystemsUltrasealEPRO, UltrasealEPROStatus +from .ultraseal_pro import KBiosystemsUltrasealPRO, UltrasealPROStatus from .ultraseal_xt_pro import KBiosystemsUltrasealXTPro, UltrasealXTProStatus diff --git a/pylabrobot/kbiosystems/sealer.py b/pylabrobot/kbiosystems/sealer.py index 85306acafbc..c95a3763581 100644 --- a/pylabrobot/kbiosystems/sealer.py +++ b/pylabrobot/kbiosystems/sealer.py @@ -101,16 +101,7 @@ async def seal(self, temperature: int, duration: float) -> None: """Seal a plate at the given sealing temperature (C) and duration (s).""" async def _open(self) -> None: - """Open the port, wait out the post-open settling, and clear the buffer. - - Emits the not-tested warning: no KBiosystems sealer driver has been verified - against hardware in PyLabRobot yet. - """ - logger.warning( - "%s has NOT been tested against hardware in PyLabRobot. Please make a PR " - "to remove this message if you have verified it on your hardware.", - type(self).__name__, - ) + """Open the port, wait out the post-open settling, and clear the buffer.""" await self.io.setup() # The device drops characters for a few seconds after the port opens. await asyncio.sleep(self.settle_time) diff --git a/pylabrobot/kbiosystems/sealer_tests.py b/pylabrobot/kbiosystems/sealer_tests.py index b5370b5b4ed..9613fcc8f49 100644 --- a/pylabrobot/kbiosystems/sealer_tests.py +++ b/pylabrobot/kbiosystems/sealer_tests.py @@ -6,8 +6,10 @@ KBiosystemsError, KBiosystemsSealer, KBiosystemsUltrasealEPRO, + KBiosystemsUltrasealPRO, KBiosystemsUltrasealXTPro, UltrasealEPROStatus, + UltrasealPROStatus, UltrasealXTProStatus, ) @@ -171,10 +173,56 @@ async def test_error_status_raises_with_code(self): self.assertEqual(ctx.exception.message, "Low air pressure.") +class TestUltrasealPRO(KBiosystemsSealerTestBase): + def _make(self, responses: Dict[str, str]) -> KBiosystemsUltrasealPRO: + sealer = KBiosystemsUltrasealPRO(port="FAKE") + sealer.io = FakeSealerSerial(responses) # type: ignore[assignment] + return sealer + + async def test_setup_sequence(self): + sealer = self._make({"?": "00", "A100": "ok"}) + await sealer.setup() + self.assertEqual(sealer.io.written, ["?", "A100"]) # type: ignore[attr-defined] + + async def test_seal_wire_bytes(self): + sealer = self._make({"?": "00", "B30": "ok", "A180": "ok", "A100": "ok", "S": "ok"}) + await sealer.seal(temperature=180, duration=3.0) + written = sealer.io.written # type: ignore[attr-defined] + for expected in ["B30", "A180", "S", "A100"]: + self.assertIn(expected, written) + # The Ultraseal PRO has no foil/force/distance/eco or shuttle commands. + self.assertFalse( + any( + w.startswith(("L=", "DO=", "PS=", "FS=", "ECO_")) or w in ("P", "U", "R") for w in written + ) + ) + + async def test_park_mode_is_bit_7(self): + # The Ultraseal PRO reports Park Mode at 0x80 (bit 6 is spare) - unlike the + # XT Pro, where Park Mode is 0x40. Decoding either byte must not raise. + sealer = self._make({"?": "80"}) + self.assertEqual(await sealer.request_status(), UltrasealPROStatus.ParkMode) + sealer = self._make({"?": "40"}) + self.assertEqual(await sealer.request_status(), UltrasealPROStatus.Spare) + + async def test_status_decode_lowair(self): + sealer = self._make({"?": "24"}) + status = await sealer.request_status() + self.assertEqual(status, UltrasealPROStatus.LowAir | UltrasealPROStatus.Busy) + + async def test_error_status_raises_with_code(self): + sealer = self._make({"?": "02", "E": "09"}) + with self.assertRaises(KBiosystemsError) as ctx: + await sealer.wait_for_idle() + self.assertEqual(ctx.exception.error_code, 9) + self.assertEqual(ctx.exception.message, "The sealer is overheating.") + + class TestSharedBase(unittest.TestCase): def test_both_drivers_subclass_base(self): self.assertTrue(issubclass(KBiosystemsUltrasealEPRO, KBiosystemsSealer)) self.assertTrue(issubclass(KBiosystemsUltrasealXTPro, KBiosystemsSealer)) + self.assertTrue(issubclass(KBiosystemsUltrasealPRO, KBiosystemsSealer)) def test_base_is_abstract(self): with self.assertRaises(TypeError): @@ -186,6 +234,9 @@ def test_shared_low_status_bits(self): self.assertEqual( int(getattr(UltrasealEPROStatus, bit)), int(getattr(UltrasealXTProStatus, bit)) ) + self.assertEqual( + int(getattr(UltrasealEPROStatus, bit)), int(getattr(UltrasealPROStatus, bit)) + ) if __name__ == "__main__": diff --git a/pylabrobot/kbiosystems/ultraseal_epro.py b/pylabrobot/kbiosystems/ultraseal_epro.py index 54a095be895..8cd6f788661 100644 --- a/pylabrobot/kbiosystems/ultraseal_epro.py +++ b/pylabrobot/kbiosystems/ultraseal_epro.py @@ -114,6 +114,10 @@ def __init__( self.firmware_version: Optional[str] = None async def setup(self) -> None: + logger.warning( + "KBiosystemsUltrasealEPRO has NOT been tested against hardware in PyLabRobot. " + "Please make a PR to remove this message if you have verified it on your hardware." + ) await self._open() # Initialize/home the sealer (``I``). if self._check(await self.send_command("I"), {"ok", "err"}, "I") != "ok": diff --git a/pylabrobot/kbiosystems/ultraseal_pro.py b/pylabrobot/kbiosystems/ultraseal_pro.py new file mode 100644 index 00000000000..e21b9872c47 --- /dev/null +++ b/pylabrobot/kbiosystems/ultraseal_pro.py @@ -0,0 +1,149 @@ +import enum +import logging +from typing import Dict + +from pylabrobot.kbiosystems.sealer import KBiosystemsError, KBiosystemsSealer + +logger = logging.getLogger(__name__) + +__all__ = ["KBiosystemsUltrasealPRO", "UltrasealPROStatus", "KBiosystemsError"] + + +class UltrasealPROStatus(enum.IntFlag): + """Status byte returned by the ``?`` command (two hex digits). + + ``Ready`` (0x00) is the all-clear value; any other bit is a condition that + must be cleared, or masked when ignorable, before an operation runs. The low + bits (Ready/Error/Busy/NotAtSealTemperature) are the shared set + :class:`KBiosystemsSealer` acts on. + + The Ultraseal PRO differs from the Ultraseal XT Pro in the top of the byte: + ``ParkMode`` is bit 7 (0x80) with bit 6 (0x40) a documented spare, whereas the + XT Pro reports ``ParkMode`` at 0x40. ``Spare`` is kept as a member so decoding + a byte with that bit set does not raise. + """ + + Ready = 0x00 + NoFoil = 0x01 + Error = 0x02 + Busy = 0x04 + NotAtSealTemperature = 0x08 + PlateNotPresent = 0x10 + LowAir = 0x20 + Spare = 0x40 + ParkMode = 0x80 + + +# Text for each status bit, used to describe a not-ready condition. +STATUS_MESSAGES: Dict[enum.IntFlag, str] = { + UltrasealPROStatus.NoFoil: "No foil detected.", + UltrasealPROStatus.Error: "Error.", + UltrasealPROStatus.Busy: "Device busy.", + UltrasealPROStatus.NotAtSealTemperature: "Waiting for seal temperature.", + UltrasealPROStatus.PlateNotPresent: "No plate detected.", + UltrasealPROStatus.LowAir: "Low air pressure - check the compressed air supply.", + UltrasealPROStatus.ParkMode: "Shuttle parked (inside).", +} + +# Error codes returned by the ``E`` command (two decimal digits). +DEVICE_ERRORS = { + 1: "Vertical shuttle down.", + 2: "Heater up.", + 3: "Shuttle in.", + 4: "Shuttle out.", + 7: "Thermocouple error - ambient temperature may be too low.", + 9: "The sealer is overheating.", + 10: "No foil detected.", +} + +MIN_SEALING_TEMPERATURE = 25 +MAX_SEALING_TEMPERATURE = 200 +MIN_SEALING_DURATION = 0.5 +MAX_SEALING_DURATION = 9.9 + + +class KBiosystemsUltrasealPRO(KBiosystemsSealer): + """KBiosystems Ultraseal PRO heat sealer (formerly the WASP). + + An inline, pneumatically driven heat sealer that advances and cuts its own + film internally. A plate is placed on the shuttle while it is extended; the + seal command draws the shuttle in, seals, and returns it automatically, so - + unlike the Ultraseal XT Pro - the Ultraseal PRO exposes no shuttle + park/unpark/reset commands over serial. The system initializes on power up + (reporting ``Busy`` while it does), so there is no initialize command either. + + Commands (in addition to the shared ``?``/``E``/``S``/``A``/``B``/``C``/ + ``D``/``F``, see :class:`KBiosystemsSealer`): none. The Ultraseal PRO host + protocol is exactly the shared command set. + + Requires a compressed air supply (see the ``LowAir`` status bit). + + Verified against hardware. + """ + + _HUMAN_READABLE_NAME = "KBiosystems Ultraseal PRO Heat Sealer" + + STATUS = UltrasealPROStatus + STATUS_MESSAGES = STATUS_MESSAGES + ERRORS = DEVICE_ERRORS + MIN_SEALING_TEMPERATURE = MIN_SEALING_TEMPERATURE + MAX_SEALING_TEMPERATURE = MAX_SEALING_TEMPERATURE + + def __init__( + self, + port: str, + timeout: float = 5.0, + settle_time: float = 5.0, + preheating_temperature: int = 100, + offline_temperature: int = 25, + ) -> None: + super().__init__( + port, + timeout=timeout, + settle_time=settle_time, + preheating_temperature=preheating_temperature, + offline_temperature=offline_temperature, + ) + + async def setup(self) -> None: + await self._open() + await self.wait_for_idle( + UltrasealPROStatus.NoFoil + | UltrasealPROStatus.NotAtSealTemperature + | UltrasealPROStatus.ParkMode + ) + await self.set_temperature(self.preheating_temperature) + logger.info("[Ultraseal PRO %s] connected", self.io.port) + + # === Operations === + + async def seal( + self, + temperature: int, + duration: float, + idle_temperature: int = 100, + ) -> None: + """Seal the plate currently on the shuttle. + + Waits for the device to be ready, applies the time and temperature, waits + for the heater to reach the setpoint, seals, then returns to + ``idle_temperature``. The Ultraseal PRO draws the shuttle in, seals, and + returns it automatically; film advance and cutting are handled by the device. + + Args: + temperature: sealing temperature in degrees C (25..200). + duration: sealing time in seconds (0.5..9.9). + idle_temperature: temperature to hold after sealing (25..200). + """ + logger.info("[Ultraseal PRO %s] sealing at %d C for %.1fs", self.io.port, temperature, duration) + ignore = UltrasealPROStatus.NotAtSealTemperature | UltrasealPROStatus.ParkMode + + await self.wait_for_idle(ignore) + await self.set_sealing_time(duration) + await self.set_temperature(temperature) + await self.wait_for_sealing_temperature() + if self._check(await self.send_command("S"), {"ok", "err"}, "S") != "ok": + raise KBiosystemsError(title="Seal command returned 'err'") + await self.wait_for_idle(ignore) + + await self.set_temperature(idle_temperature) diff --git a/pylabrobot/kbiosystems/ultraseal_xt_pro.py b/pylabrobot/kbiosystems/ultraseal_xt_pro.py index 352013a4d77..26500ff3abf 100644 --- a/pylabrobot/kbiosystems/ultraseal_xt_pro.py +++ b/pylabrobot/kbiosystems/ultraseal_xt_pro.py @@ -106,6 +106,10 @@ def __init__( ) async def setup(self) -> None: + logger.warning( + "KBiosystemsUltrasealXTPro has NOT been tested against hardware in PyLabRobot. " + "Please make a PR to remove this message if you have verified it on your hardware." + ) await self._open() await self.wait_for_idle( UltrasealXTProStatus.NoFoil