From 8bd225c147d08b638b0759c39471f816bac71448 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 4 Aug 2026 13:19:34 -0700 Subject: [PATCH 1/2] feat(cole_parmer): add GenoGrinder plate shaker driver The GenoGrinder clamps a microplate between two platens and shakes it vertically for a fixed duration at a fixed speed. Adds the serial driver, tests, an API page for the cole_parmer package, and a hello-world guide. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/pylabrobot.cole_parmer.rst | 23 ++ docs/api/pylabrobot.rst | 1 + .../cole_parmer/genogrinder/hello-world.ipynb | 234 ++++++++++++++ docs/user_guide/cole_parmer/index.md | 7 + docs/user_guide/index.md | 1 + pylabrobot/cole_parmer/__init__.py | 1 + pylabrobot/cole_parmer/genogrinder.py | 285 ++++++++++++++++++ pylabrobot/cole_parmer/genogrinder_tests.py | 143 +++++++++ 8 files changed, 695 insertions(+) create mode 100644 docs/api/pylabrobot.cole_parmer.rst create mode 100644 docs/user_guide/cole_parmer/genogrinder/hello-world.ipynb create mode 100644 docs/user_guide/cole_parmer/index.md create mode 100644 pylabrobot/cole_parmer/genogrinder.py create mode 100644 pylabrobot/cole_parmer/genogrinder_tests.py diff --git a/docs/api/pylabrobot.cole_parmer.rst b/docs/api/pylabrobot.cole_parmer.rst new file mode 100644 index 00000000000..e7785ae097e --- /dev/null +++ b/docs/api/pylabrobot.cole_parmer.rst @@ -0,0 +1,23 @@ +.. currentmodule:: pylabrobot.cole_parmer + +pylabrobot.cole_parmer package +============================= + +.. currentmodule:: pylabrobot.cole_parmer.genogrinder + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + GenoGrinder + GenoGrinderError + +.. currentmodule:: pylabrobot.cole_parmer.masterflex + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Masterflex diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index 8e190c699fa..206ff03dd1e 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -24,6 +24,7 @@ Manufacturers pylabrobot.big_bear pylabrobot.brooks pylabrobot.byonoy + pylabrobot.cole_parmer pylabrobot.curiox pylabrobot.hamilton pylabrobot.high_res diff --git a/docs/user_guide/cole_parmer/genogrinder/hello-world.ipynb b/docs/user_guide/cole_parmer/genogrinder/hello-world.ipynb new file mode 100644 index 00000000000..3a052de2aa2 --- /dev/null +++ b/docs/user_guide/cole_parmer/genogrinder/hello-world.ipynb @@ -0,0 +1,234 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "gg-title", + "metadata": {}, + "source": "# Cole-Parmer GenoGrinder (fka SPEX GenoGrinder)\n\nThe GenoGrinder is a high-throughput plate shaker and homogenizer. It clamps a microplate (or a stack of plates, or a set of vials in a plate-format holder) between two platens and shakes it vertically for a fixed time at a fixed speed, grinding or mixing the contents of every well at once.\n\nProduct page: [Cole-Parmer SamplePrep HG-600-230 Geno/Grinder 2010](https://www.coleparmer.com/i/cole-parmer-sampleprep-hg-600-230-geno-grinder-2010-tissue-homogenizer-and-cell-lyser-230-vac-50-hz/0457684)\n\n| Property | Value |\n|---|---|\n| Communication | Serial, ASCII line protocol |\n| Serial settings | 9600 baud, 8 data bits, no parity, 1 stop bit |\n| Line terminator | carriage return (`\\r`) |\n| Duration range | 1--999 s |\n| Speed range | 1--9999 rpm |\n| Clamp | motorized (open/close) or fixed, depending on model |\n\n```{warning}\nThis driver has NOT been tested against hardware in PyLabRobot. `setup()` logs a\nwarning to that effect. If you verify it on your machine, please open a PR to\nremove the warning.\n```" + }, + { + "cell_type": "markdown", + "id": "gg-physical", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "Connect the GenoGrinder's serial port to your computer, typically through a USB-to-serial adapter, and note the port name (`/dev/ttyUSB*` on Linux, `/dev/tty.usbserial-*` on macOS, `COM*` on Windows).\n", + "\n", + "Make sure the instrument's serial parameters match the driver defaults (9600 baud, 8 data bits, no parity, 1 stop bit), and that the safety lid is closed -- the machine will not run with it open." + ] + }, + { + "cell_type": "markdown", + "id": "gg-connect-md", + "metadata": {}, + "source": "## Connect\n\n`setup()` opens the serial port and runs the instrument's power-on routine, which homes the mechanism and readies the clamp. Its reply doubles as a communication check, so a successful `setup()` means the machine is talking and ready to run.\n\nSome GenoGrinder models have a fixed clamp that you tighten by hand instead of a motorized one. On those, pass `use_clamp_commands=False`: `open_clamp()` and `close_clamp()` then do nothing, so the same protocol code runs on either machine." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "gg-connect", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.cole_parmer import GenoGrinder\n", + "\n", + "grinder = GenoGrinder(port=\"/dev/ttyUSB0\") # replace with your port\n", + "await grinder.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "gg-clampstate-md", + "metadata": {}, + "source": "## Check the clamp\n\n`request_clamp_state()` reports whether the clamp is `\"open\"`, `\"closed\"`, or `\"unknown\"`." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "gg-clampstate", + "metadata": {}, + "outputs": [], + "source": "print(\"Clamp:\", await grinder.request_clamp_state())" + }, + { + "cell_type": "markdown", + "id": "gg-open-md", + "metadata": {}, + "source": [ + "## Open the clamp\n", + "\n", + "`open_clamp()` reads the current position and only moves if the clamp is not already open, so it is safe to call repeatedly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "gg-open", + "metadata": {}, + "outputs": [], + "source": [ + "await grinder.open_clamp()" + ] + }, + { + "cell_type": "markdown", + "id": "gg-load-md", + "metadata": {}, + "source": [ + "## Load a plate\n", + "\n", + "Place your plate (or plate stack) in the clamp now, centered on the platen, and close the safety lid." + ] + }, + { + "cell_type": "markdown", + "id": "gg-close-md", + "metadata": {}, + "source": [ + "## Close the clamp\n", + "\n", + "`close_clamp()` clamps the plate down. Like `open_clamp()`, it checks the current position first." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "gg-close", + "metadata": {}, + "outputs": [], + "source": [ + "await grinder.close_clamp()" + ] + }, + { + "cell_type": "markdown", + "id": "gg-shake-md", + "metadata": {}, + "source": [ + "## Shake\n", + "\n", + "`shake()` uploads the run parameters, starts the run, then polls the instrument until it reports the run is complete -- so the call returns when the plate has actually finished shaking.\n", + "\n", + "`duration` is in seconds (1--999) and `speed` in rpm (1--9999). Start gentle and work up: hard grinding at high rpm can crack plates and unseat seals." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "gg-shake", + "metadata": {}, + "outputs": [], + "source": [ + "await grinder.shake(duration=30, speed=1500)" + ] + }, + { + "cell_type": "markdown", + "id": "gg-status-md", + "metadata": {}, + "source": "## Read the status\n\n`request_status()` returns the instrument's raw status line -- `Standby` when idle, or one of the transient run states (`Running Sample`, `Locking`, `Mixing`, `Unlocking`, `Run Complete`) during a run. Useful when driving the machine from another task while a run is going." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "gg-status", + "metadata": {}, + "outputs": [], + "source": "print(\"Status:\", await grinder.request_status())" + }, + { + "cell_type": "markdown", + "id": "gg-stopshaking-md", + "metadata": {}, + "source": [ + "## Abort a run\n", + "\n", + "`stop_shaking()` stops a run in progress. Because `shake()` blocks until the run finishes, call this from a separate task (or after interrupting the cell) when you need to cut a run short." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "gg-stopshaking", + "metadata": {}, + "outputs": [], + "source": [ + "await grinder.stop_shaking()" + ] + }, + { + "cell_type": "markdown", + "id": "gg-home-md", + "metadata": {}, + "source": [ + "## Home the clamp\n", + "\n", + "`home_clamp()` returns the clamp to its reference position. Use it to recover a known state after an aborted run." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "gg-home", + "metadata": {}, + "outputs": [], + "source": [ + "await grinder.home_clamp()" + ] + }, + { + "cell_type": "markdown", + "id": "gg-unload-md", + "metadata": {}, + "source": [ + "## Unload the plate\n", + "\n", + "Open the clamp again and take the plate out." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "gg-unload", + "metadata": {}, + "outputs": [], + "source": [ + "await grinder.open_clamp()" + ] + }, + { + "cell_type": "markdown", + "id": "gg-teardown-md", + "metadata": {}, + "source": [ + "## Teardown\n", + "\n", + "`stop()` halts any run still in progress and closes the serial connection." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "gg-teardown", + "metadata": {}, + "outputs": [], + "source": [ + "await grinder.stop()" + ] + } + ], + "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/cole_parmer/index.md b/docs/user_guide/cole_parmer/index.md new file mode 100644 index 00000000000..6195ea4acf4 --- /dev/null +++ b/docs/user_guide/cole_parmer/index.md @@ -0,0 +1,7 @@ +# Cole-Parmer + +```{toctree} +:maxdepth: 1 + +genogrinder/hello-world +``` diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index ac1ec0aec45..c058aa8dca0 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -32,6 +32,7 @@ azenta/index big_bear/index brooks/index byonoy/index +cole_parmer/index curiox/index hamilton/index high_res/index diff --git a/pylabrobot/cole_parmer/__init__.py b/pylabrobot/cole_parmer/__init__.py index 6d1cc25e954..545888efdaf 100644 --- a/pylabrobot/cole_parmer/__init__.py +++ b/pylabrobot/cole_parmer/__init__.py @@ -1 +1,2 @@ +from .genogrinder import GenoGrinder, GenoGrinderError from .masterflex import Masterflex diff --git a/pylabrobot/cole_parmer/genogrinder.py b/pylabrobot/cole_parmer/genogrinder.py new file mode 100644 index 00000000000..132f5f3c3b6 --- /dev/null +++ b/pylabrobot/cole_parmer/genogrinder.py @@ -0,0 +1,285 @@ +import asyncio +import logging +import time +from typing import Literal, Optional, Sequence, Union + +from pylabrobot.io.serial import Serial + +logger = logging.getLogger(__name__) + + +# Commands and replies are carriage-return terminated. +CR = "\r" + +# The wire format packs the duration into three digits and the speed into four, +# so both are bounded by their field widths. +MAX_DURATION_SECONDS = 999 +MAX_SPEED_RPM = 9999 + +DEFAULT_SPEED_RPM = 1750 +DEFAULT_DURATION_SECONDS = 30 + +# Reply substrings the device sends to acknowledge each command. +_INITIALIZED = ("Initialize Complete", "Initialized") +_HOME_COMPLETE = "Home Complete" +_CLAMP_OPEN = "Clamp Open" +_CLAMP_CLOSED = "Clamp Closed" +_PARAMETERS_SET = "Parameters Set" +_RUNNING = "Running Sample" +_RUN_COMPLETE = "Run Complete" +_STANDBY = "Standby" +# Transient states reported while a run is in progress. +_MIXING_STATES = ("Running Sample", "Locking", "Mixing", "Unlocking") + +ClampState = Literal["open", "closed", "unknown"] + + +class GenoGrinderError(Exception): + """Exception raised by a GenoGrinder.""" + + 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 GenoGrinder: + """Cole-Parmer SPEX GenoGrinder plate shaker / homogenizer. + + A microplate clamp shaker: it locks a plate (or plate stack) into a clamp and + mixes it for a fixed duration at a fixed speed. + + Product page: + - https://www.coleparmer.com/i/cole-parmer-sampleprep-hg-600-230-geno-grinder-2010- + tissue-homogenizer-and-cell-lyser-230-vac-50-hz/0457684 + + Serial settings: + 9600 baud, 8 data bits, no parity, 1 stop bit, "\\r" terminator. + + Commands are ``*NN*`` frames; each is acknowledged with a reply whose text + identifies the reached state: + *01* status poll (Standby / Running Sample / Locking / Mixing / + Unlocking / Run Complete) + *02,,* set run parameters (duration seconds, speed rpm) + *03* initialize + *04* start the run + *05* clear error + *06* stop the run + *10* home the clamp + *11* / *12* open / close the clamp + *15* clamp status + A command that does not reach its expected state clears the error (*05*) and + raises ``GenoGrinderError``. + + Not verified: this driver has NOT been tested against hardware in PyLabRobot. + A warning is emitted at setup. + """ + + def __init__( + self, + port: str, + use_clamp_commands: bool = True, + timeout: float = 50.0, + command_settle: float = 0.5, + status_poll_interval: float = 1.0, + mix_timeout_margin: float = 60.0, + ): + """ + Args: + port: serial port the GenoGrinder is on. + use_clamp_commands: whether the clamp is motorized and accepts open/close + commands. When ``False``, :meth:`open_clamp` / :meth:`close_clamp` are + no-ops (the plate is clamped by fixed hardware). + timeout: serial read timeout, in seconds. + command_settle: pause after starting a run before polling, in seconds. + status_poll_interval: delay between status polls while waiting, in seconds. + mix_timeout_margin: grace added to the run duration before a wait for the + run to complete is considered timed out, in seconds. + """ + self.use_clamp_commands = use_clamp_commands + self.command_settle = command_settle + self.status_poll_interval = status_poll_interval + self.mix_timeout_margin = mix_timeout_margin + self.io = Serial( + human_readable_device_name="Cole-Parmer SPEX GenoGrinder", + port=port, + baudrate=9600, + bytesize=8, + parity="N", + stopbits=1, + timeout=timeout, + ) + + async def setup(self) -> None: + logger.warning( + "GenoGrinder 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._initialize() + logger.info("[GenoGrinder %s] connected", self.io.port) + + async def stop(self) -> None: + """Stop any run in progress and close the serial connection.""" + try: + await self.io.write((("*06*") + CR).encode("ascii")) + finally: + await self.io.stop() + + # === Command layer === + + async def _read_line(self, timeout: Optional[float] = None) -> str: + """Read one CR-terminated reply, skipping empty lines. + + Returns the trimmed reply, or "" if nothing arrives within the read timeout. + """ + + async def _read() -> str: + 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 # bare terminator between messages + if char == b"\n": + continue + buf += char + return buf.decode("ascii", errors="replace").strip() + + if timeout is None: + reply = await _read() + else: + with self.io.temporary_timeout(timeout): + reply = await _read() + logger.debug("[GenoGrinder] recv: %s", reply) + return reply + + async def _command( + self, command: str, double_read: bool = False, timeout: Optional[float] = None + ) -> str: + """Send a command frame and return its reply. + + Initialization emits a progress line followed by the completion line; set + ``double_read`` to return the second line. + """ + await self.io.reset_input_buffer() + await self.io.write((command + CR).encode("ascii")) + logger.debug("[GenoGrinder] send: %s", command) + reply = await self._read_line(timeout=timeout) + if double_read: + reply = await self._read_line(timeout=timeout) + return reply + + async def _expect(self, reply: str, tokens: Union[str, Sequence[str]], title: str) -> None: + """Raise if ``reply`` contains none of ``tokens``, clearing the error first.""" + if isinstance(tokens, str): + tokens = (tokens,) + if not any(token in reply for token in tokens): + await self._command("*05*") + raise GenoGrinderError(title=title, message=f"received {reply!r}") + + async def _initialize(self) -> None: + """Run the instrument's power-on routine: home the mechanism, ready the clamp. + + The reply doubles as a communication check. + """ + reply = await self._command("*03*", double_read=True) + await self._expect(reply, _INITIALIZED, "Instrument did not initialize") + logger.info("[GenoGrinder %s] initialized", self.io.port) + + # === Public API === + + async def home_clamp(self) -> None: + """Home the clamp.""" + reply = await self._command("*10*") + await self._expect(reply, _HOME_COMPLETE, "Clamp did not home") + + async def request_clamp_state(self) -> ClampState: + """Query the clamp position.""" + reply = await self._command("*15*") + if _CLAMP_OPEN in reply: + return "open" + if _CLAMP_CLOSED in reply: + return "closed" + return "unknown" + + async def open_clamp(self) -> None: + """Open the clamp. No-op if it is already open or clamp control is disabled.""" + if not self.use_clamp_commands: + return + if await self.request_clamp_state() == "open": + return + reply = await self._command("*11*") + await self._expect(reply, _CLAMP_OPEN, "Clamp did not open") + + async def close_clamp(self) -> None: + """Close the clamp. No-op if it is already closed or clamp control is disabled.""" + if not self.use_clamp_commands: + return + if await self.request_clamp_state() == "closed": + return + reply = await self._command("*12*") + await self._expect(reply, _CLAMP_CLOSED, "Clamp did not close") + + async def request_status(self) -> str: + """Return the raw status reply (*01*).""" + return await self._command("*01*") + + async def shake( + self, + duration: int = DEFAULT_DURATION_SECONDS, + speed: int = DEFAULT_SPEED_RPM, + ) -> None: + """Run a mix and block until it completes. + + Sets the run parameters, starts the run, then polls status until the device + returns to standby / run-complete. + + Args: + duration: mix time in seconds (1..999). + speed: mix speed in rpm (1..9999). + """ + if not 1 <= duration <= MAX_DURATION_SECONDS: + raise ValueError(f"duration must be 1..{MAX_DURATION_SECONDS} seconds") + if not 1 <= speed <= MAX_SPEED_RPM: + raise ValueError(f"speed must be 1..{MAX_SPEED_RPM} rpm") + + reply = await self._command(f"*02,{duration:03d},{speed:04d}*") + await self._expect(reply, _PARAMETERS_SET, "Instrument did not set parameters") + + await asyncio.sleep(self.command_settle) + reply = await self._command("*04*") + await self._expect(reply, _RUNNING, "Instrument did not start the run") + + await asyncio.sleep(self.command_settle) + await self._wait_for_run_complete(duration) + logger.info("[GenoGrinder %s] mixed %ds at %drpm", self.io.port, duration, speed) + + async def stop_shaking(self) -> None: + """Stop a run in progress.""" + await self._command("*06*") + + # === Wait helpers === + + async def _wait_for_run_complete(self, duration: int) -> None: + deadline = time.monotonic() + duration + self.mix_timeout_margin + while True: + reply = await self._command("*01*") + if _STANDBY in reply or _RUN_COMPLETE in reply: + return + if not any(state in reply for state in _MIXING_STATES): + raise GenoGrinderError( + title="Invalid mixing state; check the device and retry", + message=f"received {reply!r}", + ) + if time.monotonic() > deadline: + raise GenoGrinderError( + title="Timed out waiting for the run to complete", + message=f"duration {duration}s + {self.mix_timeout_margin:.0f}s margin", + ) + await asyncio.sleep(self.status_poll_interval) diff --git a/pylabrobot/cole_parmer/genogrinder_tests.py b/pylabrobot/cole_parmer/genogrinder_tests.py new file mode 100644 index 00000000000..8639f5a80ee --- /dev/null +++ b/pylabrobot/cole_parmer/genogrinder_tests.py @@ -0,0 +1,143 @@ +import unittest +from typing import List, cast +from unittest.mock import AsyncMock, patch + +from pylabrobot.cole_parmer.genogrinder import GenoGrinder, GenoGrinderError +from pylabrobot.io.serial import Serial + + +def make_device(replies: List[str], **kwargs) -> GenoGrinder: + """Build a device whose ``io`` is an AsyncMock replaying one reply per write. + + Writes are recorded by the mock itself, so assert against ``device.io.write`` + (``assert_any_await``, ``await_count``, ``call_args_list``). + """ + io = AsyncMock(spec=Serial) + io.port = "FAKE" + + rx = bytearray() + pending = list(replies) + + async def write(data: bytes) -> None: + if pending: + rx.extend((pending.pop(0) + "\r").encode("ascii")) + + async def read(num_bytes: int = 1) -> bytes: + out = bytes(rx[:num_bytes]) + del rx[:num_bytes] + return out + + io.write.side_effect = write + io.read.side_effect = read + + with patch("pylabrobot.cole_parmer.genogrinder.Serial", return_value=io): + device = GenoGrinder( + port="FAKE", + command_settle=0, + status_poll_interval=0, + **kwargs, + ) + return device + + +def writes(device: GenoGrinder) -> AsyncMock: + """The mock standing in for ``device.io.write``.""" + return cast(AsyncMock, device.io.write) + + +def commands(device: GenoGrinder) -> List[str]: + """Every command frame written, in order, without its terminator.""" + return [call.args[0].decode("ascii").rstrip("\r") for call in writes(device).call_args_list] + + +class GenoGrinderProtocolTests(unittest.IsolatedAsyncioTestCase): + async def test_setup_initializes_past_the_progress_line(self): + device = make_device(["Initializing\rInitialize Complete"]) + await device.setup() + self.assertEqual(commands(device), ["*03*"]) + + async def test_setup_initialization_failure_clears_the_error(self): + device = make_device(["Fault\rFault"]) + with self.assertRaises(GenoGrinderError): + await device.setup() + self.assertEqual(commands(device), ["*03*", "*05*"]) + + async def test_clamp_state(self): + device = make_device(["Clamp Open"]) + self.assertEqual(await device.request_clamp_state(), "open") + + device = make_device(["Clamp Closed"]) + self.assertEqual(await device.request_clamp_state(), "closed") + + device = make_device(["Standby"]) + self.assertEqual(await device.request_clamp_state(), "unknown") + + async def test_open_clamp_is_a_no_op_when_already_open(self): + device = make_device(["Clamp Open"]) + await device.open_clamp() + self.assertEqual(commands(device), ["*15*"]) + + async def test_open_clamp_moves_when_closed(self): + device = make_device(["Clamp Closed", "Clamp Open"]) + await device.open_clamp() + self.assertEqual(commands(device), ["*15*", "*11*"]) + + async def test_close_clamp_moves_when_open(self): + device = make_device(["Clamp Open", "Clamp Closed"]) + await device.close_clamp() + self.assertEqual(commands(device), ["*15*", "*12*"]) + + async def test_clamp_commands_suppressed_on_fixed_clamps(self): + device = make_device([], use_clamp_commands=False) + await device.open_clamp() + await device.close_clamp() + self.assertEqual(commands(device), []) + + async def test_home_clamp(self): + device = make_device(["Home Complete"]) + await device.home_clamp() + self.assertEqual(commands(device), ["*10*"]) + + async def test_shake_sets_parameters_then_runs_to_completion(self): + device = make_device( + [ + "Parameters Set", + "Running Sample", + "Mixing", + "Unlocking", + "Run Complete", + ] + ) + await device.shake(duration=45, speed=1500) + self.assertEqual( + commands(device), + ["*02,045,1500*", "*04*", "*01*", "*01*", "*01*"], + ) + + async def test_shake_rejects_out_of_range_arguments(self): + device = make_device([]) + with self.assertRaises(ValueError): + await device.shake(duration=1000) + with self.assertRaises(ValueError): + await device.shake(speed=10000) + self.assertEqual(commands(device), []) + + async def test_shake_raises_on_an_unexpected_state(self): + device = make_device(["Parameters Set", "Running Sample", "Fault"]) + with self.assertRaises(GenoGrinderError): + await device.shake(duration=5) + + async def test_shake_times_out(self): + device = make_device(["Parameters Set", "Running Sample"] + ["Mixing"] * 10) + device.mix_timeout_margin = -1 + with self.assertRaises(GenoGrinderError): + await device.shake(duration=1) + + async def test_stop_shaking(self): + device = make_device(["Standby"]) + await device.stop_shaking() + self.assertEqual(commands(device), ["*06*"]) + + +if __name__ == "__main__": + unittest.main() From 42515a597eabe62b9f890a6aa5dc57d731f28e5a Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 4 Aug 2026 13:28:49 -0700 Subject: [PATCH 2/2] fix(cole_parmer): correct docs markup in the GenoGrinder driver Pad the API page title underline, make the command table a literal block, and keep the product URL on one line so it stays a valid link. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/pylabrobot.cole_parmer.rst | 2 +- pylabrobot/cole_parmer/genogrinder.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/api/pylabrobot.cole_parmer.rst b/docs/api/pylabrobot.cole_parmer.rst index e7785ae097e..82bba033a14 100644 --- a/docs/api/pylabrobot.cole_parmer.rst +++ b/docs/api/pylabrobot.cole_parmer.rst @@ -1,7 +1,7 @@ .. currentmodule:: pylabrobot.cole_parmer pylabrobot.cole_parmer package -============================= +============================== .. currentmodule:: pylabrobot.cole_parmer.genogrinder diff --git a/pylabrobot/cole_parmer/genogrinder.py b/pylabrobot/cole_parmer/genogrinder.py index 132f5f3c3b6..05ba83cdcdf 100644 --- a/pylabrobot/cole_parmer/genogrinder.py +++ b/pylabrobot/cole_parmer/genogrinder.py @@ -52,14 +52,14 @@ class GenoGrinder: mixes it for a fixed duration at a fixed speed. Product page: - - https://www.coleparmer.com/i/cole-parmer-sampleprep-hg-600-230-geno-grinder-2010- - tissue-homogenizer-and-cell-lyser-230-vac-50-hz/0457684 + https://www.coleparmer.com/i/cole-parmer-sampleprep-hg-600-230-geno-grinder-2010-tissue-homogenizer-and-cell-lyser-230-vac-50-hz/0457684 Serial settings: 9600 baud, 8 data bits, no parity, 1 stop bit, "\\r" terminator. Commands are ``*NN*`` frames; each is acknowledged with a reply whose text - identifies the reached state: + identifies the reached state:: + *01* status poll (Standby / Running Sample / Locking / Mixing / Unlocking / Run Complete) *02,,* set run parameters (duration seconds, speed rpm) @@ -70,6 +70,7 @@ class GenoGrinder: *10* home the clamp *11* / *12* open / close the clamp *15* clamp status + A command that does not reach its expected state clears the error (*05*) and raises ``GenoGrinderError``.