diff --git a/docs/api/pylabrobot.big_bear.rst b/docs/api/pylabrobot.big_bear.rst new file mode 100644 index 00000000000..adb6290553d --- /dev/null +++ b/docs/api/pylabrobot.big_bear.rst @@ -0,0 +1,14 @@ +.. currentmodule:: pylabrobot.big_bear + +pylabrobot.big_bear package +=========================== + +.. currentmodule:: pylabrobot.big_bear.orbital_shaker + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + BigBearOrbitalShaker + BigBearError diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index 5343d347fd4..209d11b6420 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -34,6 +34,7 @@ Manufacturers pylabrobot.agilent pylabrobot.azenta + pylabrobot.big_bear pylabrobot.bmg_labtech pylabrobot.brooks pylabrobot.byonoy diff --git a/docs/user_guide/big_bear/index.md b/docs/user_guide/big_bear/index.md new file mode 100644 index 00000000000..05ec2633e36 --- /dev/null +++ b/docs/user_guide/big_bear/index.md @@ -0,0 +1,7 @@ +# BigBear + +```{toctree} +:maxdepth: 1 + +orbital-shaker/hello-world +``` diff --git a/docs/user_guide/big_bear/orbital-shaker/hello-world.ipynb b/docs/user_guide/big_bear/orbital-shaker/hello-world.ipynb new file mode 100644 index 00000000000..c9052fa0ea0 --- /dev/null +++ b/docs/user_guide/big_bear/orbital-shaker/hello-world.ipynb @@ -0,0 +1,133 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "bigbear-intro", + "source": "# BigBear Orbital Shaker\n\nThe BigBear orbital shaker is a plate shaker with a serial (RS-232 / ASCII) interface. A single serial line can drive up to 16 shaker positions -- called **nests** -- wired together in a *daisy chain*, each addressed independently.\n\n| Property | Value |\n|---|---|\n| Communication | Serial, ASCII line protocol |\n| Serial settings | 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake |\n| Line terminator | carriage return (`\\r`) |\n| Speed range | 60--3570 rpm |\n| Acceleration | 1--10 (device scale) |\n| Nests per chain | 1--16 |\n\n```{warning}\nThis driver has NOT been tested against hardware in PyLabRobot. The protocol,\nline terminator, and reply formats are reconstructed from the vendor software.\nIf you verify it on a shaker, please open a PR to remove the not-tested warning.\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "bigbear-daisy-md", + "source": "## Daisy chaining\n\nMultiple shaker units share one serial connection: the host talks to the first unit, which is wired to the second, and so on down the chain. Each unit is a **nest** with a 1-based address.\n\nEvery command is addressed with an `@` prefix, where `` is the nest number written in **hexadecimal** -- so nest 10 is `@A` and nest 16 is `@10`. A command reaches only the nest it names; the others ignore it. This lets one port run up to 16 shakers with independent speed, direction, and start/stop.\n\nAt `setup()` the driver:\n\n1. sends `U` to put the chain into daisy-chain (addressing) mode,\n2. sends `~` to ask how many units are present, and\n3. checks that the reported count matches the `num_shakers` you passed.\n\nIf the counts disagree (a unit is off, unplugged, or `num_shakers` is wrong) setup raises a `BigBearError`. Set `num_shakers` to the number of physical units on the chain.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "bigbear-setup-md", + "source": "## Physical setup\n\nConnect the shaker's serial port to your computer, typically through a USB-to-serial adapter. Chain any additional units in series and give the whole chain a single port.\n\nMake sure the shaker's serial parameters match the driver defaults (9600 baud, 8 data bits, no parity, 1 stop bit, no handshake).", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "bigbear-connect-md", + "source": "## Connect\n\n`setup()` opens the serial port, enters daisy-chain mode, and verifies the shaker count. It also logs the not-tested warning. Pass `num_shakers` to match your chain.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bigbear-connect-code", + "source": "from pylabrobot.big_bear import BigBearOrbitalShaker\n\nshaker = BigBearOrbitalShaker(port=\"/dev/ttyUSB0\", num_shakers=1) # replace with your port\nawait shaker.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "bigbear-shake-md", + "source": "## Start shaking\n\n`start_shaking()` sets the acceleration, speed, and direction for a nest and starts it. Shaking continues until you stop it. `device_id` selects which nest (default 1).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bigbear-shake-code", + "source": "await shaker.start_shaking(\n rpm=750,\n acceleration=3,\n sequence=\"CW\",\n device_id=1,\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "bigbear-stop-md", + "source": "## Stop shaking\n\nStop a single nest with `stop_shaking()`, or every nest on the chain with `stop_all()`.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bigbear-stop-code", + "source": "await shaker.stop_shaking(device_id=1)\n# or, for the whole chain:\n# await shaker.stop_all()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "bigbear-multi-md", + "source": "## Driving multiple nests\n\nWith a multi-unit chain, address each nest by its `device_id`. Each runs independently.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bigbear-multi-code", + "source": "# Example for a 3-nest chain (create with num_shakers=3)\n# await shaker.start_shaking(rpm=500, device_id=1)\n# await shaker.start_shaking(rpm=900, sequence=\"CCW\", device_id=2)\n# await shaker.start_shaking(rpm=1200, device_id=3)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "bigbear-home-md", + "source": "## Homing\n\nHome a single nest with `find_home()`, or every nest with `home_all()`.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bigbear-home-code", + "source": "await shaker.find_home(device_id=1)\n# or, for the whole chain:\n# await shaker.home_all()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "bigbear-info-md", + "source": "## Device info\n\nRead a nest's serial-number fields (`Y`, `X`, `Z`). The reply format is device-defined; the raw fields are returned joined.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bigbear-info-code", + "source": "print(\"Serial number:\", await shaker.get_serial_number(device_id=1))", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "bigbear-teardown-md", + "source": "## Teardown\n\n`stop()` stops every nest and closes the serial connection.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bigbear-teardown-code", + "source": "await shaker.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index 2a66998a996..b4ecf19f47a 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -32,6 +32,7 @@ definitions agilent/index azenta/index +big_bear/index bmg_labtech/index brooks/index byonoy/index diff --git a/pylabrobot/big_bear/__init__.py b/pylabrobot/big_bear/__init__.py new file mode 100644 index 00000000000..d9424eab9e2 --- /dev/null +++ b/pylabrobot/big_bear/__init__.py @@ -0,0 +1 @@ +from .orbital_shaker import BigBearError, BigBearOrbitalShaker diff --git a/pylabrobot/big_bear/orbital_shaker.py b/pylabrobot/big_bear/orbital_shaker.py new file mode 100644 index 00000000000..7c4c870d4f2 --- /dev/null +++ b/pylabrobot/big_bear/orbital_shaker.py @@ -0,0 +1,268 @@ +import asyncio +import logging +from typing import Literal, Optional + +from pylabrobot.io.serial import Serial + +Sequence = Literal["CW", "CCW"] + +logger = logging.getLogger(__name__) + + +# Line terminator: commands and replies are carriage-return terminated. +CR = "\r" + +# Reply returned when a command is rejected or the shaker cannot be reached. +ERROR_REPLY = "?:" + +MIN_RPM = 60 +MAX_RPM = 3570 +MIN_ACCELERATION = 1 +MAX_ACCELERATION = 10 +MIN_SHAKERS = 1 +MAX_SHAKERS = 16 + + +class BigBearError(Exception): + """Exceptions raised by a BigBear orbital shaker.""" + + def __init__(self, title: str, message: Optional[str] = None) -> None: + self.title = title + self.message = message + + def __str__(self) -> str: + return f"{self.title}: {self.message}" if self.message else self.title + + +class BigBearOrbitalShaker: + """BigBear orbital shaker. + + A daisy-chained orbital shaker: a single serial line drives up to 16 shaker + positions ("nests"), each addressed independently by a 1-based id sent as a + hexadecimal digit. + + Serial settings: + 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake, "\\r" + terminator. + + Commands (each addressed command is "@" followed by the command chars, + where is the nest number in hex; a value-setting command is followed by + a "@?" read-back query): + U enter daisy-chain mode (unaddressed) + ~ report the number of shakers on the chain (unaddressed) + @V set speed (60..3570 rpm) + @A set acceleration (1..10, device scale) + @+ set direction clockwise + @- set direction counter-clockwise + @G start shaking + @S stop shaking + @F find home + @Q poll status (reply is read back) + @Y/X/Z serial-number fields + A rejected command or a comms failure replies "?:". + + Not verified: has NOT been tested against hardware in PyLabRobot. A warning is + emitted at setup. + """ + + def __init__( + self, + port: str, + num_shakers: int = 1, + timeout: float = 10.0, + command_delay: float = 0.1, + daisy_chain_settle: float = 5.0, + stop_settle: float = 6.0, + ): + if not MIN_SHAKERS <= num_shakers <= MAX_SHAKERS: + raise ValueError(f"num_shakers must be {MIN_SHAKERS}..{MAX_SHAKERS}") + self.num_shakers = num_shakers + self.command_delay = command_delay + self.daisy_chain_settle = daisy_chain_settle + self.stop_settle = stop_settle + self.io = Serial( + human_readable_device_name="BigBear Orbital Shaker", + port=port, + baudrate=9600, + bytesize=8, + parity="N", + stopbits=1, + timeout=timeout, + ) + + async def setup(self) -> None: + logger.warning( + "BigBearOrbitalShaker 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.io.setup() + await self._enter_daisy_chain() + await self._check_num_shakers() + logger.info("[BigBear %s] connected: num_shakers=%d", self.io.port, self.num_shakers) + + async def stop(self) -> None: + """Stop all shakers and close the serial connection.""" + try: + await self.stop_all() + finally: + await self.io.stop() + + # === Command layer === + + @staticmethod + def _address(device_id: int) -> str: + return "@" + format(device_id, "X") + + def _validate_device_id(self, device_id: int) -> None: + if not 1 <= device_id <= self.num_shakers: + raise ValueError(f"device_id must be 1..{self.num_shakers}") + + async def _send(self, command: str) -> None: + """Write a command and its terminator, then pace the bus.""" + await self.io.write((command + CR).encode("ascii")) + logger.debug("[BigBear] send: %s", command) + await asyncio.sleep(self.command_delay) + + async def _read_reply(self, timeout: Optional[float] = None) -> str: + """Read one CR-terminated reply, skipping empty lines. + + Returns the trimmed reply, or "" if the device sends nothing within the + serial read timeout. + """ + original = self.io.get_read_timeout() + if timeout is not None: + self.io.set_read_timeout(timeout) + try: + buf = bytearray() + while True: + char = await self.io.read(1) + if char == b"": # read timed out + break + if char == b"\r": + if buf: + break + continue # skip a bare terminator / empty line + if char == b"\n": + continue + buf += char + finally: + if timeout is not None: + self.io.set_read_timeout(original) + reply = buf.decode("ascii", errors="replace").strip() + logger.debug("[BigBear] recv: %s", reply) + return reply + + async def _poll(self, device_id: int, read_timeout: Optional[float] = None) -> str: + """Issue the status poll ("Q") for a nest and return its reply.""" + await self._send(self._address(device_id) + "Q") + return await self._read_reply(timeout=read_timeout) + + # === Parameters === + + async def _set_speed(self, device_id: int, rpm: int) -> None: + if not MIN_RPM <= rpm <= MAX_RPM: + raise ValueError(f"rpm must be {MIN_RPM}..{MAX_RPM}") + address = self._address(device_id) + await self._send(f"{address}V{rpm}") + await self._send(f"{address}?V{rpm}") + await self._read_reply() + + async def _set_acceleration(self, device_id: int, acceleration: int) -> None: + if not MIN_ACCELERATION <= acceleration <= MAX_ACCELERATION: + raise ValueError(f"acceleration must be {MIN_ACCELERATION}..{MAX_ACCELERATION}") + address = self._address(device_id) + await self._send(f"{address}A{acceleration}") + await self._send(f"{address}?A{acceleration}") + await self._read_reply() + + async def _set_sequence(self, device_id: int, sequence: Sequence) -> None: + if sequence not in ("CW", "CCW"): + raise ValueError('sequence must be "CW" or "CCW"') + address = self._address(device_id) + direction = "+" if sequence == "CW" else "-" + await self._send(f"{address}{direction}") + await self._send(f"{address}?{direction}") + await self._read_reply() + + # === Public API === + + async def start_shaking( + self, + rpm: int, + acceleration: int = 1, + sequence: Sequence = "CW", + device_id: int = 1, + ) -> None: + """Set the parameters for a nest and start it shaking. + + Args: + rpm: shaking speed (60..3570). + acceleration: acceleration on the device's 1..10 scale. + sequence: rotation direction, "CW" (clockwise) or "CCW" (counter-clockwise). + device_id: nest to address (1..num_shakers). + """ + self._validate_device_id(device_id) + await self._set_acceleration(device_id, acceleration) + await self._set_speed(device_id, rpm) + await self._set_sequence(device_id, sequence) + await self._send(self._address(device_id) + "G") + if await self._poll(device_id) == ERROR_REPLY: + raise BigBearError(title="Starting the shaker failed", message=f"nest {device_id}") + logger.info("[BigBear %s] nest %d shaking at %d rpm", self.io.port, device_id, rpm) + + async def stop_shaking(self, device_id: int = 1) -> None: + """Stop shaking on a single nest.""" + self._validate_device_id(device_id) + await self._send(self._address(device_id) + "S") + if await self._poll(device_id, read_timeout=self.stop_settle) == ERROR_REPLY: + raise BigBearError(title="Stopping the shaker failed", message=f"nest {device_id}") + logger.info("[BigBear %s] nest %d stopped", self.io.port, device_id) + + async def stop_all(self) -> None: + """Stop shaking on every nest on the chain.""" + for device_id in range(1, self.num_shakers + 1): + await self._send(self._address(device_id) + "S") + await self._poll(device_id, read_timeout=self.stop_settle) + + async def find_home(self, device_id: int = 1) -> None: + """Home a single nest.""" + self._validate_device_id(device_id) + await self._send(self._address(device_id) + "F") + await self._poll(device_id) + logger.info("[BigBear %s] nest %d homed", self.io.port, device_id) + + async def home_all(self) -> None: + """Home every nest on the chain.""" + for device_id in range(1, self.num_shakers + 1): + await self._send(self._address(device_id) + "F") + await self._poll(device_id) + + async def get_serial_number(self, device_id: int = 1) -> str: + """Read the serial-number fields (Y, X, Z) of a nest and join them. + + The reply format is device-defined; the raw fields are returned as-is. + """ + self._validate_device_id(device_id) + address = self._address(device_id) + fields = [] + for command in ("Y", "X", "Z"): + await self._send(address + command) + fields.append(await self._read_reply()) + return "".join(fields) + + # === Setup helpers === + + async def _enter_daisy_chain(self) -> None: + await self._send("U") + await asyncio.sleep(self.daisy_chain_settle) + await self._read_reply() + + async def _check_num_shakers(self) -> None: + await self._send("U") + await self._send("~") + reply = await self._read_reply() + if str(self.num_shakers) not in reply: + raise BigBearError( + title="Shaker count mismatch", + message=f"expected {self.num_shakers}, device reported {reply!r}", + )