From 50f2488020814109bfa644e52e4d926e5d98f557 Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 18 May 2026 21:50:52 -0700 Subject: [PATCH 1/7] Add HighRes Biosolutions MicroSpin centrifuge backend Adds a TCP/IP backend (`MicroSpinBackend`) for the HighRes Biosolutions MicroSpin automated microplate centrifuge, plus an in-process mock TCP server that emulates the device's protocol for hardware-free development and testing. Backend (pylabrobot.centrifuge.highres.MicroSpinBackend): - Speaks the ASCII command/response protocol over TCP/1000 (port configurable; matches the device's web-UI SERVER_PORT setting). - Reverse-engineered from the MicroSpin User Manual (HighRes doc 1058675 Rev C, sec 6.6) and from the firmware's own `list all` / `help` / `settings` introspection commands. - Maps the abstract CentrifugeBackend API to the right wire commands; the four maintenance-only lock primitives (lockdoor, unlockdoor, locknest, unlocknest) raise NotImplementedError per the contributor guide -- the higher-level `open ` and `spin` commands handle locking internally. - MicroSpin-specific helpers: home(), is_homed(), abort(), clear_button_abort(), get_status(), get_version(), get_errors(), wait_for_spindle_stopped(), and reset(). - reset() issues abort -> clearbuttonabort -> status. The third step is the real gate: abort and clearbuttonabort return OK! immediately on the wire, but status is queued behind any active motion and only answers once the rotor is genuinely stopped. - Spin parameters are validated and the 0-1 acceleration/deceleration fractions are converted to the integer 0-100 percentages the firmware expects. - Calling spin() with g < 30 emits a UserWarning -- empirically the spindle-stopped sensor sometimes fails to latch at very low G, causing every subsequent command to time out. In-process mock server (pylabrobot.centrifuge.highres.mock_server): - MicroSpinMockServer: a localhost asyncio TCP server speaking the same wire protocol. Usable as an async context manager from Python or as a script (`python -m pylabrobot.centrifuge.highres.mock_server`) for hand-driving via netcat. - Implements the firmware's "status blocks until the spindle has truly stopped" semantics, which is what reset() exploits. - simulate_low_g_hang flag reproduces the firmware quirk described above in tests. Refactor: per-vendor folder layout Centrifuge module now mirrors the pylabrobot.plate_reading layout: - pylabrobot.centrifuge.agilent (VSpin + Access2) - pylabrobot.centrifuge.highres (MicroSpin) Existing imports from pylabrobot.centrifuge.vspin_backend and .access2 continue to work via deprecation shims that warn and re-export from the new locations. Other fixes: - Imported unittest.mock in centrifuge_tests.py (pre-existing bug that prevented the test class from running). Tests (60 new, all passing in ~3s): - microspin_tests.py: 23 stub-based tests for protocol edge cases, argument validation, reset's error-handling branches, and timeout extension logic. - mock_server_tests.py: 24 real-TCP integration tests exercising every backend method against the mock, including the status-blocks-during-motion gate and the low-G hang simulation. - Full project test suite still passes (1643 tests). Docs: - New user-guide notebook docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb with pre-spin checklist and a section on developing without hardware via the mock server. - API ref docs/api/pylabrobot.centrifuge.rst updated for the new layout (vendors grouped under their module paths). Safety: This integration was developed against a physical HighRes MicroSpin during reverse-engineering, but the spin command itself was NEVER executed by the integration code -- all wire-level behaviour was exercised against the mock server only. Users running this against their own MicroSpin should follow the manual's commissioning checklist (secs 5.3, 7, 8) before issuing their first spin. --- CHANGELOG.md | 15 + docs/api/pylabrobot.centrifuge.rst | 24 +- .../centrifuge/_centrifuge.md | 4 +- .../centrifuge/highres_microspin.ipynb | 420 +++++++++++ pylabrobot/centrifuge/__init__.py | 30 +- pylabrobot/centrifuge/access2.py | 30 +- pylabrobot/centrifuge/agilent/__init__.py | 8 + pylabrobot/centrifuge/agilent/access2.py | 26 + .../centrifuge/agilent/vspin_backend.py | 649 +++++++++++++++++ pylabrobot/centrifuge/centrifuge_tests.py | 1 + pylabrobot/centrifuge/highres/__init__.py | 13 + pylabrobot/centrifuge/highres/microspin.py | 68 ++ .../centrifuge/highres/microspin_backend.py | 550 +++++++++++++++ .../centrifuge/highres/microspin_tests.py | 344 +++++++++ pylabrobot/centrifuge/highres/mock_server.py | 532 ++++++++++++++ .../centrifuge/highres/mock_server_tests.py | 293 ++++++++ pylabrobot/centrifuge/vspin_backend.py | 650 +----------------- 17 files changed, 2984 insertions(+), 673 deletions(-) create mode 100644 docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb create mode 100644 pylabrobot/centrifuge/agilent/__init__.py create mode 100644 pylabrobot/centrifuge/agilent/access2.py create mode 100644 pylabrobot/centrifuge/agilent/vspin_backend.py create mode 100644 pylabrobot/centrifuge/highres/__init__.py create mode 100644 pylabrobot/centrifuge/highres/microspin.py create mode 100644 pylabrobot/centrifuge/highres/microspin_backend.py create mode 100644 pylabrobot/centrifuge/highres/microspin_tests.py create mode 100644 pylabrobot/centrifuge/highres/mock_server.py create mode 100644 pylabrobot/centrifuge/highres/mock_server_tests.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 220209251b6..daeaae3b7d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## Unreleased +### Added + +- HighRes Biosolutions MicroSpin centrifuge backend (`pylabrobot.centrifuge.highres.MicroSpinBackend`) speaking the device's ASCII command/response protocol over TCP/1000, plus a `MicroSpin(...)` factory. +- In-process `MicroSpinMockServer` (`pylabrobot.centrifuge.highres.mock_server`) that faithfully emulates the MicroSpin's wire protocol -- including the firmware's "`status` blocks until the spindle has stopped" semantics and the low-G spin-down-detection hang -- usable as a Python async context manager or runnable as a script (`python -m pylabrobot.centrifuge.highres.mock_server`) for `nc`/`telnet` debugging. +- `MicroSpinBackend.reset()` recovery helper that issues `abort` -> `clearbuttonabort` -> `status`, using the last as the gate that genuinely confirms the rotor has stopped. +- User guide notebook for the MicroSpin (`docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb`). + +### Changed + +- Refactored `pylabrobot.centrifuge` to a per-vendor folder layout (`agilent/`, `highres/`) mirroring `pylabrobot.plate_reading`. Existing imports from `pylabrobot.centrifuge.vspin_backend` and `pylabrobot.centrifuge.access2` continue to work via deprecation shims. + +### Fixed + +- Imported `unittest.mock` in `pylabrobot/centrifuge/centrifuge_tests.py` (pre-existing bug that prevented the test class from running). + ## 0.2.1 ### Added diff --git a/docs/api/pylabrobot.centrifuge.rst b/docs/api/pylabrobot.centrifuge.rst index df0932e8e22..8abd3e50ddb 100644 --- a/docs/api/pylabrobot.centrifuge.rst +++ b/docs/api/pylabrobot.centrifuge.rst @@ -11,6 +11,7 @@ This package contains APIs for working with centrifuges. :recursive: centrifuge.Centrifuge + centrifuge.Loader Backends @@ -21,4 +22,25 @@ Backends :nosignatures: :recursive: - vspin_backend.VSpinBackend + chatterbox.CentrifugeChatterboxBackend + chatterbox.LoaderChatterboxBackend + agilent.vspin_backend.VSpinBackend + agilent.vspin_backend.Access2Backend + highres.microspin_backend.MicroSpinBackend + + +Errors +------ + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + standard.BucketHasPlateError + standard.BucketNoPlateError + standard.CentrifugeDoorError + standard.LoaderNoPlateError + standard.NotAtBucketError + highres.microspin_backend.MicroSpinError + highres.microspin_backend.MicroSpinProtocolError diff --git a/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md b/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md index d6e13a1191b..26ce2df415f 100644 --- a/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md +++ b/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md @@ -12,8 +12,7 @@ The {class}`~pylabrobot.centrifuge.centrifuge.Centrifuge` class has a number of - {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.unlock_bucket`: Unlock centrifuge buckets. - {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.go_to_bucket1`: Rotate to Bucket 1. - {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.go_to_bucket2`: Rotate to Bucket 2. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.rotate_distance`: Rotate the buckets a specified distance (8000 = 360 degrees). -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.start_spin_cycle`: Start centrifuge spin cycle. +- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.spin`: Start a spin cycle. (The older {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.start_spin_cycle` method is a deprecated alias.) PLR supports the following centrifuges: @@ -21,4 +20,5 @@ PLR supports the following centrifuges: :maxdepth: 1 agilent_vspin +highres_microspin ``` diff --git a/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb b/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb new file mode 100644 index 00000000000..9a27b73e4cf --- /dev/null +++ b/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb @@ -0,0 +1,420 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f5b94f96", + "metadata": {}, + "source": [ + "# HighRes Biosolutions MicroSpin\n", + "\n", + "The MicroSpin is a sealed automated microplate centrifuge from HighRes Biosolutions. It has two swing-out buckets (1 SBS plate per bucket), spins up to 3000 ×g (4729 RPM), and is driven over TCP/IP -- there is no separate USB driver or FTDI library needed.\n", + "\n", + "It is controlled by the {class}`~pylabrobot.centrifuge.highres.microspin_backend.MicroSpinBackend` class and the {func}`~pylabrobot.centrifuge.highres.microspin.MicroSpin` factory.\n", + "\n", + "Reference: HighRes Biosolutions, MicroSpin User Manual (document 1058675).\n" + ] + }, + { + "cell_type": "markdown", + "id": "b3f4d99d", + "metadata": {}, + "source": [ + "## 1. Network configuration\n", + "\n", + "The MicroSpin ships with a static IP address printed on the product label (factory default `192.168.127.60`, mask `255.255.255.0`). To talk to it you need to put your computer on the same subnet, or change the device's IP to one that fits your network.\n", + "\n", + "There are two ways to find/change the IP:\n", + "\n", + "1. **Direct-attached (Ethernet point-to-point).** Connect the MicroSpin to a wired NIC on your computer (e.g. via a USB-to-Ethernet adapter). Configure that NIC with a static address such as `192.168.127.10 / 255.255.255.0`. Then open `http:///network.html` in a browser, and either:\n", + " - switch the device to **DHCP** (recommended for lab networks with a DHCP server), or\n", + " - enter a **static IP** in your lab's subnet plus the appropriate gateway.\n", + "\n", + " The same page also exposes a **Server Port** setting (factory default 1000); change it here if 1000 conflicts with something else on your network, and remember to pass the matching `port=...` when constructing `MicroSpin(...)` in Python.\n", + "\n", + " After saving, wait ~30 seconds, then reboot the MicroSpin (back-panel power switch).\n", + "\n", + "2. **Plug into the lab network directly.** If the device is already DHCP-enabled, find its lease on your DHCP server using the MAC address printed on the product label (the OUI is `00:d0:69`, assigned to Technologic Systems, who make the embedded controller inside the MicroSpin).\n", + "\n", + "Once you know the IP, you can verify connectivity with a simple `ping` before going any further.\n" + ] + }, + { + "cell_type": "markdown", + "id": "102ad3dc", + "metadata": {}, + "source": [ + "## 2. Connecting from PLR\n", + "\n", + "The {func}`~pylabrobot.centrifuge.highres.microspin.MicroSpin` factory returns a fully configured {class}`~pylabrobot.centrifuge.centrifuge.Centrifuge`. Unlike the VSpin + Access2 combo, no separate `Loader` is needed: an integration arm (or a human) places plates directly into the presented bucket.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d6079238", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.centrifuge import MicroSpin\n", + "\n", + "# Default: port 1000 (the MicroSpin's factory default).\n", + "cf = MicroSpin(name=\"microspin\", host=\"192.168.127.60\")\n", + "\n", + "# If your device has been reconfigured (Server Port setting on /network.html),\n", + "# pass the matching port explicitly:\n", + "# cf = MicroSpin(name=\"microspin\", host=\"192.168.127.60\", port=2300)\n", + "\n", + "await cf.setup() # opens a TCP connection to the configured port\n" + ] + }, + { + "cell_type": "markdown", + "id": "924995f5", + "metadata": {}, + "source": [ + "## 3. Homing\n", + "\n", + "The MicroSpin needs to be homed once after every power-cycle before bucket or spin commands are accepted. The backend exposes `home()` and `is_homed()` for this:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "91c23cb3", + "metadata": {}, + "outputs": [], + "source": [ + "if not await cf.backend.is_homed():\n", + " await cf.backend.home()\n" + ] + }, + { + "cell_type": "markdown", + "id": "58d6e7ac", + "metadata": {}, + "source": [ + "## 4. Reading status & version\n", + "\n", + "Two read-only helpers are convenient for diagnostics:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d30ab739", + "metadata": {}, + "outputs": [], + "source": [ + "await cf.backend.get_version()\n", + "# {'Product Name': 'RandomServe', 'Serial Number': 'HRB-...',\n", + "# 'Version': 'MS-1.3.3', 'Firmware Build': '...'}\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6984b371", + "metadata": {}, + "outputs": [], + "source": [ + "await cf.backend.get_status()\n", + "# {'Spindle Position': '1958', 'Door Position': '-457'}\n" + ] + }, + { + "cell_type": "markdown", + "id": "7fb071d2", + "metadata": {}, + "source": [ + "## 5. Positioning buckets and operating the door\n", + "\n", + "The MicroSpin's `open ` firmware command both opens the door and rotates the chosen bucket into the load position; PLR exposes this as `go_to_bucket1()` / `go_to_bucket2()`. The door's pneumatic lock and the nest-pin that holds plates during loading are managed by the firmware as part of `open ` and `spin` -- they are not exposed as standalone calls on the MicroSpin backend (see manual §6.7, which classifies the underlying `lockdoor` / `unlockdoor` / `locknest` / `unlocknest` commands as maintenance-only).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "935d0e90", + "metadata": {}, + "outputs": [], + "source": [ + "# Present bucket 1 at the load position (this also opens the door)\n", + "await cf.go_to_bucket1()\n", + "\n", + "# After placing/removing a plate:\n", + "await cf.close_door()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "be7c5024", + "metadata": {}, + "outputs": [], + "source": [ + "# Optionally just open the door without rotating a bucket (e.g. for inspection)\n", + "# await cf.open_door()\n", + "# await cf.close_door()\n" + ] + }, + { + "cell_type": "markdown", + "id": "32fe9d83", + "metadata": {}, + "source": [ + "## 6. Spinning\n", + "\n", + "```{warning}\n", + "**Pre-spin checklist (manual §§5.3, 7, 8):**\n", + "\n", + "- The device must be homed (see step 3).\n", + "- The shipping tie-wraps that hold the buckets to the rotor MUST be removed -- starting a spin with them in place will damage the buckets.\n", + "- Both buckets must be properly seated on the rotor pins, swing freely, and the bucket pivot pins must not protrude.\n", + "- The payload must be balanced (max 15 g imbalance at full speed, up to 75 g at lower speeds).\n", + "- The chamber must be free of debris.\n", + "- The door must be closed.\n", + "- Compressed air must be supplied at 70-135 psi.\n", + "\n", + "The MicroSpin does not have biosafety seals -- do not centrifuge hazardous, flammable, or corrosive materials.\n", + "```\n", + "\n", + "PLR's `spin()` takes:\n", + "\n", + "- `g`: G-force in ×g (valid range 1-3000)\n", + "- `duration`: time at speed in seconds (≥ 1)\n", + "- `acceleration`: ramp-up rate as a fraction (0, 1]\n", + "- `deceleration`: ramp-down rate as a fraction (0, 1]\n", + "\n", + "The backend converts the 0-1 fractions to the integer 0-100 percentages the firmware expects.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "997c455e", + "metadata": {}, + "outputs": [], + "source": [ + "await cf.spin(\n", + " g=500,\n", + " duration=60, # seconds\n", + " acceleration=1.0, # 0-1 fraction of max\n", + " deceleration=1.0, # 0-1 fraction of max\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "e3a6810c", + "metadata": {}, + "source": [ + "```{warning}\n", + "**Low-G hang:** Spinning at less than ~30 ×g is known to occasionally hang the firmware. The spindle \"stopped\" sensor sometimes fails to latch when the rotor decelerates from a very low speed, so no `OK!` completion line is emitted and every subsequent command times out. The backend will emit a `UserWarning` if you call `spin()` with `g < 30`. If you hit the hang, the recovery path is:\n", + "\n", + "```python\n", + "await cf.backend.abort()\n", + "await cf.backend.clear_button_abort()\n", + "```\n", + "\n", + "If the device still won't respond, power-cycle it from the back-panel switch.\n", + "```\n" + ] + }, + { + "cell_type": "markdown", + "id": "988b0cdd", + "metadata": {}, + "source": [ + "## 7. Recovering from a stuck or aborted state\n", + "\n", + "`abort()` requests a decel + stop. After an abort the firmware enters a latched abort state that must be cleared before any further motion commands will run.\n", + "\n", + "A subtlety: `abort` and `clearbuttonabort` both return `OK!` **as soon as the firmware accepts the request** -- they do *not* wait for the rotor to actually spin down. The real \"we are stopped\" gate is the `status` command: while motion is in progress, the firmware queues `status` behind it and only answers once motion has completed. The MicroSpin backend uses this property in `reset()`, which is therefore the canonical \"really, fully back to idle\" call:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ad40dddd", + "metadata": {}, + "outputs": [], + "source": [ + "# Sends abort, then clearbuttonabort, then status (which blocks until\n", + "# the spindle has actually stopped). Returns the final status report.\n", + "final_status = await cf.backend.reset()\n", + "print(final_status)\n", + "# {'Spindle Position': '...', 'Door Position': '...'}\n" + ] + }, + { + "cell_type": "markdown", + "id": "8441c6c3", + "metadata": {}, + "source": [ + "`reset()` swallows errors from the `abort` step by default (it's common for the firmware to reject abort if there's nothing to abort), so it's safe as a generic \"get me back to a known state\" routine. The arguments let you tune each phase:\n", + "\n", + "- `swallow_abort_errors=False` -- propagate any error from the abort step instead of ignoring it\n", + "- `wait_for_settle=False` -- skip the final `status` gate (just clear the abort latch, don't wait for the rotor)\n", + "- `abort_timeout=...` / `settle_timeout=...` -- override the per-step timeouts\n", + "\n", + "If you only want the \"wait for actual stop\" gate without changing any state, use `wait_for_spindle_stopped()` -- it sends a single `status` with a generous timeout and returns the parsed result:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f5029b27", + "metadata": {}, + "outputs": [], + "source": [ + "# Block until the firmware confirms the rotor is fully stopped.\n", + "status = await cf.backend.wait_for_spindle_stopped()\n" + ] + }, + { + "cell_type": "markdown", + "id": "68e263c4", + "metadata": {}, + "source": [ + "You can of course still call the two primitives directly if you need finer control. Remember that without the `status` step, you do NOT have confirmation that the rotor has actually spun down -- just that the abort latch was set/cleared:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "93c2434b", + "metadata": {}, + "outputs": [], + "source": [ + "await cf.backend.abort() # request OK; rotor may still be spinning\n", + "await cf.backend.clear_button_abort() # latch cleared; rotor may still be spinning\n", + "status = await cf.backend.get_status() # NOW we know we're really stopped\n" + ] + }, + { + "cell_type": "markdown", + "id": "b76bed47", + "metadata": {}, + "source": [ + "## 8. Error stack\n", + "\n", + "The MicroSpin maintains an internal error stack. `get_errors(n)` returns the top `n` lines (default 10):\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "94eac76d", + "metadata": {}, + "outputs": [], + "source": [ + "await cf.backend.get_errors(10)\n" + ] + }, + { + "cell_type": "markdown", + "id": "2ff390d5", + "metadata": {}, + "source": [ + "## 9. Sending raw commands\n", + "\n", + "For advanced use, `send_command()` exposes the underlying ASCII protocol. It returns the data lines emitted between `ACK!` and `OK!`, and raises {class}`~pylabrobot.centrifuge.highres.microspin_backend.MicroSpinError` on `ERROR!`. See manual §6.6 for the protocol; use `list` and `info` to enumerate commands the device supports:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0bc961a5", + "metadata": {}, + "outputs": [], + "source": [ + "await cf.backend.send_command(\"list\") # public commands\n", + "# await cf.backend.send_command(\"list all\") # includes maintenance commands\n" + ] + }, + { + "cell_type": "markdown", + "id": "bfcb2439", + "metadata": {}, + "source": [ + "## 10. Developing without a real device\n", + "\n", + "Bringing a 43 kg centrifuge into your apartment for testing is suboptimal. PLR ships an in-process mock TCP server that speaks the MicroSpin command protocol faithfully enough for the `MicroSpinBackend` to drive it end-to-end. Use it from Python:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "43c67d77", + "metadata": {}, + "outputs": [], + "source": [ + "import asyncio\n", + "from pylabrobot.centrifuge import MicroSpin\n", + "from pylabrobot.centrifuge.highres.mock_server import MicroSpinMockServer\n", + "\n", + "async def demo():\n", + " async with MicroSpinMockServer() as srv:\n", + " cf = MicroSpin(name=\"mock-microspin\", host=srv.host, port=srv.port)\n", + " await cf.setup()\n", + " await cf.backend.home()\n", + " print(await cf.backend.get_version())\n", + " # The mock implements `status`-blocks-during-motion, so reset(), abort\n", + " # recovery, and the low-G hang are all reproducible in tests.\n", + " await cf.stop()\n", + "\n", + "# await demo() # uncomment to run from a notebook\n" + ] + }, + { + "cell_type": "markdown", + "id": "314c2e82", + "metadata": {}, + "source": [ + "Or hand-drive it with `nc` / `telnet` after starting it as a script -- handy when you want to poke at the wire protocol directly:\n", + "\n", + "```bash\n", + "python -m pylabrobot.centrifuge.highres.mock_server --port 1000\n", + "# then, in another terminal:\n", + "nc 127.0.0.1 1000\n", + "```\n", + "\n", + "A few capabilities worth knowing about for tests:\n", + "\n", + "- `srv.motion_dwell[\"home\"] = 0.5` -- slow down motions to race against them.\n", + "- `srv.state.simulate_low_g_hang = True` -- reproduce the firmware bug where `status` never returns after a spin (see [§6 Spinning](#6-spinning)).\n", + "- Inspect `srv.state.homed`, `srv.state.at_bucket`, etc. directly from your test code.\n" + ] + }, + { + "cell_type": "markdown", + "id": "1ed4571a", + "metadata": {}, + "source": [ + "## 11. Cleaning up\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d3aba74b", + "metadata": {}, + "outputs": [], + "source": [ + "await cf.stop() # closes the TCP connection\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pylabrobot/centrifuge/__init__.py b/pylabrobot/centrifuge/__init__.py index df6a67cc292..96a4973b7a8 100644 --- a/pylabrobot/centrifuge/__init__.py +++ b/pylabrobot/centrifuge/__init__.py @@ -1,5 +1,11 @@ -from .access2 import Access2 +from .agilent import Access2, Access2Backend, VSpinBackend from .centrifuge import Centrifuge, Loader +from .highres import ( + MicroSpin, + MicroSpinBackend, + MicroSpinError, + MicroSpinProtocolError, +) from .standard import ( BucketHasPlateError, BucketNoPlateError, @@ -7,4 +13,24 @@ LoaderNoPlateError, NotAtBucketError, ) -from .vspin_backend import Access2Backend, VSpinBackend + +__all__ = [ + # Front-end + "Centrifuge", + "Loader", + # Standard / errors + "BucketHasPlateError", + "BucketNoPlateError", + "CentrifugeDoorError", + "LoaderNoPlateError", + "NotAtBucketError", + # Agilent (VSpin + Access2 loader) + "Access2", + "Access2Backend", + "VSpinBackend", + # HighRes Biosolutions (MicroSpin) + "MicroSpin", + "MicroSpinBackend", + "MicroSpinError", + "MicroSpinProtocolError", +] diff --git a/pylabrobot/centrifuge/access2.py b/pylabrobot/centrifuge/access2.py index 8f773914ab7..611b605623f 100644 --- a/pylabrobot/centrifuge/access2.py +++ b/pylabrobot/centrifuge/access2.py @@ -1,26 +1,8 @@ -from typing import Tuple +import warnings -from pylabrobot.centrifuge.centrifuge import Centrifuge, Loader -from pylabrobot.centrifuge.vspin_backend import Access2Backend, VSpinBackend -from pylabrobot.resources import Coordinate +from .agilent.access2 import Access2 # noqa: F401 - -def Access2(name: str, device_id: str, vspin: VSpinBackend) -> Tuple[Centrifuge, Loader]: - centrifuge = Centrifuge( - backend=vspin, - size_x=0, # TODO - size_y=0, # TODO - size_z=0, # TODO - name=name + "_centrifuge", - ) - # Use `python -m pylibftdi.examples.list_devices` to find the device id for each - loader = Loader( - name=name, - size_x=0, # TODO - size_y=0, # TODO - size_z=0, # TODO - backend=Access2Backend(device_id=device_id), - centrifuge=centrifuge, - child_location=Coordinate.zero(), - ) - return centrifuge, loader +warnings.warn( + "pylabrobot.centrifuge.access2 is deprecated and will be removed in a future release. " + "Please use pylabrobot.centrifuge.agilent.access2 instead.", +) diff --git a/pylabrobot/centrifuge/agilent/__init__.py b/pylabrobot/centrifuge/agilent/__init__.py new file mode 100644 index 00000000000..1697838e576 --- /dev/null +++ b/pylabrobot/centrifuge/agilent/__init__.py @@ -0,0 +1,8 @@ +from .access2 import Access2 +from .vspin_backend import Access2Backend, VSpinBackend + +__all__ = [ + "Access2", + "Access2Backend", + "VSpinBackend", +] diff --git a/pylabrobot/centrifuge/agilent/access2.py b/pylabrobot/centrifuge/agilent/access2.py new file mode 100644 index 00000000000..5ec6d8fc1b1 --- /dev/null +++ b/pylabrobot/centrifuge/agilent/access2.py @@ -0,0 +1,26 @@ +from typing import Tuple + +from pylabrobot.centrifuge.agilent.vspin_backend import Access2Backend, VSpinBackend +from pylabrobot.centrifuge.centrifuge import Centrifuge, Loader +from pylabrobot.resources import Coordinate + + +def Access2(name: str, device_id: str, vspin: VSpinBackend) -> Tuple[Centrifuge, Loader]: + centrifuge = Centrifuge( + backend=vspin, + size_x=0, # TODO + size_y=0, # TODO + size_z=0, # TODO + name=name + "_centrifuge", + ) + # Use `python -m pylibftdi.examples.list_devices` to find the device id for each + loader = Loader( + name=name, + size_x=0, # TODO + size_y=0, # TODO + size_z=0, # TODO + backend=Access2Backend(device_id=device_id), + centrifuge=centrifuge, + child_location=Coordinate.zero(), + ) + return centrifuge, loader diff --git a/pylabrobot/centrifuge/agilent/vspin_backend.py b/pylabrobot/centrifuge/agilent/vspin_backend.py new file mode 100644 index 00000000000..66834d3f815 --- /dev/null +++ b/pylabrobot/centrifuge/agilent/vspin_backend.py @@ -0,0 +1,649 @@ +import asyncio +import ctypes +import json +import logging +import math +import os +import time +import warnings +from typing import Optional + +from pylabrobot.io.ftdi import FTDI + +from ..backend import CentrifugeBackend, LoaderBackend +from ..standard import LoaderNoPlateError + +logger = logging.getLogger(__name__) + + +class Access2Backend(LoaderBackend): + def __init__( + self, + device_id: str, + timeout: int = 60, + ): + """ + Args: + device_id: The libftdi id for the loader. Find using + `python3 -m pylibftdi.examples.list_devices` + """ + self.io = FTDI(human_readable_device_name="Agilent Access2 Loader", device_id=device_id) + self.timeout = timeout + + async def _read(self) -> bytes: + x = b"" + r = None + start = time.time() + while r != b"" or x == b"": + r = await self.io.read(1) + x += r + if r == b"": + await asyncio.sleep(0.1) + if x == b"" and (time.time() - start) > self.timeout: + raise TimeoutError("No data received within the specified timeout period") + return x + + async def send_command(self, command: bytes) -> bytes: + logger.debug("[loader] Sending %s", command.hex()) + await self.io.write(command) + return await self._read() + + async def setup(self): + logger.debug("[loader] setup") + + await self.io.setup() + await self.io.set_baudrate(115384) + + status = await self.get_status() + if not status.startswith(bytes.fromhex("1105")): + raise RuntimeError("Failed to get status") + + await self.send_command(bytes.fromhex("110500030014000072b1")) + await self.send_command(bytes.fromhex("1105000300100000ae71")) + await self.send_command(bytes.fromhex("110500070024040000008000be89")) + await self.send_command(bytes.fromhex("11050007002404008000800063b1")) + await self.send_command(bytes.fromhex("11050007002404000001800089b9")) + await self.send_command(bytes.fromhex("1105000700240400800180005481")) + await self.send_command(bytes.fromhex("110500070024040000024000c6bd")) + await self.send_command(bytes.fromhex("1105000300400000f0bf")) + await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) + # await self.send_command(bytes.fromhex("11050003002000006bd4")) + await self.send_command(bytes.fromhex("1105000e00440b00000000000000007041020203c7")) + # await self.send_command(bytes.fromhex("11050003002000006bd4")) + + async def stop(self): + logger.debug("[loader] stop") + await self.io.stop() + + def serialize(self): + return {"io": self.io.serialize(), "timeout": self.timeout} + + async def get_status(self) -> bytes: + logger.debug("[loader] get_status") + return await self.send_command(bytes.fromhex("11050003002000006bd4")) + + async def park(self): + logger.debug("[loader] park") + await self.send_command(bytes.fromhex("1105000e00440b0000000000410000704103007539")) + + async def close(self): + logger.debug("[loader] close") + await self.send_command(bytes.fromhex("1105000a00420700010000803f02008c64")) + + async def open(self): + logger.debug("[loader] open") + await self.send_command(bytes.fromhex("1105000a0042070001000080bf0200b73e")) + + async def load(self): + """only tested for 1cm plate, 3mm pickup height""" + logger.debug("[loader] load") + + await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) + await self.send_command(bytes.fromhex("1105000e00440b000100004040000020410200a5cb")) + + # laser check + r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) + if r == bytes.fromhex("1105000800510500000300000079f1"): + raise LoaderNoPlateError("no plate found on stage") + + await self.send_command(bytes.fromhex("1105000a00460700018fc2b540020023dc")) + await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410300ee00")) + await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) + await self.send_command(bytes.fromhex("1105000e00440b0000000040400000204102007d82")) + + async def unload(self): + """only tested for 1cm plate, 3mm pickup height""" + logger.debug("[loader] unload") + + await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) + await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410200dd31")) + + # laser check + r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) + if r == bytes.fromhex("1105000800510500000300000079f1"): + raise LoaderNoPlateError("no plate found in centrifuge") + + await self.send_command(bytes.fromhex("1105000a00460700017b14b6400200d57a")) + await self.send_command(bytes.fromhex("1105000e00440b00010000404000002041030096fa")) + await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) + await self.send_command(bytes.fromhex("1105000e00440b00000000000000002041020056be")) + # await self.send_command(bytes.fromhex("11050003002000006bd4")) + + +_vspin_bucket_calibrations_path = os.path.join( + os.path.expanduser("~"), + ".pylabrobot", + "vspin_bucket_calibrations.json", +) + + +def _load_vspin_calibrations(device_id: str) -> Optional[int]: + if not os.path.exists(_vspin_bucket_calibrations_path): + warnings.warn( + f"No calibration found for VSpin with device id {device_id}. " + "Please set the bucket 1 position using `set_bucket_1_position_to_current` method after setup.", + UserWarning, + ) + return None + with open(_vspin_bucket_calibrations_path, "r") as f: + return json.load(f).get(device_id) # type: ignore + + +def _save_vspin_calibrations(device_id, remainder: int): + if os.path.exists(_vspin_bucket_calibrations_path): + with open(_vspin_bucket_calibrations_path, "r") as f: + data = json.load(f) + else: + data = {} + data[device_id] = remainder + os.makedirs(os.path.dirname(_vspin_bucket_calibrations_path), exist_ok=True) + with open(_vspin_bucket_calibrations_path, "w") as f: + json.dump(data, f) + + +FULL_ROTATION: int = 8000 + + +bucket_1_not_set_error = RuntimeError( + "Bucket 1 position not set. " + "Please rotate the bucket to bucket 1 using VSpinBackend.go_to_position and " + "then calling VSpinBackend.set_bucket_1_position_to_current." +) + + +class VSpinBackend(CentrifugeBackend): + """Backend for the Agilent Centrifuge. + Note that this is not a complete implementation.""" + + def __init__(self, device_id: Optional[str] = None): + """ + Args: + device_id: The libftdi id for the centrifuge. Find using `python -m pylibftdi.examples.list_devices` + """ + self.io = FTDI(human_readable_device_name="Agilent VSpin Centrifuge", device_id=device_id) + self._bucket_1_remainder: Optional[int] = None + # only attempt loading calibration if device_id is not None + # if it is None, we will load it after setup when we can query the device id from the io + if device_id is not None: + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + + async def setup(self): + await self.io.setup() + # TODO: add functionality where if robot has been initialized before nothing needs to happen + for _ in range(3): + await self.configure_and_initialize() + await self._send_command(bytes.fromhex("aa002101ff21")) + await self._send_command(bytes.fromhex("aa002101ff21")) + await self._send_command(bytes.fromhex("aa01132034")) + await self._send_command(bytes.fromhex("aa002102ff22")) + await self._send_command(bytes.fromhex("aa02132035")) + await self._send_command(bytes.fromhex("aa002103ff23")) + await self._send_command(bytes.fromhex("aaff1a142d")) + + await self.io.set_baudrate(57600) + await self.io.set_rts(True) + await self.io.set_dtr(True) + + await self._send_command(bytes.fromhex("aa01121f32")) + for _ in range(8): + await self._send_command(bytes.fromhex("aa0220ff0f30")) + await self._send_command(bytes.fromhex("aa0220df0f10")) + await self._send_command(bytes.fromhex("aa0220df0e0f")) + await self._send_command(bytes.fromhex("aa0220df0c0d")) + await self._send_command(bytes.fromhex("aa0220df0809")) + for _ in range(4): + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa02120317")) + for _ in range(5): + await self._send_command(bytes.fromhex("aa0226200048")) + await self._send_command(bytes.fromhex("aa0226000028")) + await self.lock_door() + + await self._send_command(bytes.fromhex("aa0226000028")) + + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa010001")) + await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await self._send_command(bytes.fromhex("aa01192842")) + + resp = 0x89 + while resp == 0x89: + resp = (await self._get_positions_and_tachometer()).status + + # --- almost the same as go to position --- + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + new_position = (0).to_bytes(4, byteorder="little") # arbitrary + # rpm = 600, + # acceleration = 75.09289617486338 + await self._send_command( + bytes.fromhex("aa01d497") + new_position + bytes.fromhex("c3f52800d71a000049") + ) + # ----------------------------------------- + + resp = 0x08 + while resp != 0x09: + resp = (await self._get_positions_and_tachometer()).status + + await self._send_command(bytes.fromhex("aa0117021a")) + + await self.lock_door() + + # If we have not set the calibration yet, load it now. + if self._bucket_1_remainder is None: + device_id = await self.io.get_serial() + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + + @property + def bucket_1_remainder(self) -> int: + if self._bucket_1_remainder is None: + raise bucket_1_not_set_error + return self._bucket_1_remainder + + async def set_bucket_1_position_to_current(self) -> None: + """Set the current position as bucket 1 position and save calibration.""" + current_position = await self.get_position() + device_id = await self.io.get_serial() + remainder = await self.get_home_position() - current_position + self._bucket_1_remainder = current_position % FULL_ROTATION + _save_vspin_calibrations(device_id, remainder) + + async def get_bucket_1_position(self) -> int: + """Get the bucket 1 position based on calibration. + Normally it is the home position minus the remainder (calibration). + The bucket 1 position must be greater than the current position, so we find + the first position greater than the current position by adding full rotations if needed. + """ + if self._bucket_1_remainder is None: + raise bucket_1_not_set_error + home_position = await self.get_home_position() + bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder + # first number after current position that matches bucket 1 position mod FULL_ROTATION + current_position = await self.get_position() + bucket_1_position = ( + FULL_ROTATION + * math.floor((current_position - bucket_1_position_mod_full_rotation) / FULL_ROTATION + 1) + + bucket_1_position_mod_full_rotation + ) + return bucket_1_position + + async def stop(self): + await self.configure_and_initialize() + await self.io.stop() + + class _StatusPositionTachometer(ctypes.LittleEndianStructure): + _pack_ = 1 + _fields_ = [ + ("status", ctypes.c_uint8), + ("current_position", ctypes.c_uint32), + ("unknown1", ctypes.c_uint8), + ("tachometer", ctypes.c_int16), + ("unknown2", ctypes.c_uint8), + ("home_position", ctypes.c_uint32), + ("checksum", ctypes.c_uint8), + ] + + async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: + """Returns 14 bytes + + Example: + 11 22 25 00 00 4f 00 00 18 e0 05 00 00 a4 + ^^ checksum + ^^ ^^ ^^ ^^ home position + ^^ ? (probably binary status objects) + ^^ ^^ tachometer + ^^ ? (probably binary status objects) + ^^ ^^ ^^ ^^ current position + ^^ + - First byte (index 0): + - 11 = 0b0001011 = idle + - 13 = 0b0001101 = unknown + - 08 = 0b0001000 = spinning + - 09 = 0b0001001 = also spinning but different + - 19 = 0b0010011 = unknown + - 88 = 0b1011000 = unknown + - 89 = 0b1011001 = unknown + - 10th to 13th byte (index 9-12) = Homing Position + - Last byte (index 13) = checksum + """ + resp = await self._send_command(bytes.fromhex("aa010e0f")) + if len(resp) == 0: + raise IOError("Empty status from centrifuge") + return VSpinBackend._StatusPositionTachometer.from_buffer_copy(resp) + + async def get_position(self) -> int: + return (await self._get_positions_and_tachometer()).current_position # type: ignore + + async def get_tachometer(self) -> int: + """current speed in rpm""" + tack_to_rpm = -14.69320388 # R^2 = 0.9999 when spinning, but not specific at single-digit RPM + return (await self._get_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore + + async def get_home_position(self) -> int: + """changes during a run, but the bucket 1 position relative to it does not""" + return (await self._get_positions_and_tachometer()).home_position # type: ignore + + async def _get_status(self): + """ + examples: + - 0080d0015 + - 0080f0015 + """ + + resp = await self._send_command(bytes.fromhex("aa020e10")) + if len(resp) == 0: + raise IOError("Empty status from centrifuge. Is the machine on?") + return resp + + async def get_bucket_locked(self) -> bool: + resp = await self._get_status() + return resp[2] & 0b0001 != 0 # type: ignore + + async def get_door_open(self) -> bool: + resp = await self._get_status() + return resp[2] & 0b0010 != 0 # type: ignore + + async def get_door_locked(self) -> bool: + resp = await self._get_status() + return resp[2] & 0b0100 == 0 # type: ignore + + # Centrifuge communication: read_resp, send + + async def _read_resp(self, timeout: float = 20) -> bytes: + """Read a response from the centrifuge. If the timeout is reached, return the data that has + been read so far.""" + data = b"" + end_byte_found = False + start_time = time.time() + + while True: + chunk = await self.io.read(25) + if chunk: + data += chunk + end_byte_found = data[-1] == 0x0D + if len(chunk) < 25 and end_byte_found: + break + else: + if end_byte_found or time.time() - start_time > timeout: + break + await asyncio.sleep(0.0001) + + logger.debug("Read %s", data.hex()) + return data + + async def _send_command(self, cmd: bytes, read_timeout=0.2) -> bytes: + written = await self.io.write(bytes(cmd)) + + if written != len(cmd): + raise RuntimeError("Failed to write all bytes") + return await self._read_resp(timeout=read_timeout) + + async def configure_and_initialize(self): + await self.set_configuration_data() + await self.initialize() + + async def set_configuration_data(self): + """Set the device configuration data.""" + await self.io.set_latency_timer(16) + await self.io.set_line_property(bits=8, stopbits=1, parity=0) + await self.io.set_flowctrl(0) + await self.io.set_baudrate(19200) + + async def initialize(self): + await self.io.write(b"\x00" * 20) + for i in range(33): + packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 + await self.io.write(packet) + await self._send_command(bytes.fromhex("aaff0f0e")) + + # Centrifuge operations + + async def open_door(self): + if await self.get_door_open(): + return + # used to be: aa022600072f + await self._send_command(bytes.fromhex("aa022600062e")) # same as unlock door + + # we can't tell when the door is fully open, so we just wait a bit + await asyncio.sleep(4) + + async def close_door(self): + if not (await self.get_door_open()): + return + # used to be: aa022600052d + await self._send_command(bytes.fromhex("aa022600042c")) # same as unlock door + # we can't tell when the door is fully closed, so we just wait a bit + await asyncio.sleep(2) + + async def lock_door(self): + if await self.get_door_open(): + raise RuntimeError("Cannot lock door while it is open.") + if await self.get_door_locked(): + return + # used to be aa0226000129 + await self._send_command(bytes.fromhex("aa0226000028")) + + async def unlock_door(self): + if not await self.get_door_locked(): + return + # used to be aa022600052d + await self._send_command(bytes.fromhex("aa022600042c")) # same as close door + + async def lock_bucket(self): + if await self.get_bucket_locked(): + return + await self._send_command(bytes.fromhex("aa022600072f")) + + async def unlock_bucket(self): + if not await self.get_bucket_locked(): + return + await self._send_command(bytes.fromhex("aa022600062e")) # same as open door + + async def go_to_bucket1(self): + await self.go_to_position(await self.get_bucket_1_position()) + + async def go_to_bucket2(self): + await self.go_to_position(await self.get_bucket_1_position() + FULL_ROTATION // 2) + + async def go_to_position(self, position: int): + await self.close_door() + await self.lock_door() + + position_bytes = position.to_bytes(4, byteorder="little") + byte_string = bytes.fromhex("aa01d497") + position_bytes + bytes.fromhex("c3f52800d71a0000") + sum_byte = (sum(byte_string) - 0xAA) & 0xFF + byte_string += sum_byte.to_bytes(1, byteorder="little") + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(byte_string) + + # await self._send_command(bytes.fromhex("aa0117021a")) + while ( + abs(await self.get_position() - position) > 10 + ): # 10 tacks tolerance (10/8000 * 360 = 0.45 degrees) + await asyncio.sleep(0.1) + await self.open_door() + + @staticmethod + def g_to_rpm(g: float) -> int: + # https://en.wikipedia.org/wiki/Centrifugation#Mathematical_formula + r = 10 + rpm = int((g / (1.118 * 10**-5 * r)) ** 0.5) + return rpm + + async def spin( + self, + g: float = 500, + duration: float = 60, + acceleration: float = 0.8, + deceleration: float = 0.8, + ) -> None: + """Start a spin cycle. spin spin spin spin + + Args: + g: relative centrifugal force, also known as g-force + duration: time in seconds spent at speed (g) + acceleration: 0-1 of total acceleration + deceleration: 0-1 of total deceleration + """ + + if acceleration <= 0 or acceleration > 1: + raise ValueError("Acceleration must be within 0-1.") + if deceleration <= 0 or deceleration > 1: + raise ValueError("Deceleration must be within 0-1.") + if g < 1 or g > 1000: + raise ValueError("G-force must be within 1-1000") + if duration < 1: + raise ValueError("Spin time must be at least 1 second") + + if await self.get_door_open(): + await self.close_door() + if not await self.get_door_locked(): + await self.lock_door() + if await self.get_bucket_locked(): + await self.unlock_bucket() + + # 1 - compute the final position + rpm = VSpinBackend.g_to_rpm(g) + + # compute the distance traveled during the acceleration period + # distance = 1/2 * v^2 / a. area under 0 to t (triangle). t = a/v_max + # 12903.2 ticks/s^2 is 100% acceleration + acceleration_ticks_per_second2 = 12903.2 * acceleration + rounds_per_second = rpm / 60 + ticks_per_second = rounds_per_second * 8000 + distance_during_acceleration = int(0.5 * (ticks_per_second**2) / acceleration_ticks_per_second2) + + # compute the distance traveled at speed + distance_at_speed = ticks_per_second * duration + + current_position = await self.get_position() + final_position = int(current_position + distance_during_acceleration + distance_at_speed) + + if final_position > 2**32 - 1: + # this is almost 3 hours of spinning at 3000 rpm (max speed), + # so we assume nobody will ever hit this. + raise NotImplementedError( + "We don't know what happens if the destination position exceeds 2^32-1. " + "Please report this issue on discuss.pylabrobot.org." + ) + + # 2 - send "go to position" command with computed final position and rpm + position_b = final_position.to_bytes(4, byteorder="little") + rpm_b = int(rpm * 4473.925).to_bytes(4, byteorder="little") + acceleration_b = int(9.15 * 100 * acceleration).to_bytes(4, byteorder="little") + + byte_string = bytes.fromhex("aa01d497") + position_b + rpm_b + acceleration_b + checksum = (sum(byte_string) - 0xAA) & 0xFF + byte_string += checksum.to_bytes(1, byteorder="little") + + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + + await self._send_command(byte_string) + + # 3 - wait for acceleration to the set rpm + # we also check the position to avoid waiting forever if the speed is not reached (e.g. short spin...) + while await self.get_tachometer() < rpm * 0.95 and await self.get_position() < final_position: + await asyncio.sleep(0.1) + + # 4 - once the speed is reached, compute the position at which to start deceleration + # this is different than computed above, because above we assumed constant acceleration from 0 to rpm. + # however, in reality there is jerk and the acceleration is not constant, so we have to adjust as we go. + # this is what the vendor software does too. + # if we are already past that position, we skip this part. + if await self.get_position() < final_position: + decel_start_position = await self.get_position() + distance_at_speed + + # then wait until we reach that position + while await self.get_position() < decel_start_position: + await asyncio.sleep(0.1) + + # 5 - send deceleration command + await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + # aa0194b600000000dc02000029: decel at 80 + # aa0194b6000000000a03000058: decel at 85 + # aa0194b61283000012010000f3: used in setup (30%) + decc = int(9.15 * 100 * deceleration).to_bytes(2, byteorder="little") + decel_command = bytes.fromhex("aa0194b600000000") + decc + bytes.fromhex("0000") + decel_command += ((sum(decel_command) - 0xAA) & 0xFF).to_bytes(1, byteorder="little") + await self._send_command(decel_command) + + await asyncio.sleep(2) + + # 6 - reset position back to 0ish + # this part is aneeded because otherwise calling go_to_position will not work after + async def _reset_to_zero(): + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa010001")) # set position back to 0 (exactly) + await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await self._send_command(bytes.fromhex("aa01192842")) # it starts moving again + + await _reset_to_zero() + + # 7 - wait for home position to change + # go_to_bucket{1,2} does not work until the home position changes + start = await self.get_home_position() + num_tries = 0 + while await self.get_home_position() == start: + await asyncio.sleep(0.1) + num_tries += 1 + if num_tries % 25 == 0: + await _reset_to_zero() + if num_tries > 100: + raise RuntimeError("Home position did not change after spin.") + + +# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) +# https://github.com/PyLabRobot/pylabrobot/issues/466 + + +class VSpin: + def __init__(self, *args, **kwargs): + raise RuntimeError("`VSpin` is deprecated. Please use `VSpinBackend` instead. ") diff --git a/pylabrobot/centrifuge/centrifuge_tests.py b/pylabrobot/centrifuge/centrifuge_tests.py index 9dbf6c56d8b..9e84c90440b 100644 --- a/pylabrobot/centrifuge/centrifuge_tests.py +++ b/pylabrobot/centrifuge/centrifuge_tests.py @@ -1,4 +1,5 @@ import unittest +import unittest.mock from pylabrobot.centrifuge import ( BucketHasPlateError, diff --git a/pylabrobot/centrifuge/highres/__init__.py b/pylabrobot/centrifuge/highres/__init__.py new file mode 100644 index 00000000000..90ff90c679a --- /dev/null +++ b/pylabrobot/centrifuge/highres/__init__.py @@ -0,0 +1,13 @@ +from .microspin import MicroSpin +from .microspin_backend import ( + MicroSpinBackend, + MicroSpinError, + MicroSpinProtocolError, +) + +__all__ = [ + "MicroSpin", + "MicroSpinBackend", + "MicroSpinError", + "MicroSpinProtocolError", +] diff --git a/pylabrobot/centrifuge/highres/microspin.py b/pylabrobot/centrifuge/highres/microspin.py new file mode 100644 index 00000000000..ffca7c7d2e0 --- /dev/null +++ b/pylabrobot/centrifuge/highres/microspin.py @@ -0,0 +1,68 @@ +"""Factory for the HighRes Biosolutions MicroSpin centrifuge. + +The MicroSpin is a sealed, automation-friendly microplate centrifuge with two +swing-out buckets (1 SBS plate per bucket). It does not have a separate +plate loader -- plates are placed into the presented bucket directly by a +robot arm, then the door is closed and a spin is started. Because of this, +the :class:`~pylabrobot.centrifuge.Centrifuge` returned here is *not* +paired with a separate :class:`~pylabrobot.centrifuge.Loader` (unlike the +Agilent VSpin + Access2 combo). +""" + +from __future__ import annotations + +from typing import Optional + +from pylabrobot.centrifuge.centrifuge import Centrifuge +from pylabrobot.centrifuge.highres.microspin_backend import MicroSpinBackend + +# Spec sheet (manual §11): +# Dimensions (H x W x L): 15" x 15" x 16" +# Weight (empty) : 43 kg +# pylabrobot conventionally uses size_x = width, size_y = depth, size_z = height. +_INCH_MM = 25.4 +_MICROSPIN_WIDTH_MM = 15.0 * _INCH_MM # 381.0 mm +_MICROSPIN_DEPTH_MM = 16.0 * _INCH_MM # 406.4 mm +_MICROSPIN_HEIGHT_MM = 15.0 * _INCH_MM # 381.0 mm + + +def MicroSpin( + name: str, + host: str, + port: int = MicroSpinBackend.DEFAULT_PORT, + timeout: float = 30.0, + backend: Optional[MicroSpinBackend] = None, +) -> Centrifuge: + """Construct a HighRes Biosolutions MicroSpin centrifuge. + + Args: + name: A descriptive name for this centrifuge instance. + host: IP address or DNS name of the MicroSpin's Ethernet port. The + factory default is ``192.168.127.60`` but the IP is configurable on + the device's ``/network.html`` web page. + port: TCP port for the remote-control server. Defaults to + :attr:`MicroSpinBackend.DEFAULT_PORT` (1000, the factory default). + Pass a different value if the device's ``SERVER_PORT`` setting has + been changed via ``/network.html``. + timeout: Default per-command timeout in seconds. The backend extends + this automatically for long-running operations (spin, home). + backend: Optionally supply a pre-constructed + :class:`MicroSpinBackend`. Useful for tests or for sharing a backend + between front-end objects. If omitted, a new backend is built from + ``host``/``port``/``timeout``. + + Returns: + A :class:`~pylabrobot.centrifuge.Centrifuge` wired to a + :class:`MicroSpinBackend`. + """ + if backend is None: + backend = MicroSpinBackend(host=host, port=port, timeout=timeout) + + return Centrifuge( + backend=backend, + name=name, + size_x=_MICROSPIN_WIDTH_MM, + size_y=_MICROSPIN_DEPTH_MM, + size_z=_MICROSPIN_HEIGHT_MM, + model="HighRes Biosolutions MicroSpin", + ) diff --git a/pylabrobot/centrifuge/highres/microspin_backend.py b/pylabrobot/centrifuge/highres/microspin_backend.py new file mode 100644 index 00000000000..abbb68970d8 --- /dev/null +++ b/pylabrobot/centrifuge/highres/microspin_backend.py @@ -0,0 +1,550 @@ +"""Backend for the HighRes Biosolutions MicroSpin automated microplate centrifuge. + +The MicroSpin exposes an ASCII command/response protocol over TCP/IP on port +1000 by default. The wire protocol is documented in HighRes Biosolutions +"MicroSpin User Manual" (document 1058675, §6.6). In summary, every command +exchange has the following shape:: + + >>> [args...]\\r\\n + <<< ACK! + <<< (optional data lines, depending on the command) + <<< OK! -- success terminator + ERROR! -- failure terminator + Error : -- (one or more lines after ERROR!) + +This module also exposes a small surface of MicroSpin-specific helpers +(``home``, ``abort``, ``get_status`` ...) in addition to the abstract +:class:`~pylabrobot.centrifuge.backend.CentrifugeBackend` interface. + +Safety +------ +The MicroSpin is a real piece of mechanical equipment that can develop +3000 ``×g`` (4729 RPM) and weighs 43 kg. Before issuing a ``spin`` command: + +* the device MUST be homed (use :meth:`home` if :meth:`is_homed` returns False) +* the shipping tie-wraps holding the payload buckets MUST be removed +* both buckets must be properly seated on the rotor pins and balanced +* the door must be closed (the firmware will refuse to spin otherwise) +* nothing flammable, corrosive, or biohazardous may be in the chamber + +Refer to the MicroSpin user manual (HighRes document 1058675) §7 for the +full pre-spin checklist, and to §11 for environmental specifications. +""" + +from __future__ import annotations + +import asyncio +import logging +import re +import warnings +from typing import Dict, List, Optional + +from ..backend import CentrifugeBackend + +logger = logging.getLogger(__name__) + + +_ACK_RE = re.compile(r"^ACK!\s+(?P.*?)\s+(?P\d+)\s*$") + + +class MicroSpinError(RuntimeError): + """Raised when the MicroSpin responds with ``ERROR!`` to a command. + + Attributes: + command: The command text that was sent. + command_id: The numeric command id assigned by the device, or -1 if the + ACK could not be parsed. + error_lines: The diagnostic lines (``Error : ...``) the device emitted + before the ``ERROR!`` terminator. + """ + + def __init__(self, command: str, command_id: int, error_lines: List[str]): + self.command = command + self.command_id = command_id + self.error_lines = list(error_lines) + super().__init__( + f"MicroSpin returned ERROR! for {command!r} (id={command_id}):\n " + + ("\n ".join(error_lines) if error_lines else "(no error detail)") + ) + + +class MicroSpinProtocolError(RuntimeError): + """Raised when the MicroSpin emits a response we cannot parse.""" + + +class MicroSpinBackend(CentrifugeBackend): + """Asynchronous backend for the HighRes Biosolutions MicroSpin centrifuge. + + Communicates over a single persistent TCP connection. Commands are serialised + with an :class:`asyncio.Lock` so the front-end can safely interleave callers. + """ + + #: Factory-default TCP port for the MicroSpin's remote-control server + #: (manual §6.3). The port is configurable per-device on the + #: ``/network.html`` web page (the ``SERVER_PORT`` setting); if your unit + #: has been reconfigured, pass the right port to the constructor or to + #: :func:`~pylabrobot.centrifuge.highres.microspin.MicroSpin`. + DEFAULT_PORT: int = 1000 + #: Spec sheet maximum (manual §11). The firmware will reject larger values. + MAX_G_FORCE = 3000 + #: Empirically observed minimum below which the firmware sometimes fails to + #: detect spin-down. Spinning below this triggers a :class:`UserWarning`; + #: see :meth:`spin` for the failure mode. + LOW_G_WARNING_THRESHOLD = 30 + + def __init__( + self, + host: str, + port: int = DEFAULT_PORT, + timeout: float = 30.0, + ): + """ + Args: + host: IP address or DNS name of the MicroSpin's Ethernet interface. + port: TCP port for the remote-control server. Defaults to + :attr:`DEFAULT_PORT` (1000, the factory default). Override this if + your device has been reconfigured via its ``/network.html`` web UI + to listen on a different port. + timeout: Default per-command timeout in seconds. Long-running commands + (``spin``, ``home``) automatically extend this internally. + """ + self.host = host + self.port = port + self.timeout = timeout + + self._reader: Optional[asyncio.StreamReader] = None + self._writer: Optional[asyncio.StreamWriter] = None + self._lock = asyncio.Lock() + + # ------------------------------------------------------------------ lifecycle + + async def setup(self) -> None: + """Open the TCP connection to the MicroSpin's remote-control server.""" + logger.debug("[microspin] connecting to %s:%d", self.host, self.port) + self._reader, self._writer = await asyncio.open_connection(self.host, self.port) + + async def stop(self) -> None: + """Close the TCP connection. Safe to call even if never set up.""" + if self._writer is not None: + try: + self._writer.close() + await self._writer.wait_closed() + except Exception: # noqa: BLE001 -- best-effort close + logger.debug("[microspin] error while closing connection", exc_info=True) + self._reader = None + self._writer = None + + def serialize(self) -> dict: + """Return a JSON-serialisable view of this backend's construction args.""" + return { + **super().serialize(), + "host": self.host, + "port": self.port, + "timeout": self.timeout, + } + + # ------------------------------------------------------------------ wire IO + + async def _readline(self) -> str: + assert self._reader is not None, "not connected" + raw = await self._reader.readline() + if not raw: + raise ConnectionError("MicroSpin closed the connection") + return raw.rstrip(b"\r\n").decode("ascii", errors="replace") + + async def send_command( + self, + command: str, + *, + timeout: Optional[float] = None, + ) -> List[str]: + """Send a single command and return any data lines emitted by the device. + + Args: + command: The full command line *without* CR/LF. + timeout: Override the default per-command timeout (seconds). + + Returns: + A list of the data lines emitted between ``ACK!`` and ``OK!``. The list + is empty for commands that report only status (e.g. ``home``). + + Raises: + MicroSpinError: If the device terminates with ``ERROR!``. + MicroSpinProtocolError: If the ACK or terminator cannot be parsed. + asyncio.TimeoutError: If the timeout elapses. + """ + if self._writer is None or self._reader is None: + raise RuntimeError("MicroSpinBackend is not set up. Call `await backend.setup()` first.") + + effective_timeout = self.timeout if timeout is None else timeout + + async with self._lock: + return await asyncio.wait_for( + self._send_command_no_lock(command), + timeout=effective_timeout, + ) + + async def _send_command_no_lock(self, command: str) -> List[str]: + assert self._writer is not None + logger.debug("[microspin] >>> %s", command) + self._writer.write((command + "\r\n").encode("ascii")) + await self._writer.drain() + + # Stage 2: ACK! + ack = await self._readline() + m = _ACK_RE.match(ack) + if not m: + raise MicroSpinProtocolError(f"Expected ACK!, got {ack!r}") + command_id = int(m.group("id")) + logger.debug("[microspin] <<< ACK id=%d", command_id) + + # Stages 3+4: data lines until OK!/ERROR! + end_re = re.compile(rf"^(?POK!|ERROR!)\s+.*\s+{command_id}\s*$") + data: List[str] = [] + while True: + line = await self._readline() + end = end_re.match(line) + if end: + if end.group("status") == "OK!": + logger.debug("[microspin] <<< OK (%d data lines)", len(data)) + return data + raise MicroSpinError(command, command_id, data) + data.append(line) + + # ------------------------------ CentrifugeBackend abstract methods -------- + + async def open_door(self) -> None: + """Open the plate-loading door. Sends the firmware ``od`` command.""" + await self.send_command("od") + + async def close_door(self) -> None: + """Close the plate-loading door. Sends the firmware ``cd`` command.""" + await self.send_command("cd") + + async def go_to_bucket1(self) -> None: + """Present bucket 1 at the load position. + + Note that on the MicroSpin the ``open `` command also *opens the + door* as a side effect; this is the only way to position a bucket for + loading. + """ + await self.send_command("open 1", timeout=max(self.timeout, 60.0)) + + async def go_to_bucket2(self) -> None: + """Present bucket 2 at the load position (also opens the door).""" + await self.send_command("open 2", timeout=max(self.timeout, 60.0)) + + # The four lock/unlock primitives below are declared abstract by + # CentrifugeBackend, but on the MicroSpin they are firmware-internal + # maintenance commands (see manual §6.7) that the higher-level commands + # (``open ``, ``spin``, ``home``) already handle automatically. We + # deliberately do NOT forward them to the wire so callers can't put the + # device into a half-managed state by issuing them out-of-band. If you + # really need to drive the underlying ``lockdoor`` / ``unlockdoor`` / + # ``locknest`` / ``unlocknest`` commands (e.g. for service), use + # :meth:`send_command` directly. + + async def lock_door(self) -> None: # pragma: no cover -- always raises + """Not supported on the MicroSpin: door locking is firmware-managed. + + Always raises :class:`NotImplementedError`. The MicroSpin firmware locks + the door automatically as part of ``spin``; the underlying ``lockdoor`` + wire command is documented as maintenance-only in manual §6.7 and is + deliberately not exposed here. If you really need to drive it, use + ``backend.send_command("lockdoor")`` directly. + """ + raise NotImplementedError( + "Door locking is handled automatically by the MicroSpin firmware as " + "part of `spin`; the standalone `lockdoor` command is a maintenance " + "primitive (manual §6.7) and is not exposed through pylabrobot. If " + "you really need it, use `backend.send_command('lockdoor')`." + ) + + async def unlock_door(self) -> None: # pragma: no cover -- always raises + """Not supported on the MicroSpin: door unlocking is firmware-managed. + + Always raises :class:`NotImplementedError`. See :meth:`lock_door`. + """ + raise NotImplementedError( + "Door unlocking is handled automatically by the MicroSpin firmware " + "as part of `open `; the standalone `unlockdoor` command is " + "a maintenance primitive (manual §6.7) and is not exposed through " + "pylabrobot. If you really need it, use " + "`backend.send_command('unlockdoor')`." + ) + + async def lock_bucket(self) -> None: # pragma: no cover -- always raises + """Not supported on the MicroSpin: nest locking is firmware-managed. + + Always raises :class:`NotImplementedError`. See :meth:`lock_door`. + """ + raise NotImplementedError( + "Nest locking is handled automatically by the MicroSpin firmware as " + "part of `open `; the standalone `locknest` command is a " + "maintenance primitive (manual §6.7) and is not exposed through " + "pylabrobot. If you really need it, use " + "`backend.send_command('locknest')`." + ) + + async def unlock_bucket(self) -> None: # pragma: no cover -- always raises + """Not supported on the MicroSpin: nest unlocking is firmware-managed. + + Always raises :class:`NotImplementedError`. See :meth:`lock_door`. + """ + raise NotImplementedError( + "Nest unlocking is handled automatically by the MicroSpin firmware " + "as part of `spin`; the standalone `unlocknest` command is a " + "maintenance primitive (manual §6.7) and is not exposed through " + "pylabrobot. If you really need it, use " + "`backend.send_command('unlocknest')`." + ) + + async def spin( + self, + g: float, + duration: float, + acceleration: float = 0.5, + deceleration: float = 0.5, + ) -> None: + """Start a spin cycle on the MicroSpin. + + Args: + g: Relative centrifugal force in ``×g``. Must be in ``[1, 3000]`` + (3000 ``×g`` is the spec maximum per manual §11). + duration: Time at speed in seconds. Must be ``>= 1``. + acceleration: Acceleration ramp as a fraction of the machine maximum, + in ``(0, 1]``. Internally converted to the integer percent the + firmware expects. + deceleration: Deceleration ramp as a fraction of the machine maximum, + in ``(0, 1]``. The MicroSpin firmware uses a fast decel curve above + ``CENTRIFUGE_DECEL_THRESHOLD_G`` (default 300 ``×g``) and a slow one + below, so the *effective* decel rate may vary across the run. + + Raises: + ValueError: If any argument is out of range. + MicroSpinError: If the device rejects the spin (e.g. door not closed, + device not homed, imbalance trip). + + Warns: + UserWarning: If ``g`` is below + :attr:`LOW_G_WARNING_THRESHOLD` (30 ×g by default). At very low + G-forces the spindle's "stopped" sensor sometimes fails to latch at + the end of the spin, so the firmware never emits the final ``OK!`` + completion line. From the client's point of view the command + appears to hang indefinitely; subsequent commands will time out + because the firmware still considers a spin in progress. If you + hit this, the recovery path is :meth:`abort` followed by + :meth:`clear_button_abort` (and possibly a power-cycle). + + Safety: + See the module-level docstring for the pre-spin checklist. This method + does NOT verify physical conditions; it only validates argument ranges. + """ + if not 1 <= g <= self.MAX_G_FORCE: + raise ValueError(f"g must be in [1, {self.MAX_G_FORCE}] ×g, got {g}") + if duration < 1: + raise ValueError(f"duration must be at least 1 second, got {duration}") + if not 0 < acceleration <= 1: + raise ValueError(f"acceleration must be in (0, 1], got {acceleration}") + if not 0 < deceleration <= 1: + raise ValueError(f"deceleration must be in (0, 1], got {deceleration}") + + if g < self.LOW_G_WARNING_THRESHOLD: + warnings.warn( + f"Spinning the MicroSpin at g={g} (<{self.LOW_G_WARNING_THRESHOLD} ×g) " + "is known to occasionally hang the firmware: the spindle-stopped " + "sensor may fail to latch, so no `OK!` is emitted and subsequent " + "commands will time out. If this happens, call `abort()` followed " + "by `clear_button_abort()`, and power-cycle if the device stays stuck.", + UserWarning, + stacklevel=2, + ) + + g_int = int(round(g)) + duration_int = int(round(duration)) + accel_pct = max(1, min(100, int(round(acceleration * 100)))) + decel_pct = max(1, min(100, int(round(deceleration * 100)))) + + # The spin command completes when the rotor has fully decelerated and the + # "spindle stopped" sensor latches. Generous padding on top of the user's + # `duration` is needed to cover both ramps plus the sensor settle window. + spin_timeout = max(self.timeout, duration + 180.0) + await self.send_command( + f"spin {g_int} {accel_pct} {decel_pct} {duration_int}", + timeout=spin_timeout, + ) + + # ------------------------------ MicroSpin-specific helpers ---------------- + + async def home(self) -> None: + """Home both axes (door and spindle). + + The MicroSpin needs to be homed once after every power-cycle before any + spin or bucket-positioning command is accepted. + """ + await self.send_command("home", timeout=max(self.timeout, 120.0)) + + async def is_homed(self) -> bool: + """Return ``True`` if the device reports ``homed`` to ``hss``.""" + data = await self.send_command("hss") + return bool(data) and data[0].strip().lower() == "homed" + + async def abort(self, *, timeout: Optional[float] = None) -> None: + """Decelerate the rotor and stop the current operation. + + After ``abort``, the firmware enters an aborted state that blocks further + motion commands until you call :meth:`clear_button_abort`. + + Args: + timeout: Override the per-command timeout. A full decel from 3000 ×g on + the slow-decel curve can take well over a minute, so by default this + uses ``max(self.timeout, 180s)``. + """ + effective = max(self.timeout, 180.0) if timeout is None else timeout + await self.send_command("abort", timeout=effective) + + async def clear_button_abort(self) -> None: + """Clear the abort state (resets the latch set by ``abort`` or the front + panel button).""" + await self.send_command("clearbuttonabort") + + async def reset( + self, + *, + abort_timeout: Optional[float] = None, + settle_timeout: Optional[float] = None, + swallow_abort_errors: bool = True, + wait_for_settle: bool = True, + ) -> Optional[Dict[str, str]]: + """Bring the device back to a clean, ready-to-command state. + + Issues the canonical recovery sequence: + + 1. :meth:`abort` -- request a decel + stop. + 2. :meth:`clear_button_abort` -- release the latched abort state so that + subsequent motion commands (``home``, ``open``, ``spin``, ...) are + accepted again. + 3. :meth:`wait_for_spindle_stopped` -- a single ``status`` call that the + firmware will not answer until the rotor is genuinely stopped. + + Steps 1 and 2 both return ``OK!`` *immediately* on the wire -- they are + just acknowledgements of the request, not confirmation that motion has + ceased. Step 3 is the real "we are stopped" gate, because the firmware + queues a ``status`` request behind any active motion and only answers + once that motion completes. + + Args: + abort_timeout: Override the timeout for the ``abort`` step. Pass a + generous value if you've configured a very tight backend timeout. + Defaults to :meth:`abort`'s own default. + settle_timeout: Override the timeout for the final ``status`` poll + that waits for the spindle to actually spin down. Defaults to + :meth:`wait_for_spindle_stopped`'s own default (``max(self.timeout, + 300s)``). + swallow_abort_errors: If ``True`` (the default), errors raised by the + ``abort`` step are logged and ignored so that + :meth:`clear_button_abort` is still attempted. This is usually what + you want for a recovery routine -- e.g. ``abort`` can legitimately + fail with "nothing to abort" depending on firmware state. Set this + to ``False`` if you want any abort failure to propagate. + wait_for_settle: If ``True`` (the default), block on step 3 and + return the final status dict. If ``False``, skip step 3 and return + ``None`` immediately after :meth:`clear_button_abort` -- useful + when you only want to clear the firmware's abort latch and don't + care whether the rotor has stopped yet. + + Returns: + The parsed status report from step 3 (a ``{field: value}`` dict), or + ``None`` if ``wait_for_settle=False``. + + Raises: + MicroSpinError: If :meth:`clear_button_abort` fails, or if the final + ``status`` poll returns ``ERROR!``. Either case indicates the + device is in a state that the normal recovery sequence can't get + out of -- a power-cycle is usually required at that point. + asyncio.TimeoutError: If the rotor doesn't stop within + ``settle_timeout`` (i.e. the spindle is genuinely stuck). + + Note: + ``reset`` does NOT verify that the resulting state is *homed*. Follow + up with :meth:`is_homed` (and re-:meth:`home` if needed) before + issuing the next motion command. The MicroSpin firmware also keeps a + persistent error stack which is unaffected by reset; use + :meth:`get_errors` to inspect it. + """ + try: + await self.abort(timeout=abort_timeout) + except MicroSpinError as exc: + if not swallow_abort_errors: + raise + logger.info( + "[microspin] abort during reset() returned ERROR! (swallowed): %s", + exc, + ) + await self.clear_button_abort() + if not wait_for_settle: + return None + return await self.wait_for_spindle_stopped(timeout=settle_timeout) + + async def get_status(self, *, timeout: Optional[float] = None) -> Dict[str, str]: + """Return the device's status report as a ``{field: value}`` dict. + + Typical fields include ``Spindle Position`` and ``Door Position``. + + Note: + When a spin or decel is in progress, the MicroSpin firmware will not + respond to ``status`` until the rotor is fully stopped. ``status`` is + therefore a convenient synchronous "are we really stopped yet?" gate; + see :meth:`wait_for_spindle_stopped` and :meth:`reset` for callers + that exploit this. + + Args: + timeout: Override the per-command timeout. The default (None) uses + ``self.timeout`` which may be too short if a spin-down is in + progress -- pass a generous value (e.g. 300 s) in that case. + """ + data = await self.send_command("status", timeout=timeout) + return _parse_kv_lines(data) + + async def wait_for_spindle_stopped( + self, + *, + timeout: Optional[float] = None, + ) -> Dict[str, str]: + """Block until the firmware confirms the rotor is fully stopped. + + Sends a single ``status`` command, which the firmware queues behind any + in-progress motion. When that motion completes, the firmware emits the + status report and ``OK!`` -- at which point we know the rotor is + mechanically stopped from the controller's point of view. + + Args: + timeout: Override the per-command timeout. Defaults to + ``max(self.timeout, 300s)`` which covers a full decel from 3000 ×g + on the slow-decel curve. + + Returns: + The parsed status report ({key: value} dict, same as + :meth:`get_status`). + """ + effective = max(self.timeout, 300.0) if timeout is None else timeout + return await self.get_status(timeout=effective) + + async def get_version(self) -> Dict[str, str]: + """Return the firmware/library version report as a ``{field: value}`` dict.""" + data = await self.send_command("version") + return _parse_kv_lines(data) + + async def get_errors(self, n: int = 10) -> List[str]: + """Return the top ``n`` entries from the device's error stack.""" + return await self.send_command(f"errors {int(n)}") + + +def _parse_kv_lines(lines: List[str]) -> Dict[str, str]: + """Parse ``key: value`` style report lines into a dict.""" + out: Dict[str, str] = {} + for line in lines: + if ":" in line: + key, _, value = line.partition(":") + out[key.strip()] = value.strip() + return out diff --git a/pylabrobot/centrifuge/highres/microspin_tests.py b/pylabrobot/centrifuge/highres/microspin_tests.py new file mode 100644 index 00000000000..08c6a61318b --- /dev/null +++ b/pylabrobot/centrifuge/highres/microspin_tests.py @@ -0,0 +1,344 @@ +"""Edge-case tests for :class:`MicroSpinBackend` using a stub asyncio stream +pair. + +End-to-end wire-level behaviour (command mappings, status-blocks-during-motion, +reset happy path, etc.) lives in :mod:`mock_server_tests` and runs against the +real :class:`MicroSpinMockServer` over a TCP socket. The tests in *this* file +only cover situations the mock server cannot reasonably reproduce: + +* Malformed protocol bytes from the device (bad ACK line, EOF mid-response). +* Operating without :meth:`MicroSpinBackend.setup`. +* Specific ``ERROR!`` sequences whose exact text we want to assert on. +* Argument validation that never reaches the wire. +* Monkey-patched ``send_command`` for verifying timeout extension logic. +* Serialization (no socket). +* Reset's error-handling branches, which need precisely-controllable + ``ERROR!`` responses from each step. +""" + +from __future__ import annotations + +import unittest +import warnings +from typing import List, Tuple + +from pylabrobot.centrifuge.highres.microspin_backend import ( + MicroSpinBackend, + MicroSpinError, + MicroSpinProtocolError, +) + + +class _FakeWriter: + """Captures everything written by the backend so tests can assert on it.""" + + def __init__(self) -> None: + self.sent: bytearray = bytearray() + self.closed = False + + def write(self, data: bytes) -> None: + self.sent.extend(data) + + async def drain(self) -> None: + return None + + def close(self) -> None: + self.closed = True + + async def wait_closed(self) -> None: + return None + + +class _FakeReader: + """Yields a queue of lines one ``readline()`` at a time.""" + + def __init__(self, lines: List[bytes]) -> None: + self._lines: List[bytes] = list(lines) + + async def readline(self) -> bytes: + if not self._lines: + return b"" # simulate EOF + return self._lines.pop(0) + + +def _make_backend(server_lines: List[str]) -> Tuple[MicroSpinBackend, _FakeWriter]: + """Build a backend pre-wired to a fake reader/writer pair. No real socket.""" + backend = MicroSpinBackend(host="ignored", port=0, timeout=2.0) + writer = _FakeWriter() + reader = _FakeReader([s.encode("ascii") for s in server_lines]) + backend._writer = writer # type: ignore[assignment] + backend._reader = reader # type: ignore[assignment] + return backend, writer + + +def _sent_commands(writer: _FakeWriter) -> List[str]: + text = writer.sent.decode("ascii") + return [line for line in text.split("\r\n") if line] + + +class MicroSpinProtocolEdgeCaseTests(unittest.IsolatedAsyncioTestCase): + """Cases the real mock server cannot easily produce (malformed bytes / EOF).""" + + async def test_protocol_error_on_bad_ack(self): + backend, _ = _make_backend(["GARBAGE\r\n"]) + with self.assertRaises(MicroSpinProtocolError): + await backend.send_command("status") + + async def test_connection_closed_mid_response(self): + backend, _ = _make_backend(["ACK! status 1\r\n"]) # no terminator, then EOF + with self.assertRaises(ConnectionError): + await backend.send_command("status") + + async def test_setup_required_before_send_command(self): + backend = MicroSpinBackend(host="ignored", port=0) + with self.assertRaises(RuntimeError): + await backend.send_command("status") + + async def test_error_response_carries_diagnostic_lines(self): + backend, _ = _make_backend( + [ + "ACK! spin 0 0 0 1 19\r\n", + "Error 1: (00:00:01) -12: bad params\r\n", + "ERROR! spin 0 0 0 1 19\r\n", + ] + ) + with self.assertRaises(MicroSpinError) as cm: + await backend.send_command("spin 0 0 0 1") + self.assertEqual(cm.exception.command_id, 19) + self.assertEqual(cm.exception.command, "spin 0 0 0 1") + self.assertEqual(cm.exception.error_lines, ["Error 1: (00:00:01) -12: bad params"]) + + +class MicroSpinValidationTests(unittest.IsolatedAsyncioTestCase): + """Argument validation that raises before any bytes hit the wire.""" + + async def test_spin_rounds_and_clamps_percents(self): + backend, writer = _make_backend( + [ + "ACK! spin 250 1 100 5 9\r\n", + "OK! spin 250 1 100 5 9\r\n", + ] + ) + # 0.004 rounds to 0; we clamp to a minimum of 1%. + await backend.spin(g=250.4, duration=5.2, acceleration=0.004, deceleration=1.0) + self.assertEqual(_sent_commands(writer), ["spin 250 1 100 5"]) + + async def test_spin_rejects_out_of_range_g(self): + backend, _ = _make_backend([]) + for bad in [-1, 0, 3001, 100000]: + with self.assertRaises(ValueError): + await backend.spin(g=bad, duration=10) + + async def test_spin_rejects_short_duration(self): + backend, _ = _make_backend([]) + with self.assertRaises(ValueError): + await backend.spin(g=100, duration=0.5) + + async def test_spin_rejects_bad_acceleration(self): + backend, _ = _make_backend([]) + for bad in [0, -0.1, 1.1, 2.0]: + with self.assertRaises(ValueError): + await backend.spin(g=100, duration=10, acceleration=bad) + + async def test_spin_rejects_bad_deceleration(self): + backend, _ = _make_backend([]) + for bad in [0, -0.1, 1.1, 2.0]: + with self.assertRaises(ValueError): + await backend.spin(g=100, duration=10, deceleration=bad) + + async def test_spin_warns_below_low_g_threshold(self): + backend, writer = _make_backend( + [ + "ACK! spin 20 50 50 5 1\r\n", + "OK! spin 20 50 50 5 1\r\n", + ] + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await backend.spin(g=20, duration=5) + low_g_warnings = [ + w for w in caught if issubclass(w.category, UserWarning) and "×g" in str(w.message) + ] + self.assertEqual(len(low_g_warnings), 1) + self.assertEqual(_sent_commands(writer), ["spin 20 50 50 5"]) + + async def test_spin_does_not_warn_at_or_above_threshold(self): + backend, _ = _make_backend( + [ + "ACK! spin 30 50 50 5 1\r\n", + "OK! spin 30 50 50 5 1\r\n", + ] + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await backend.spin(g=30, duration=5) + low_g_warnings = [ + w for w in caught if issubclass(w.category, UserWarning) and "×g" in str(w.message) + ] + self.assertEqual(low_g_warnings, []) + + async def test_maintenance_lock_methods_raise_not_implemented(self): + backend, writer = _make_backend([]) + for method_name in ("lock_door", "unlock_door", "lock_bucket", "unlock_bucket"): + with self.assertRaises(NotImplementedError): + await getattr(backend, method_name)() + self.assertEqual(_sent_commands(writer), []) + + +class MicroSpinHelperEdgeCaseTests(unittest.IsolatedAsyncioTestCase): + """Helper behaviour with carefully-shaped responses we don't get from the mock.""" + + async def test_get_errors_returns_lines_verbatim(self): + backend, _ = _make_backend( + [ + "ACK! errors 3 4\r\n", + "Error 1: foo\r\n", + "Error 2: bar\r\n", + "Error 3: baz\r\n", + "OK! errors 3 4\r\n", + ] + ) + self.assertEqual( + await backend.get_errors(3), + ["Error 1: foo", "Error 2: bar", "Error 3: baz"], + ) + + +class MicroSpinResetErrorPathTests(unittest.IsolatedAsyncioTestCase): + """Reset's error-handling branches, which need controllable ERROR! at each step.""" + + async def test_reset_swallows_abort_error_by_default(self): + # The MicroSpin can legitimately reject an abort if there's nothing to abort. + # `reset` should still go on to clear the button-abort latch AND wait. + backend, writer = _make_backend( + [ + "ACK! abort 1\r\n", + "Error 1: nothing to abort\r\n", + "ERROR! abort 1\r\n", + "ACK! clearbuttonabort 2\r\n", + "OK! clearbuttonabort 2\r\n", + "ACK! status 3\r\n", + "Spindle Position: 0\r\n", + "OK! status 3\r\n", + ] + ) + result = await backend.reset() + self.assertEqual(_sent_commands(writer), ["abort", "clearbuttonabort", "status"]) + self.assertEqual(result, {"Spindle Position": "0"}) + + async def test_reset_propagates_abort_error_when_asked(self): + backend, writer = _make_backend( + [ + "ACK! abort 1\r\n", + "Error 1: nothing to abort\r\n", + "ERROR! abort 1\r\n", + ] + ) + with self.assertRaises(MicroSpinError): + await backend.reset(swallow_abort_errors=False) + self.assertEqual(_sent_commands(writer), ["abort"]) + + async def test_reset_propagates_clear_button_abort_error(self): + backend, writer = _make_backend( + [ + "ACK! abort 1\r\n", + "OK! abort 1\r\n", + "ACK! clearbuttonabort 2\r\n", + "Error 1: stuck\r\n", + "ERROR! clearbuttonabort 2\r\n", + ] + ) + with self.assertRaises(MicroSpinError): + await backend.reset() + self.assertEqual(_sent_commands(writer), ["abort", "clearbuttonabort"]) + + async def test_reset_propagates_status_error(self): + backend, writer = _make_backend( + [ + "ACK! abort 1\r\n", + "OK! abort 1\r\n", + "ACK! clearbuttonabort 2\r\n", + "OK! clearbuttonabort 2\r\n", + "ACK! status 3\r\n", + "Error 1: spindle wedged\r\n", + "ERROR! status 3\r\n", + ] + ) + with self.assertRaises(MicroSpinError): + await backend.reset() + self.assertEqual(_sent_commands(writer), ["abort", "clearbuttonabort", "status"]) + + +class MicroSpinTimeoutExtensionTests(unittest.IsolatedAsyncioTestCase): + """Monkey-patched send_command to verify long-motion timeout extension.""" + + async def test_abort_uses_extended_default_timeout(self): + backend = MicroSpinBackend(host="ignored", port=0, timeout=2.0) + seen: list = [] + + async def fake_send(cmd, *, timeout=None): + seen.append((cmd, timeout)) + return [] + + backend.send_command = fake_send # type: ignore[assignment] + await backend.abort() + self.assertEqual(seen, [("abort", 180.0)]) + + seen.clear() + await backend.abort(timeout=5.0) + self.assertEqual(seen, [("abort", 5.0)]) + + async def test_wait_for_spindle_stopped_uses_extended_default_timeout(self): + backend = MicroSpinBackend(host="ignored", port=0, timeout=2.0) + seen: list = [] + + async def fake_send(cmd, *, timeout=None): + seen.append((cmd, timeout)) + return [] + + backend.send_command = fake_send # type: ignore[assignment] + await backend.wait_for_spindle_stopped() + self.assertEqual(seen, [("status", 300.0)]) + + seen.clear() + await backend.wait_for_spindle_stopped(timeout=10.0) + self.assertEqual(seen, [("status", 10.0)]) + + +class MicroSpinConstructorTests(unittest.IsolatedAsyncioTestCase): + def test_default_port_is_1000(self): + """Backend, factory, and the class constant must agree on 1000.""" + from pylabrobot.centrifuge import MicroSpin + + self.assertEqual(MicroSpinBackend.DEFAULT_PORT, 1000) + backend = MicroSpinBackend(host="example.invalid") + self.assertEqual(backend.port, 1000) + cf = MicroSpin(name="x", host="example.invalid") + assert isinstance(cf.backend, MicroSpinBackend) + self.assertEqual(cf.backend.port, 1000) + + def test_port_can_be_customised(self): + from pylabrobot.centrifuge import MicroSpin + + backend = MicroSpinBackend(host="example.invalid", port=9001) + self.assertEqual(backend.port, 9001) + cf = MicroSpin(name="x", host="example.invalid", port=9001) + assert isinstance(cf.backend, MicroSpinBackend) + self.assertEqual(cf.backend.port, 9001) + + +class MicroSpinSerializeTests(unittest.IsolatedAsyncioTestCase): + def test_serialize_includes_connection_info(self): + backend = MicroSpinBackend(host="10.0.0.5", port=1234, timeout=12.5) + s = backend.serialize() + self.assertEqual(s["host"], "10.0.0.5") + self.assertEqual(s["port"], 1234) + self.assertEqual(s["timeout"], 12.5) + + def test_serialize_records_default_port(self): + backend = MicroSpinBackend(host="10.0.0.5") + self.assertEqual(backend.serialize()["port"], 1000) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/centrifuge/highres/mock_server.py b/pylabrobot/centrifuge/highres/mock_server.py new file mode 100644 index 00000000000..18c7d9d092e --- /dev/null +++ b/pylabrobot/centrifuge/highres/mock_server.py @@ -0,0 +1,532 @@ +"""In-process mock of the HighRes MicroSpin TCP/1000 command server. + +This is a faithful-enough re-implementation of the MicroSpin's remote-control +protocol (manual \u00a76.6) to drive :class:`MicroSpinBackend` end-to-end without +a real device. It is intended for: + +* CI / local integration tests that want to exercise the full asyncio socket + path of the backend (not just stubs that replay canned bytes). +* Hand-driving via ``nc`` / ``telnet`` while developing or debugging. +* Reproducing tricky firmware behaviours -- e.g. the "``status`` blocks until + the spindle has truly stopped" gate that :meth:`MicroSpinBackend.reset` + relies on, or the low-G hang we warn about in :meth:`MicroSpinBackend.spin`. + +The server is small and *not* a perfect emulator -- it implements only the +commands pylabrobot uses, plus a few handy ones for diagnostics (``status``, +``hss``, ``errors``, ``version``, ``list``). + +Usage from Python:: + + async with MicroSpinMockServer() as srv: + print(f"listening on {srv.host}:{srv.port}") + backend = MicroSpinBackend(host=srv.host, port=srv.port) + await backend.setup() + ... + +Or as a script:: + + $ python -m pylabrobot.centrifuge.highres.mock_server --port 1000 + # in another shell: + $ nc 127.0.0.1 1000 + home + ACK! home 1 + OK! home 1 +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import time +from dataclasses import dataclass, field +from typing import Awaitable, Callable, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +class _MockError(Exception): + """Raised inside a command handler to produce an ``ERROR!`` terminator.""" + + def __init__(self, error_lines: List[str]): + self.error_lines = list(error_lines) + + +@dataclass +class MockState: + """In-memory model of the MicroSpin's relevant firmware state.""" + + homed: bool = False + spindle_position: int = 0 + door_position: int = -258 # CENTRIFUGE_DOOR_CLOSED from real settings dump + at_bucket: Optional[int] = None # 1, 2, or None + abort_latched: bool = False + spinning: bool = False # True while a spin task is active + current_motion: Optional[asyncio.Task] = None + errors: List[str] = field(default_factory=list) + next_command_id: int = 1 + # The mock's analogue of the real device's spindle-stopped sensor. False + # while motion is in progress; set back to True when motion settles -- + # *unless* `simulate_low_g_hang` is True, in which case the sensor stays + # False and any subsequent `status` waits forever, reproducing the + # firmware bug we warn about in :meth:`MicroSpinBackend.spin`. + spindle_settled: asyncio.Event = field(default_factory=asyncio.Event) + # When True, motion handlers refuse to mark the spindle as settled. + simulate_low_g_hang: bool = False + + def __post_init__(self): + self.spindle_settled.set() # idle to start + + def push_error(self, code: int, message: str) -> None: + """Append an entry to the simulated error stack in the firmware's format.""" + ts = time.strftime("%H:%M:%S", time.gmtime()) + self.errors.append(f"Error {len(self.errors) + 1}: ({ts}) {code}: {message}") + + +class MicroSpinMockServer: + """A localhost TCP server that speaks the MicroSpin remote-control protocol. + + Multiple clients can connect concurrently (the real firmware allows up to + 10); each gets its own command-id counter is *not* shared across clients, + which matches the real device's behaviour. + """ + + def __init__( + self, + host: str = "127.0.0.1", + port: int = 0, + state: Optional[MockState] = None, + ) -> None: + self.host = host + self.port = port # 0 = pick a free port; actual port set in start() + self.state = state or MockState() + self._server: Optional[asyncio.AbstractServer] = None + self._client_tasks: "set[asyncio.Task]" = set() + # Maps motion commands to their simulated dwell time. Tests can override + # this map to make every motion instantaneous, or use the real ramps. + self.motion_dwell: Dict[str, float] = { + "home": 0.05, + "open": 0.05, + "od": 0.02, + "cd": 0.02, + # `spin` computes its own dwell from parameters. + } + + # ---- lifecycle -------------------------------------------------------- + + async def start(self) -> "MicroSpinMockServer": + """Bind the listening socket and begin serving clients. + + If ``self.port`` was ``0`` (the default), the OS picks a free port and + ``self.port`` is updated in-place so callers can read it back. + """ + self._server = await asyncio.start_server(self._handle_client, self.host, self.port) + sock = self._server.sockets[0] + self.host, self.port = sock.getsockname()[:2] + logger.debug("[mock] listening on %s:%d", self.host, self.port) + return self + + async def stop(self) -> None: + """Shut down the server, cancelling any in-flight client handlers. + + Cancelling per-client tasks is necessary because a handler that is + waiting on :attr:`MockState.spindle_settled` would otherwise block + :meth:`asyncio.Server.wait_closed` forever -- this matters in + particular for the low-G hang simulation. + """ + # Cancel any in-progress motion task. + if self.state.current_motion is not None and not self.state.current_motion.done(): + self.state.current_motion.cancel() + # Cancel all in-flight per-client handler tasks, so handlers that are + # blocked waiting on `spindle_settled` (or anything else) can exit and + # let `wait_closed()` complete. Without this, an aborted-but-not-yet- + # settled handler would deadlock `stop()`. + for task in list(self._client_tasks): + if not task.done(): + task.cancel() + if self._server is not None: + self._server.close() + # Give cancelled handlers a chance to exit cleanly. + if self._client_tasks: + await asyncio.gather(*self._client_tasks, return_exceptions=True) + await self._server.wait_closed() + self._server = None + self._client_tasks.clear() + + async def __aenter__(self) -> "MicroSpinMockServer": + return await self.start() + + async def __aexit__(self, *exc) -> None: + await self.stop() + + # ---- per-client loop -------------------------------------------------- + + async def _handle_client( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + addr = writer.get_extra_info("peername") + logger.debug("[mock] client connected: %s", addr) + task = asyncio.current_task() + if task is not None: + self._client_tasks.add(task) + try: + while True: + raw = await reader.readline() + if not raw: + break + cmd_line = raw.rstrip(b"\r\n").decode("ascii", errors="replace").strip() + if not cmd_line: + continue + await self._serve_command(cmd_line, writer) + except (ConnectionError, asyncio.CancelledError): + pass + finally: + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + if task is not None: + self._client_tasks.discard(task) + logger.debug("[mock] client disconnected: %s", addr) + + async def _serve_command(self, cmd_line: str, writer: asyncio.StreamWriter) -> None: + cmd_id = self.state.next_command_id + self.state.next_command_id += 1 + + # Stage 2: ACK + writer.write(f"ACK! {cmd_line} {cmd_id}\r\n".encode("ascii")) + await writer.drain() + + parts = cmd_line.split() + head = parts[0] if parts else "" + args = parts[1:] + + try: + data_lines = await self._dispatch(head, args, cmd_id) + for line in data_lines: + writer.write((line + "\r\n").encode("ascii")) + writer.write(f"OK! {cmd_line} {cmd_id}\r\n".encode("ascii")) + except _MockError as exc: + for err in exc.error_lines: + writer.write((err + "\r\n").encode("ascii")) + # mirror real device: errors also go on the persistent stack + self.state.errors.append(err) + writer.write(f"ERROR! {cmd_line} {cmd_id}\r\n".encode("ascii")) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + logger.exception("[mock] handler crashed for %r", cmd_line) + writer.write(f"Error: internal mock crash: {exc}\r\n".encode("ascii")) + writer.write(f"ERROR! {cmd_line} {cmd_id}\r\n".encode("ascii")) + await writer.drain() + + # ---- command dispatch ------------------------------------------------- + + async def _dispatch( + self, + head: str, + args: List[str], + cmd_id: int, + ) -> List[str]: + aliases = { + "a": "abort", + "cba": "clearbuttonabort", + "d": "disconnect", + "e": "errors", + "hi": "history", + "hss": "homedstatus", + "s": "status", + "sp": "spin", + "v": "version", + "?": "list", + "??": "info", + } + head_canon = aliases.get(head, head) + + handler = self._handlers.get(head_canon) + if handler is None: + raise _MockError([f"Error: Command {head!r} not recognized!"]) + return await handler(self, args, cmd_id) + + # ---------- helpers shared by handlers -------------------------------- + + async def _wait_for_spindle_stopped(self) -> None: + """Block until the spindle-stopped sensor reports settled. + + Reproduces the firmware behaviour where ``status`` (and a handful of + other commands) queue behind active motion. While + :attr:`MockState.simulate_low_g_hang` is True, the settle event is + never set after motion, so this method hangs indefinitely -- the + real-world failure mode we warn about in + :meth:`MicroSpinBackend.spin`. + """ + await self.state.spindle_settled.wait() + + def _begin_motion(self, coro_factory): + """Start a motion coroutine, clearing/setting the spindle-settled flag. + + `coro_factory` is a no-arg callable returning the awaitable that does + the actual simulated motion (sleep + state mutations). + """ + self.state.spindle_settled.clear() + + async def runner(): + try: + await coro_factory() + finally: + if not self.state.simulate_low_g_hang: + self.state.spindle_settled.set() + + self.state.current_motion = asyncio.create_task(runner()) + return self.state.current_motion + + def _require_homed(self) -> None: + if not self.state.homed: + raise _MockError(["Error: device is not homed"]) + + def _require_not_aborted(self) -> None: + if self.state.abort_latched: + raise _MockError(["Error: aborted state latched; call clearbuttonabort"]) + + # ---------- individual command handlers ------------------------------- + + async def _h_version(self, args, cmd_id): + return [ + "Product Name: RandomServe", + "Serial Number: HRB-MOCK-0000001", + "libcommon Revision: 4289", + "libsettings Revision: 2830", + "libsqlite Revision: 3973", + "libts7500dio Revision: 3973", + "Firmware Revision: 4290", + "Version: MS-1.3.3-mock", + "Firmware Build: MOCK1234", + ] + + async def _h_list(self, args, cmd_id): + return [ + "clearbuttonabort, cba - Clear abort state.", + "disconnect, d - Close the current client's connection.", + "errors, e - Display the error stack.", + "help - Show parameter info for a command.", + "info, ?? - List commands with parameter info.", + "list, ? - List available commands.", + "version, v - Software version report.", + "whoami - Return client number.", + "abort, a - Stop current operation.", + "home - Home the system.", + "homedstatus, hss - Whether the device is homed.", + "open - Open the door and present a bucket.", + "spin, sp - Spin the centrifuge.", + "status, s - Return the device status report.", + ] + + async def _h_info(self, args, cmd_id): + return await self._h_list(args, cmd_id) # same surface in the mock + + async def _h_whoami(self, args, cmd_id): + return [str(cmd_id)] + + async def _h_disconnect(self, args, cmd_id): + # The real device closes the connection after ACK; we leave that to the + # caller's stream handling. Just return no data. + return [] + + async def _h_help(self, args, cmd_id): + if not args: + raise _MockError( + ['Error: Abnormal number of parameters (0) for command "help". Min: 1, Max: 1'] + ) + return [f"{args[0]} -- (mock) no detailed help available"] + + async def _h_errors(self, args, cmd_id): + n = int(args[0]) if args else 10 + return list(self.state.errors[-n:]) + + async def _h_homedstatus(self, args, cmd_id): + # `hss` does NOT wait for motion to finish in the real device, so we + # don't either. + return ["homed" if self.state.homed else "not homed"] + + async def _h_status(self, args, cmd_id): + # Crucial: status blocks behind active motion. This is the gate that + # MicroSpinBackend.reset() / wait_for_spindle_stopped() depend on. + await self._wait_for_spindle_stopped() + return [ + f"Spindle Position: {self.state.spindle_position}", + f"Door Position: {self.state.door_position}", + ] + + async def _h_abort(self, args, cmd_id): + motion = self.state.current_motion + if motion is not None and not motion.done(): + motion.cancel() + self.state.abort_latched = True + self.state.spinning = False + return [] + + async def _h_clearbuttonabort(self, args, cmd_id): + self.state.abort_latched = False + return [] + + async def _h_home(self, args, cmd_id): + self._require_not_aborted() + + async def do_home(): + await asyncio.sleep(self.motion_dwell["home"]) + self.state.spindle_position = 1958 # arbitrary "homed" rest position + self.state.door_position = -258 + self.state.homed = True + self.state.at_bucket = None + + task = self._begin_motion(do_home) + try: + await task + except asyncio.CancelledError: + raise _MockError(["Error: home cancelled by abort"]) + return [] + + async def _h_open(self, args, cmd_id): + self._require_not_aborted() + if not args: + raise _MockError( + ['Error: Abnormal number of parameters (0) for command "open". Min: 1, Max: 1'] + ) + try: + bucket = int(args[0]) + except ValueError: + raise _MockError([f"Error: bad bucket: {args[0]!r}"]) + if bucket not in (1, 2): + raise _MockError([f"Error: bucket must be 1 or 2, got {bucket}"]) + self._require_homed() + + async def do_open(): + await asyncio.sleep(self.motion_dwell["open"]) + self.state.at_bucket = bucket + self.state.door_position = 19242 # CENTRIFUGE_DOOR_OPEN-ish + + task = self._begin_motion(do_open) + try: + await task + except asyncio.CancelledError: + raise _MockError(["Error: open cancelled by abort"]) + return [] + + async def _h_od(self, args, cmd_id): + self._require_not_aborted() + + async def do_od(): + await asyncio.sleep(self.motion_dwell["od"]) + self.state.door_position = 19242 + + task = self._begin_motion(do_od) + try: + await task + except asyncio.CancelledError: + raise _MockError(["Error: od cancelled by abort"]) + return [] + + async def _h_cd(self, args, cmd_id): + self._require_not_aborted() + + async def do_cd(): + await asyncio.sleep(self.motion_dwell["cd"]) + self.state.door_position = -258 + self.state.at_bucket = None + + task = self._begin_motion(do_cd) + try: + await task + except asyncio.CancelledError: + raise _MockError(["Error: cd cancelled by abort"]) + return [] + + async def _h_spin(self, args, cmd_id): + if len(args) != 4: + raise _MockError( + [f'Error: Abnormal number of parameters ({len(args)}) for command "spin". Min: 4, Max: 4'] + ) + try: + g, accel, decel, duration = (int(x) for x in args) + except ValueError: + raise _MockError([f"Error: spin params must be integers, got {args}"]) + if not (1 <= g <= 3000): + raise _MockError([f"Error: g out of range: {g}"]) + if duration < 1: + raise _MockError([f"Error: duration too short: {duration}"]) + self._require_homed() + self._require_not_aborted() + if self.state.door_position > 0: # door not closed + raise _MockError(["Error: door must be closed before spin"]) + + # Compute a simulated dwell that scales with duration but is short by + # default so tests don't sleep for a minute. Tests can override. + dwell = self.motion_dwell.get("spin_seconds_per_real_second", 0.005) * duration + + async def do_spin(): + self.state.spinning = True + try: + await asyncio.sleep(dwell) + finally: + self.state.spinning = False + self.state.spindle_position = (self.state.spindle_position + g) % 8192 + + task = self._begin_motion(do_spin) + try: + await task + except asyncio.CancelledError: + raise _MockError(["Error: spin cancelled by abort"]) + return [] + + _handlers: Dict[str, Callable[["MicroSpinMockServer", List[str], int], Awaitable[List[str]]]] + + +# Wire up the handler table (after class body so methods are bound names). +MicroSpinMockServer._handlers = { + "abort": MicroSpinMockServer._h_abort, + "cd": MicroSpinMockServer._h_cd, + "clearbuttonabort": MicroSpinMockServer._h_clearbuttonabort, + "disconnect": MicroSpinMockServer._h_disconnect, + "errors": MicroSpinMockServer._h_errors, + "help": MicroSpinMockServer._h_help, + "home": MicroSpinMockServer._h_home, + "homedstatus": MicroSpinMockServer._h_homedstatus, + "info": MicroSpinMockServer._h_info, + "list": MicroSpinMockServer._h_list, + "od": MicroSpinMockServer._h_od, + "open": MicroSpinMockServer._h_open, + "spin": MicroSpinMockServer._h_spin, + "status": MicroSpinMockServer._h_status, + "version": MicroSpinMockServer._h_version, + "whoami": MicroSpinMockServer._h_whoami, +} + + +# --- CLI entry point ------------------------------------------------------ + + +async def _run_forever(host: str, port: int) -> None: + async with MicroSpinMockServer(host=host, port=port) as srv: + print(f"MicroSpin mock listening on {srv.host}:{srv.port} (Ctrl-C to stop)") + await asyncio.Event().wait() + + +def main() -> None: + """CLI entry point: parse args and run the mock server until interrupted.""" + parser = argparse.ArgumentParser(description="Run the MicroSpin mock server.") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=1000) + parser.add_argument("--verbose", "-v", action="store_true") + ns = parser.parse_args() + logging.basicConfig(level=logging.DEBUG if ns.verbose else logging.INFO) + try: + asyncio.run(_run_forever(ns.host, ns.port)) + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/pylabrobot/centrifuge/highres/mock_server_tests.py b/pylabrobot/centrifuge/highres/mock_server_tests.py new file mode 100644 index 00000000000..a597ba7d5a9 --- /dev/null +++ b/pylabrobot/centrifuge/highres/mock_server_tests.py @@ -0,0 +1,293 @@ +"""End-to-end integration tests for ``MicroSpinBackend`` against the +in-process :class:`MicroSpinMockServer`. + +Unlike :mod:`microspin_tests` (which stubs the asyncio stream pair with +canned bytes), these tests open a real TCP socket from +:meth:`MicroSpinBackend.setup` to a real :class:`asyncio.Server` on +``127.0.0.1``. They are the highest-fidelity tests we can run without a +physical MicroSpin. +""" + +from __future__ import annotations + +import asyncio +import unittest + +from pylabrobot.centrifuge.highres.microspin_backend import ( + MicroSpinBackend, + MicroSpinError, +) +from pylabrobot.centrifuge.highres.mock_server import MicroSpinMockServer + + +class _MockServerTestBase(unittest.IsolatedAsyncioTestCase): + """Shared setup: a fresh mock server + a connected backend per test.""" + + async def asyncSetUp(self): + self.server = MicroSpinMockServer() + await self.server.start() + self.backend = MicroSpinBackend(host=self.server.host, port=self.server.port, timeout=5.0) + await self.backend.setup() + + async def asyncTearDown(self): + await self.backend.stop() + await self.server.stop() + + +class MockServerCommandMappingTests(_MockServerTestBase): + """Verify each pylabrobot backend method produces the expected wire-level + effect on the mock server. These tests *replace* the stub-based mapping + tests that used to live in :mod:`microspin_tests` -- using a real TCP + server gives us higher confidence the wire format and ordering are right. + """ + + async def test_open_door_emits_od(self): + self.assertLess(self.server.state.door_position, 0) + await self.backend.open_door() + self.assertGreater(self.server.state.door_position, 0) + + async def test_close_door_emits_cd(self): + await self.backend.open_door() + await self.backend.close_door() + self.assertLess(self.server.state.door_position, 0) + + async def test_go_to_bucket1_after_home_lands_at_bucket_1(self): + await self.backend.home() + await self.backend.go_to_bucket1() + self.assertEqual(self.server.state.at_bucket, 1) + + async def test_go_to_bucket2_after_home_lands_at_bucket_2(self): + await self.backend.home() + await self.backend.go_to_bucket2() + self.assertEqual(self.server.state.at_bucket, 2) + + async def test_home_sets_homed_flag(self): + self.assertFalse(self.server.state.homed) + await self.backend.home() + self.assertTrue(self.server.state.homed) + self.assertTrue(await self.backend.is_homed()) + + async def test_abort_latches_abort_state(self): + self.assertFalse(self.server.state.abort_latched) + await self.backend.abort() + self.assertTrue(self.server.state.abort_latched) + + async def test_clear_button_abort_clears_latch(self): + await self.backend.abort() + self.assertTrue(self.server.state.abort_latched) + await self.backend.clear_button_abort() + self.assertFalse(self.server.state.abort_latched) + + async def test_spin_formats_parameters_correctly_on_the_wire(self): + """Pin down the float->integer conversion against a real server.""" + # Patch _h_spin to record the args it received. + recorded: list = [] + original = MicroSpinMockServer._h_spin + + async def recording_spin(self, args, cmd_id): # pylint: disable=unused-argument + recorded.append(list(args)) + return await original(self, args, cmd_id) + + self.server._h_spin = recording_spin.__get__( # type: ignore[method-assign] + self.server, MicroSpinMockServer + ) + MicroSpinMockServer._handlers["spin"] = recording_spin + + try: + # Make the spin fast so the test isn't slow. + self.server.motion_dwell["spin_seconds_per_real_second"] = 0.001 + await self.backend.home() + await self.backend.spin(g=100, duration=30, acceleration=0.5, deceleration=0.25) + self.assertEqual(recorded, [["100", "50", "25", "30"]]) + finally: + MicroSpinMockServer._handlers["spin"] = original + + async def test_send_command_for_unknown_command_raises_microspin_error(self): + with self.assertRaises(MicroSpinError): + await self.backend.send_command("this_command_does_not_exist") + + async def test_get_version_round_trip(self): + info = await self.backend.get_version() + self.assertEqual(info["Product Name"], "RandomServe") + self.assertEqual(info["Version"], "MS-1.3.3-mock") + + async def test_get_status_returns_dict_with_positions(self): + status = await self.backend.get_status() + self.assertIn("Spindle Position", status) + self.assertIn("Door Position", status) + + async def test_reset_without_wait_for_settle_returns_none(self): + await self.backend.home() + # With wait_for_settle=False, reset shouldn't call status. + result = await self.backend.reset(wait_for_settle=False) + self.assertIsNone(result) + + +class MockServerIntegrationTests(_MockServerTestBase): + # --- basic protocol round-trips ---------------------------------------- + + async def test_version_round_trip(self): + info = await self.backend.get_version() + self.assertEqual(info["Product Name"], "RandomServe") + self.assertEqual(info["Version"], "MS-1.3.3-mock") + + async def test_homed_status_starts_false(self): + self.assertFalse(await self.backend.is_homed()) + + async def test_home_then_status(self): + await self.backend.home() + self.assertTrue(await self.backend.is_homed()) + status = await self.backend.get_status() + self.assertIn("Spindle Position", status) + self.assertIn("Door Position", status) + + async def test_open_requires_homed(self): + with self.assertRaises(MicroSpinError): + await self.backend.go_to_bucket1() + + async def test_open_after_home_succeeds(self): + await self.backend.home() + await self.backend.go_to_bucket1() + self.assertEqual(self.server.state.at_bucket, 1) + + async def test_spin_requires_homed_and_door_closed(self): + # not homed -> error + with self.assertRaises(MicroSpinError): + await self.backend.spin(g=100, duration=1) + # home, then leave door open via go_to_bucket1 + await self.backend.home() + await self.backend.go_to_bucket1() + with self.assertRaises(MicroSpinError): + await self.backend.spin(g=100, duration=1) + # close door -> spin works + await self.backend.close_door() + await self.backend.spin(g=100, duration=1) + + async def test_unknown_command_errors(self): + with self.assertRaises(MicroSpinError): + await self.backend.send_command("does_not_exist") + + # --- the status-blocking gate (the reason we built this) ---------------- + + async def test_status_blocks_until_motion_completes(self): + """The reason `reset()`'s third step works: status doesn't answer + until the active motion task is done.""" + # Make the home motion noticeably slow so we can race against it. + self.server.motion_dwell["home"] = 0.2 + + async def issue_home(): + await self.backend.home() + + # Use a separate backend connection to send `status` while home is running. + side = MicroSpinBackend(host=self.server.host, port=self.server.port, timeout=5.0) + await side.setup() + try: + home_task = asyncio.create_task(issue_home()) + # Give the home command time to start + await asyncio.sleep(0.02) + # Now ask for status -- this should block until home completes. + t0 = asyncio.get_event_loop().time() + await side.get_status() + elapsed = asyncio.get_event_loop().time() - t0 + await home_task + # status should have waited at least ~0.1s (most of the remaining home dwell) + self.assertGreater(elapsed, 0.05) + finally: + await side.stop() + + # --- reset() and abort flow -------------------------------------------- + + async def test_reset_sequence_against_real_server(self): + await self.backend.home() + result = await self.backend.reset() + # `reset` returns the final status dict, populated from the mock state. + assert result is not None + self.assertIn("Spindle Position", result) + self.assertIn("Door Position", result) + + async def test_abort_interrupts_motion_then_reset_recovers(self): + self.server.motion_dwell["home"] = 0.5 # long home + await self.backend.home() # one home so the state is "homed=True" + + # Now start another motion (open) and abort it mid-way. + self.server.motion_dwell["open"] = 0.3 + open_task = asyncio.create_task(self.backend.go_to_bucket1()) + await asyncio.sleep(0.05) + + side = MicroSpinBackend(host=self.server.host, port=self.server.port, timeout=5.0) + await side.setup() + try: + await side.abort() + # The original open command should have raised because abort cancelled it + with self.assertRaises(MicroSpinError): + await open_task + # The server is now in abort-latched state; further motion should fail + with self.assertRaises(MicroSpinError): + await side.go_to_bucket1() + # reset() clears the latch and waits for the (already-stopped) spindle + await side.reset() + # Motion works again + await side.go_to_bucket1() + self.assertEqual(self.server.state.at_bucket, 1) + finally: + await side.stop() + + +class MockServerLowGHangTests(unittest.IsolatedAsyncioTestCase): + """The mock can simulate the firmware's low-G "stopped sensor never latches" + bug. We use this to verify that callers can recover via a timeout.""" + + async def asyncSetUp(self): + self.server = MicroSpinMockServer() + self.server.state.simulate_low_g_hang = True + await self.server.start() + # Very short timeout so the test doesn't take forever + self.backend = MicroSpinBackend(host=self.server.host, port=self.server.port, timeout=0.5) + await self.backend.setup() + + async def asyncTearDown(self): + await self.backend.stop() + await self.server.stop() + + async def test_status_hangs_after_motion_with_low_g_simulation(self): + await self.backend.send_command("home") # populates state.current_motion + # status would normally answer immediately; in low-G-hang mode it sits. + with self.assertRaises(asyncio.TimeoutError): + await self.backend.get_status() + + +class MockServerCliSmokeTest(unittest.IsolatedAsyncioTestCase): + """A minimal sanity check that the server is usable with raw TCP, the same + way a netcat session would use it.""" + + async def test_raw_tcp_round_trip(self): + async with MicroSpinMockServer() as srv: + reader, writer = await asyncio.open_connection(srv.host, srv.port) + try: + writer.write(b"version\r\n") + await writer.drain() + # ACK! + ack = await reader.readline() + self.assertTrue(ack.startswith(b"ACK! version "), ack) + # data lines + OK! + lines = [] + while True: + line = await reader.readline() + if line.startswith(b"OK! version "): + break + if line.startswith(b"ERROR!"): + self.fail(f"unexpected ERROR: {line!r}") + lines.append(line) + joined = b"".join(lines).decode() + self.assertIn("RandomServe", joined) + self.assertIn("MS-1.3.3-mock", joined) + finally: + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/centrifuge/vspin_backend.py b/pylabrobot/centrifuge/vspin_backend.py index 021a550f359..7198c1eeec1 100644 --- a/pylabrobot/centrifuge/vspin_backend.py +++ b/pylabrobot/centrifuge/vspin_backend.py @@ -1,649 +1,11 @@ -import asyncio -import ctypes -import json -import logging -import math -import os -import time import warnings -from typing import Optional -from pylabrobot.io.ftdi import FTDI - -from .backend import CentrifugeBackend, LoaderBackend -from .standard import LoaderNoPlateError - -logger = logging.getLogger(__name__) - - -class Access2Backend(LoaderBackend): - def __init__( - self, - device_id: str, - timeout: int = 60, - ): - """ - Args: - device_id: The libftdi id for the loader. Find using - `python3 -m pylibftdi.examples.list_devices` - """ - self.io = FTDI(human_readable_device_name="Agilent Access2 Loader", device_id=device_id) - self.timeout = timeout - - async def _read(self) -> bytes: - x = b"" - r = None - start = time.time() - while r != b"" or x == b"": - r = await self.io.read(1) - x += r - if r == b"": - await asyncio.sleep(0.1) - if x == b"" and (time.time() - start) > self.timeout: - raise TimeoutError("No data received within the specified timeout period") - return x - - async def send_command(self, command: bytes) -> bytes: - logger.debug("[loader] Sending %s", command.hex()) - await self.io.write(command) - return await self._read() - - async def setup(self): - logger.debug("[loader] setup") - - await self.io.setup() - await self.io.set_baudrate(115384) - - status = await self.get_status() - if not status.startswith(bytes.fromhex("1105")): - raise RuntimeError("Failed to get status") - - await self.send_command(bytes.fromhex("110500030014000072b1")) - await self.send_command(bytes.fromhex("1105000300100000ae71")) - await self.send_command(bytes.fromhex("110500070024040000008000be89")) - await self.send_command(bytes.fromhex("11050007002404008000800063b1")) - await self.send_command(bytes.fromhex("11050007002404000001800089b9")) - await self.send_command(bytes.fromhex("1105000700240400800180005481")) - await self.send_command(bytes.fromhex("110500070024040000024000c6bd")) - await self.send_command(bytes.fromhex("1105000300400000f0bf")) - await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) - # await self.send_command(bytes.fromhex("11050003002000006bd4")) - await self.send_command(bytes.fromhex("1105000e00440b00000000000000007041020203c7")) - # await self.send_command(bytes.fromhex("11050003002000006bd4")) - - async def stop(self): - logger.debug("[loader] stop") - await self.io.stop() - - def serialize(self): - return {"io": self.io.serialize(), "timeout": self.timeout} - - async def get_status(self) -> bytes: - logger.debug("[loader] get_status") - return await self.send_command(bytes.fromhex("11050003002000006bd4")) - - async def park(self): - logger.debug("[loader] park") - await self.send_command(bytes.fromhex("1105000e00440b0000000000410000704103007539")) - - async def close(self): - logger.debug("[loader] close") - await self.send_command(bytes.fromhex("1105000a00420700010000803f02008c64")) - - async def open(self): - logger.debug("[loader] open") - await self.send_command(bytes.fromhex("1105000a0042070001000080bf0200b73e")) - - async def load(self): - """only tested for 1cm plate, 3mm pickup height""" - logger.debug("[loader] load") - - await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) - await self.send_command(bytes.fromhex("1105000e00440b000100004040000020410200a5cb")) - - # laser check - r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) - if r == bytes.fromhex("1105000800510500000300000079f1"): - raise LoaderNoPlateError("no plate found on stage") - - await self.send_command(bytes.fromhex("1105000a00460700018fc2b540020023dc")) - await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410300ee00")) - await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) - await self.send_command(bytes.fromhex("1105000e00440b0000000040400000204102007d82")) - - async def unload(self): - """only tested for 1cm plate, 3mm pickup height""" - logger.debug("[loader] unload") - - await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) - await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410200dd31")) - - # laser check - r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) - if r == bytes.fromhex("1105000800510500000300000079f1"): - raise LoaderNoPlateError("no plate found in centrifuge") - - await self.send_command(bytes.fromhex("1105000a00460700017b14b6400200d57a")) - await self.send_command(bytes.fromhex("1105000e00440b00010000404000002041030096fa")) - await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) - await self.send_command(bytes.fromhex("1105000e00440b00000000000000002041020056be")) - # await self.send_command(bytes.fromhex("11050003002000006bd4")) - - -_vspin_bucket_calibrations_path = os.path.join( - os.path.expanduser("~"), - ".pylabrobot", - "vspin_bucket_calibrations.json", +from .agilent.vspin_backend import ( # noqa: F401 + Access2Backend, + VSpinBackend, ) - -def _load_vspin_calibrations(device_id: str) -> Optional[int]: - if not os.path.exists(_vspin_bucket_calibrations_path): - warnings.warn( - f"No calibration found for VSpin with device id {device_id}. " - "Please set the bucket 1 position using `set_bucket_1_position_to_current` method after setup.", - UserWarning, - ) - return None - with open(_vspin_bucket_calibrations_path, "r") as f: - return json.load(f).get(device_id) # type: ignore - - -def _save_vspin_calibrations(device_id, remainder: int): - if os.path.exists(_vspin_bucket_calibrations_path): - with open(_vspin_bucket_calibrations_path, "r") as f: - data = json.load(f) - else: - data = {} - data[device_id] = remainder - os.makedirs(os.path.dirname(_vspin_bucket_calibrations_path), exist_ok=True) - with open(_vspin_bucket_calibrations_path, "w") as f: - json.dump(data, f) - - -FULL_ROTATION: int = 8000 - - -bucket_1_not_set_error = RuntimeError( - "Bucket 1 position not set. " - "Please rotate the bucket to bucket 1 using VSpinBackend.go_to_position and " - "then calling VSpinBackend.set_bucket_1_position_to_current." +warnings.warn( + "pylabrobot.centrifuge.vspin_backend is deprecated and will be removed in a future release. " + "Please use pylabrobot.centrifuge.agilent.vspin_backend instead.", ) - - -class VSpinBackend(CentrifugeBackend): - """Backend for the Agilent Centrifuge. - Note that this is not a complete implementation.""" - - def __init__(self, device_id: Optional[str] = None): - """ - Args: - device_id: The libftdi id for the centrifuge. Find using `python -m pylibftdi.examples.list_devices` - """ - self.io = FTDI(human_readable_device_name="Agilent VSpin Centrifuge", device_id=device_id) - self._bucket_1_remainder: Optional[int] = None - # only attempt loading calibration if device_id is not None - # if it is None, we will load it after setup when we can query the device id from the io - if device_id is not None: - self._bucket_1_remainder = _load_vspin_calibrations(device_id) - - async def setup(self): - await self.io.setup() - # TODO: add functionality where if robot has been initialized before nothing needs to happen - for _ in range(3): - await self.configure_and_initialize() - await self._send_command(bytes.fromhex("aa002101ff21")) - await self._send_command(bytes.fromhex("aa002101ff21")) - await self._send_command(bytes.fromhex("aa01132034")) - await self._send_command(bytes.fromhex("aa002102ff22")) - await self._send_command(bytes.fromhex("aa02132035")) - await self._send_command(bytes.fromhex("aa002103ff23")) - await self._send_command(bytes.fromhex("aaff1a142d")) - - await self.io.set_baudrate(57600) - await self.io.set_rts(True) - await self.io.set_dtr(True) - - await self._send_command(bytes.fromhex("aa01121f32")) - for _ in range(8): - await self._send_command(bytes.fromhex("aa0220ff0f30")) - await self._send_command(bytes.fromhex("aa0220df0f10")) - await self._send_command(bytes.fromhex("aa0220df0e0f")) - await self._send_command(bytes.fromhex("aa0220df0c0d")) - await self._send_command(bytes.fromhex("aa0220df0809")) - for _ in range(4): - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa02120317")) - for _ in range(5): - await self._send_command(bytes.fromhex("aa0226200048")) - await self._send_command(bytes.fromhex("aa0226000028")) - await self.lock_door() - - await self._send_command(bytes.fromhex("aa0226000028")) - - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa010001")) - await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) - await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) - await self._send_command(bytes.fromhex("aa01192842")) - - resp = 0x89 - while resp == 0x89: - resp = (await self._get_positions_and_tachometer()).status - - # --- almost the same as go to position --- - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - new_position = (0).to_bytes(4, byteorder="little") # arbitrary - # rpm = 600, - # acceleration = 75.09289617486338 - await self._send_command( - bytes.fromhex("aa01d497") + new_position + bytes.fromhex("c3f52800d71a000049") - ) - # ----------------------------------------- - - resp = 0x08 - while resp != 0x09: - resp = (await self._get_positions_and_tachometer()).status - - await self._send_command(bytes.fromhex("aa0117021a")) - - await self.lock_door() - - # If we have not set the calibration yet, load it now. - if self._bucket_1_remainder is None: - device_id = await self.io.get_serial() - self._bucket_1_remainder = _load_vspin_calibrations(device_id) - - @property - def bucket_1_remainder(self) -> int: - if self._bucket_1_remainder is None: - raise bucket_1_not_set_error - return self._bucket_1_remainder - - async def set_bucket_1_position_to_current(self) -> None: - """Set the current position as bucket 1 position and save calibration.""" - current_position = await self.get_position() - device_id = await self.io.get_serial() - remainder = await self.get_home_position() - current_position - self._bucket_1_remainder = current_position % FULL_ROTATION - _save_vspin_calibrations(device_id, remainder) - - async def get_bucket_1_position(self) -> int: - """Get the bucket 1 position based on calibration. - Normally it is the home position minus the remainder (calibration). - The bucket 1 position must be greater than the current position, so we find - the first position greater than the current position by adding full rotations if needed. - """ - if self._bucket_1_remainder is None: - raise bucket_1_not_set_error - home_position = await self.get_home_position() - bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder - # first number after current position that matches bucket 1 position mod FULL_ROTATION - current_position = await self.get_position() - bucket_1_position = ( - FULL_ROTATION - * math.floor((current_position - bucket_1_position_mod_full_rotation) / FULL_ROTATION + 1) - + bucket_1_position_mod_full_rotation - ) - return bucket_1_position - - async def stop(self): - await self.configure_and_initialize() - await self.io.stop() - - class _StatusPositionTachometer(ctypes.LittleEndianStructure): - _pack_ = 1 - _fields_ = [ - ("status", ctypes.c_uint8), - ("current_position", ctypes.c_uint32), - ("unknown1", ctypes.c_uint8), - ("tachometer", ctypes.c_int16), - ("unknown2", ctypes.c_uint8), - ("home_position", ctypes.c_uint32), - ("checksum", ctypes.c_uint8), - ] - - async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: - """Returns 14 bytes - - Example: - 11 22 25 00 00 4f 00 00 18 e0 05 00 00 a4 - ^^ checksum - ^^ ^^ ^^ ^^ home position - ^^ ? (probably binary status objects) - ^^ ^^ tachometer - ^^ ? (probably binary status objects) - ^^ ^^ ^^ ^^ current position - ^^ - - First byte (index 0): - - 11 = 0b0001011 = idle - - 13 = 0b0001101 = unknown - - 08 = 0b0001000 = spinning - - 09 = 0b0001001 = also spinning but different - - 19 = 0b0010011 = unknown - - 88 = 0b1011000 = unknown - - 89 = 0b1011001 = unknown - - 10th to 13th byte (index 9-12) = Homing Position - - Last byte (index 13) = checksum - """ - resp = await self._send_command(bytes.fromhex("aa010e0f")) - if len(resp) == 0: - raise IOError("Empty status from centrifuge") - return VSpinBackend._StatusPositionTachometer.from_buffer_copy(resp) - - async def get_position(self) -> int: - return (await self._get_positions_and_tachometer()).current_position # type: ignore - - async def get_tachometer(self) -> int: - """current speed in rpm""" - tack_to_rpm = -14.69320388 # R^2 = 0.9999 when spinning, but not specific at single-digit RPM - return (await self._get_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore - - async def get_home_position(self) -> int: - """changes during a run, but the bucket 1 position relative to it does not""" - return (await self._get_positions_and_tachometer()).home_position # type: ignore - - async def _get_status(self): - """ - examples: - - 0080d0015 - - 0080f0015 - """ - - resp = await self._send_command(bytes.fromhex("aa020e10")) - if len(resp) == 0: - raise IOError("Empty status from centrifuge. Is the machine on?") - return resp - - async def get_bucket_locked(self) -> bool: - resp = await self._get_status() - return resp[2] & 0b0001 != 0 # type: ignore - - async def get_door_open(self) -> bool: - resp = await self._get_status() - return resp[2] & 0b0010 != 0 # type: ignore - - async def get_door_locked(self) -> bool: - resp = await self._get_status() - return resp[2] & 0b0100 == 0 # type: ignore - - # Centrifuge communication: read_resp, send - - async def _read_resp(self, timeout: float = 20) -> bytes: - """Read a response from the centrifuge. If the timeout is reached, return the data that has - been read so far.""" - data = b"" - end_byte_found = False - start_time = time.time() - - while True: - chunk = await self.io.read(25) - if chunk: - data += chunk - end_byte_found = data[-1] == 0x0D - if len(chunk) < 25 and end_byte_found: - break - else: - if end_byte_found or time.time() - start_time > timeout: - break - await asyncio.sleep(0.0001) - - logger.debug("Read %s", data.hex()) - return data - - async def _send_command(self, cmd: bytes, read_timeout=0.2) -> bytes: - written = await self.io.write(bytes(cmd)) - - if written != len(cmd): - raise RuntimeError("Failed to write all bytes") - return await self._read_resp(timeout=read_timeout) - - async def configure_and_initialize(self): - await self.set_configuration_data() - await self.initialize() - - async def set_configuration_data(self): - """Set the device configuration data.""" - await self.io.set_latency_timer(16) - await self.io.set_line_property(bits=8, stopbits=1, parity=0) - await self.io.set_flowctrl(0) - await self.io.set_baudrate(19200) - - async def initialize(self): - await self.io.write(b"\x00" * 20) - for i in range(33): - packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 - await self.io.write(packet) - await self._send_command(bytes.fromhex("aaff0f0e")) - - # Centrifuge operations - - async def open_door(self): - if await self.get_door_open(): - return - # used to be: aa022600072f - await self._send_command(bytes.fromhex("aa022600062e")) # same as unlock door - - # we can't tell when the door is fully open, so we just wait a bit - await asyncio.sleep(4) - - async def close_door(self): - if not (await self.get_door_open()): - return - # used to be: aa022600052d - await self._send_command(bytes.fromhex("aa022600042c")) # same as unlock door - # we can't tell when the door is fully closed, so we just wait a bit - await asyncio.sleep(2) - - async def lock_door(self): - if await self.get_door_open(): - raise RuntimeError("Cannot lock door while it is open.") - if await self.get_door_locked(): - return - # used to be aa0226000129 - await self._send_command(bytes.fromhex("aa0226000028")) - - async def unlock_door(self): - if not await self.get_door_locked(): - return - # used to be aa022600052d - await self._send_command(bytes.fromhex("aa022600042c")) # same as close door - - async def lock_bucket(self): - if await self.get_bucket_locked(): - return - await self._send_command(bytes.fromhex("aa022600072f")) - - async def unlock_bucket(self): - if not await self.get_bucket_locked(): - return - await self._send_command(bytes.fromhex("aa022600062e")) # same as open door - - async def go_to_bucket1(self): - await self.go_to_position(await self.get_bucket_1_position()) - - async def go_to_bucket2(self): - await self.go_to_position(await self.get_bucket_1_position() + FULL_ROTATION // 2) - - async def go_to_position(self, position: int): - await self.close_door() - await self.lock_door() - - position_bytes = position.to_bytes(4, byteorder="little") - byte_string = bytes.fromhex("aa01d497") + position_bytes + bytes.fromhex("c3f52800d71a0000") - sum_byte = (sum(byte_string) - 0xAA) & 0xFF - byte_string += sum_byte.to_bytes(1, byteorder="little") - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(byte_string) - - # await self._send_command(bytes.fromhex("aa0117021a")) - while ( - abs(await self.get_position() - position) > 10 - ): # 10 tacks tolerance (10/8000 * 360 = 0.45 degrees) - await asyncio.sleep(0.1) - await self.open_door() - - @staticmethod - def g_to_rpm(g: float) -> int: - # https://en.wikipedia.org/wiki/Centrifugation#Mathematical_formula - r = 10 - rpm = int((g / (1.118 * 10**-5 * r)) ** 0.5) - return rpm - - async def spin( - self, - g: float = 500, - duration: float = 60, - acceleration: float = 0.8, - deceleration: float = 0.8, - ) -> None: - """Start a spin cycle. spin spin spin spin - - Args: - g: relative centrifugal force, also known as g-force - duration: time in seconds spent at speed (g) - acceleration: 0-1 of total acceleration - deceleration: 0-1 of total deceleration - """ - - if acceleration <= 0 or acceleration > 1: - raise ValueError("Acceleration must be within 0-1.") - if deceleration <= 0 or deceleration > 1: - raise ValueError("Deceleration must be within 0-1.") - if g < 1 or g > 1000: - raise ValueError("G-force must be within 1-1000") - if duration < 1: - raise ValueError("Spin time must be at least 1 second") - - if await self.get_door_open(): - await self.close_door() - if not await self.get_door_locked(): - await self.lock_door() - if await self.get_bucket_locked(): - await self.unlock_bucket() - - # 1 - compute the final position - rpm = VSpinBackend.g_to_rpm(g) - - # compute the distance traveled during the acceleration period - # distance = 1/2 * v^2 / a. area under 0 to t (triangle). t = a/v_max - # 12903.2 ticks/s^2 is 100% acceleration - acceleration_ticks_per_second2 = 12903.2 * acceleration - rounds_per_second = rpm / 60 - ticks_per_second = rounds_per_second * 8000 - distance_during_acceleration = int(0.5 * (ticks_per_second**2) / acceleration_ticks_per_second2) - - # compute the distance traveled at speed - distance_at_speed = ticks_per_second * duration - - current_position = await self.get_position() - final_position = int(current_position + distance_during_acceleration + distance_at_speed) - - if final_position > 2**32 - 1: - # this is almost 3 hours of spinning at 3000 rpm (max speed), - # so we assume nobody will ever hit this. - raise NotImplementedError( - "We don't know what happens if the destination position exceeds 2^32-1. " - "Please report this issue on discuss.pylabrobot.org." - ) - - # 2 - send "go to position" command with computed final position and rpm - position_b = final_position.to_bytes(4, byteorder="little") - rpm_b = int(rpm * 4473.925).to_bytes(4, byteorder="little") - acceleration_b = int(9.15 * 100 * acceleration).to_bytes(4, byteorder="little") - - byte_string = bytes.fromhex("aa01d497") + position_b + rpm_b + acceleration_b - checksum = (sum(byte_string) - 0xAA) & 0xFF - byte_string += checksum.to_bytes(1, byteorder="little") - - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) - - await self._send_command(byte_string) - - # 3 - wait for acceleration to the set rpm - # we also check the position to avoid waiting forever if the speed is not reached (e.g. short spin...) - while await self.get_tachometer() < rpm * 0.95 and await self.get_position() < final_position: - await asyncio.sleep(0.1) - - # 4 - once the speed is reached, compute the position at which to start deceleration - # this is different than computed above, because above we assumed constant acceleration from 0 to rpm. - # however, in reality there is jerk and the acceleration is not constant, so we have to adjust as we go. - # this is what the vendor software does too. - # if we are already past that position, we skip this part. - if await self.get_position() < final_position: - decel_start_position = await self.get_position() + distance_at_speed - - # then wait until we reach that position - while await self.get_position() < decel_start_position: - await asyncio.sleep(0.1) - - # 5 - send deceleration command - await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) - # aa0194b600000000dc02000029: decel at 80 - # aa0194b6000000000a03000058: decel at 85 - # aa0194b61283000012010000f3: used in setup (30%) - decc = int(9.15 * 100 * deceleration).to_bytes(2, byteorder="little") - decel_command = bytes.fromhex("aa0194b600000000") + decc + bytes.fromhex("0000") - decel_command += ((sum(decel_command) - 0xAA) & 0xFF).to_bytes(1, byteorder="little") - await self._send_command(decel_command) - - await asyncio.sleep(2) - - # 6 - reset position back to 0ish - # this part is aneeded because otherwise calling go_to_position will not work after - async def _reset_to_zero(): - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa010001")) # set position back to 0 (exactly) - await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) - await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) - await self._send_command(bytes.fromhex("aa01192842")) # it starts moving again - - await _reset_to_zero() - - # 7 - wait for home position to change - # go_to_bucket{1,2} does not work until the home position changes - start = await self.get_home_position() - num_tries = 0 - while await self.get_home_position() == start: - await asyncio.sleep(0.1) - num_tries += 1 - if num_tries % 25 == 0: - await _reset_to_zero() - if num_tries > 100: - raise RuntimeError("Home position did not change after spin.") - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 - - -class VSpin: - def __init__(self, *args, **kwargs): - raise RuntimeError("`VSpin` is deprecated. Please use `VSpinBackend` instead. ") From df292f317303fcfbc8926b848e999fde03617cd8 Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 18 May 2026 22:07:25 -0700 Subject: [PATCH 2/7] Robust wait_for_spindle_stopped with retry and protocol-level drain The previous implementation issued a single `status` call with a timeout of max(self.timeout, 300s). Two real-world problems with that: 1. A bare `status` is bounded by one timeout. If the rotor needs longer to spin down than that (we have observed `spin 1000 100 10 5` taking >17 minutes on the slow-decel curve), `wait_for_spindle_stopped` raises spuriously. The caller has no good knob: too-short means false negatives, too-long means a low-G hang would block forever. 2. When the timeout DID fire, the cancelled `status` left its response queued at the device end. When that response eventually arrived in our socket's recv buffer, the next `send_command` would read it as if it were its own ACK/data/OK, and every subsequent command would be off-by-one (protocol desync). This change addresses both: - send_command now tracks the number of terminator lines still owed by cancelled-but-not-fully-drained commands (`_pending_terminator_count`). Each new send first drains that many terminators from the socket before parsing its own response. The bookkeeping survives both flavours of cancellation (during ACK read and during terminator read). Tests cover both flavours plus the multi-cancellation case. - wait_for_spindle_stopped grows two parameters: `timeout` (overall budget, default 1800s = 30 min, `None` for unbounded) and `poll_interval` (per-status timeout, default 60s). Each individual `status` is allowed to time out without raising; only the overall budget expiry raises `asyncio.TimeoutError`. A genuine `MicroSpinError` from status is NOT retried -- it signals the device thinks something is wrong, not that motion is still in progress. Also adjust the `home()` docstring to reflect what the manual actually says: \u00a75.3 starts with home as part of unpacking, \u00a77.2 requires re-home after an imbalance abort, but the manual does not state in those words that homing is required after every power-cycle (that's an empirical observation, now noted as such). --- .../centrifuge/highres/microspin_backend.py | 126 +++++++++++-- .../centrifuge/highres/microspin_tests.py | 170 +++++++++++++++++- 2 files changed, 275 insertions(+), 21 deletions(-) diff --git a/pylabrobot/centrifuge/highres/microspin_backend.py b/pylabrobot/centrifuge/highres/microspin_backend.py index abbb68970d8..b925be580f1 100644 --- a/pylabrobot/centrifuge/highres/microspin_backend.py +++ b/pylabrobot/centrifuge/highres/microspin_backend.py @@ -45,6 +45,11 @@ _ACK_RE = re.compile(r"^ACK!\s+(?P.*?)\s+(?P\d+)\s*$") +#: Matches any ``OK!``/``ERROR!`` terminator line, regardless of command id. +#: Used by :meth:`MicroSpinBackend._drain_stale_responses` to identify +#: terminators from previously-cancelled commands without needing to know +#: their cmd-ids. +_ANY_TERMINATOR_RE = re.compile(r"^(?:OK!|ERROR!)\s+.*\s+\d+\s*$") class MicroSpinError(RuntimeError): @@ -115,6 +120,12 @@ def __init__( self._reader: Optional[asyncio.StreamReader] = None self._writer: Optional[asyncio.StreamWriter] = None self._lock = asyncio.Lock() + # Number of terminator lines we still need to drain from the socket + # because previously-issued commands were cancelled (e.g. by + # `asyncio.wait_for`) before their response fully arrived. Each new + # `send_command` first drains this many terminators before reading its + # own response, preventing protocol desync after timeouts. + self._pending_terminator_count: int = 0 # ------------------------------------------------------------------ lifecycle @@ -184,12 +195,38 @@ async def send_command( timeout=effective_timeout, ) + async def _drain_stale_responses(self) -> None: + """Consume any leftover lines from previously-cancelled commands. + + We track the number of terminators we still owe in + ``self._pending_terminator_count``. Each cancelled in-flight command + leaves at most one full response (``ACK!`` -> data -> ``OK!``/``ERROR!``) + in the socket buffer; reading lines until we have seen that many + terminators is sufficient to resynchronise the stream. + """ + while self._pending_terminator_count > 0: + line = await self._readline() + if _ANY_TERMINATOR_RE.match(line): + self._pending_terminator_count -= 1 + logger.debug( + "[microspin] drained stale terminator (%d still pending)", + self._pending_terminator_count, + ) + async def _send_command_no_lock(self, command: str) -> List[str]: assert self._writer is not None + # Resynchronise the stream before writing anything new. + await self._drain_stale_responses() + logger.debug("[microspin] >>> %s", command) self._writer.write((command + "\r\n").encode("ascii")) await self._writer.drain() + # Speculatively assume our response will become orphaned if we are + # cancelled mid-read; the count is decremented again iff we successfully + # consume our own terminator. + self._pending_terminator_count += 1 + # Stage 2: ACK! ack = await self._readline() m = _ACK_RE.match(ack) @@ -205,6 +242,7 @@ async def _send_command_no_lock(self, command: str) -> List[str]: line = await self._readline() end = end_re.match(line) if end: + self._pending_terminator_count -= 1 if end.group("status") == "OK!": logger.debug("[microspin] <<< OK (%d data lines)", len(data)) return data @@ -379,8 +417,14 @@ async def spin( async def home(self) -> None: """Home both axes (door and spindle). - The MicroSpin needs to be homed once after every power-cycle before any - spin or bucket-positioning command is accepted. + The MicroSpin User Manual (HighRes doc 1058675 Rev C) does not + explicitly require homing after every power-cycle, but observably the + firmware reports ``hss -> not homed`` after a power-cycle, and + subsequent motion commands (``open ``, ``spin``) fail with + "not homed" errors until ``home`` is issued. The manual's recommended + unpacking procedure (§5.3) also opens with ``home``, and §7.2 requires + a re-home after an imbalance abort. In practice, treat ``home`` as the + first motion command of every power-on session. """ await self.send_command("home", timeout=max(self.timeout, 120.0)) @@ -506,29 +550,83 @@ async def get_status(self, *, timeout: Optional[float] = None) -> Dict[str, str] data = await self.send_command("status", timeout=timeout) return _parse_kv_lines(data) + #: Default *overall* budget for :meth:`wait_for_spindle_stopped` -- 30 min, + #: chosen to comfortably cover the worst-case observed decel (~17 min for a + #: high-G spin on the slow-decel curve, e.g. ``spin 1000 100 10 5``). + DEFAULT_SPINDLE_STOP_TIMEOUT: Optional[float] = 1800.0 + #: Default *per-poll* timeout for :meth:`wait_for_spindle_stopped`. The + #: method issues a fresh ``status`` every ``poll_interval`` seconds until + #: one succeeds (i.e. the spindle reports stopped) or until the overall + #: ``timeout`` budget expires. + DEFAULT_SPINDLE_POLL_INTERVAL: float = 60.0 + async def wait_for_spindle_stopped( self, *, - timeout: Optional[float] = None, + timeout: Optional[float] = DEFAULT_SPINDLE_STOP_TIMEOUT, + poll_interval: float = DEFAULT_SPINDLE_POLL_INTERVAL, ) -> Dict[str, str]: """Block until the firmware confirms the rotor is fully stopped. - Sends a single ``status`` command, which the firmware queues behind any - in-progress motion. When that motion completes, the firmware emits the - status report and ``OK!`` -- at which point we know the rotor is - mechanically stopped from the controller's point of view. + The MicroSpin firmware queues ``status`` behind any active motion and + only answers once the rotor has spun down, so a single ``status`` is + sufficient as a "we are stopped" gate. This method issues ``status`` + repeatedly with a short per-call timeout until one returns successfully + (the rotor stopped) -- or until the overall ``timeout`` budget expires. + + Retrying matters in practice because long decels can take well over a + poll interval (a worst-case observed spin was ``spin 1000 100 10 5`` + taking >17 min to spin down on the slow-decel curve). With a single + long timeout, you have to either pick a value that's too short and + raise spuriously, or one that's so long it would mask a genuine hang + forever. Polling gives you both bounded latency and tolerant patience. Args: - timeout: Override the per-command timeout. Defaults to - ``max(self.timeout, 300s)`` which covers a full decel from 3000 ×g - on the slow-decel curve. + timeout: Total time budget in seconds. ``None`` means "wait + indefinitely" (only do this if you're sure the device isn't stuck + -- see the low-G hang warning in :meth:`spin`). Defaults to + :attr:`DEFAULT_SPINDLE_STOP_TIMEOUT` (30 min). + poll_interval: Per-``status`` call timeout in seconds. Each + individual ``status`` call may legitimately time out (because the + rotor is still moving); the loop catches those and tries again. + Defaults to :attr:`DEFAULT_SPINDLE_POLL_INTERVAL` (60 s). Returns: - The parsed status report ({key: value} dict, same as - :meth:`get_status`). + The parsed status report ({key: value} dict) from the first + ``status`` call that succeeds. + + Raises: + asyncio.TimeoutError: If the overall ``timeout`` expires before any + ``status`` call returns successfully. + MicroSpinError: If a ``status`` call returns ``ERROR!`` (this is not + retried -- an ``ERROR!`` from ``status`` means the device itself + thinks something is wrong, not that motion is still in progress). """ - effective = max(self.timeout, 300.0) if timeout is None else timeout - return await self.get_status(timeout=effective) + if poll_interval <= 0: + raise ValueError(f"poll_interval must be positive, got {poll_interval}") + + loop = asyncio.get_event_loop() + deadline: Optional[float] = None if timeout is None else loop.time() + timeout + + attempt = 0 + while True: + attempt += 1 + remaining = None if deadline is None else max(0.0, deadline - loop.time()) + if remaining is not None and remaining <= 0: + raise asyncio.TimeoutError( + f"Spindle did not stop within wait_for_spindle_stopped budget " + f"({timeout}s, {attempt - 1} polls)" + ) + this_call_timeout = poll_interval if remaining is None else min(poll_interval, remaining) + try: + return await self.get_status(timeout=this_call_timeout) + except asyncio.TimeoutError: + logger.debug( + "[microspin] status poll %d timed out after %.1fs; retrying", + attempt, + this_call_timeout, + ) + # Loop body retries until the overall deadline is reached. async def get_version(self) -> Dict[str, str]: """Return the firmware/library version report as a ``{field: value}`` dict.""" diff --git a/pylabrobot/centrifuge/highres/microspin_tests.py b/pylabrobot/centrifuge/highres/microspin_tests.py index 08c6a61318b..fcf0b52f4aa 100644 --- a/pylabrobot/centrifuge/highres/microspin_tests.py +++ b/pylabrobot/centrifuge/highres/microspin_tests.py @@ -18,6 +18,7 @@ from __future__ import annotations +import asyncio import unittest import warnings from typing import List, Tuple @@ -50,13 +51,21 @@ async def wait_closed(self) -> None: class _FakeReader: - """Yields a queue of lines one ``readline()`` at a time.""" + """Yields a queue of lines one ``readline()`` at a time. - def __init__(self, lines: List[bytes]) -> None: + When the queue is exhausted, returns ``b""`` (EOF) by default, or hangs + indefinitely if ``hang_on_empty=True`` -- the latter is what we want when + simulating a slow device whose response never arrives in time. + """ + + def __init__(self, lines: List[bytes], *, hang_on_empty: bool = False) -> None: self._lines: List[bytes] = list(lines) + self._hang_on_empty = hang_on_empty async def readline(self) -> bytes: if not self._lines: + if self._hang_on_empty: + await asyncio.Event().wait() # never fires return b"" # simulate EOF return self._lines.pop(0) @@ -109,6 +118,96 @@ async def test_error_response_carries_diagnostic_lines(self): self.assertEqual(cm.exception.error_lines, ["Error 1: (00:00:01) -12: bad params"]) +class MicroSpinStreamResyncTests(unittest.IsolatedAsyncioTestCase): + """After a cancelled/timed-out command, the next command must transparently + drain the stale response and return its own result. Without this the + protocol desyncs and every subsequent command reads someone else's data. + """ + + async def test_stale_response_is_drained_before_next_command(self): + # Simulate the buffer state after a `status` was cancelled mid-flight: + # the device eventually delivers its full response, then ours arrives. + backend, writer = _make_backend( + [ + # Stale response for the previously-cancelled command (cmd-id 5): + "ACK! status 5\r\n", + "Spindle Position: 9999\r\n", + "OK! status 5\r\n", + # Our fresh response (cmd-id 6): + "ACK! version 6\r\n", + "Version: MS-1.3.3\r\n", + "OK! version 6\r\n", + ] + ) + # Pretend the previous send_command was cancelled after writing "status": + backend._pending_terminator_count = 1 + + data = await backend.send_command("version") + self.assertEqual(data, ["Version: MS-1.3.3"]) + # And the stale-counter is back to zero. + self.assertEqual(backend._pending_terminator_count, 0) + + async def test_partial_stale_response_drained(self): + # Cancelled AFTER reading the ACK but before the terminator: the buffer + # holds the remaining data + OK, then our response. + backend, writer = _make_backend( + [ + # Leftover from a partially-read previous response (cmd-id 5): + "Spindle Position: 9999\r\n", # data line we hadn't read yet + "OK! status 5\r\n", # terminator we hadn't read yet + # Our fresh response (cmd-id 6): + "ACK! version 6\r\n", + "Version: MS-1.3.3\r\n", + "OK! version 6\r\n", + ] + ) + backend._pending_terminator_count = 1 + + data = await backend.send_command("version") + self.assertEqual(data, ["Version: MS-1.3.3"]) + self.assertEqual(backend._pending_terminator_count, 0) + + async def test_multiple_stale_responses_drained(self): + backend, writer = _make_backend( + [ + # Two stale responses (cmd-ids 5 and 6): + "ACK! status 5\r\n", + "Spindle Position: 9999\r\n", + "OK! status 5\r\n", + "ACK! status 6\r\n", + "Spindle Position: 1\r\n", + "OK! status 6\r\n", + # Our fresh response (cmd-id 7): + "ACK! version 7\r\n", + "Version: MS-1.3.3\r\n", + "OK! version 7\r\n", + ] + ) + backend._pending_terminator_count = 2 + + data = await backend.send_command("version") + self.assertEqual(data, ["Version: MS-1.3.3"]) + self.assertEqual(backend._pending_terminator_count, 0) + + async def test_send_command_keeps_pending_count_on_cancellation(self): + """Verify the bookkeeping that enables the drain. + + If `send_command` is cancelled (e.g. by ``asyncio.wait_for``) mid-read, + the in-flight terminator must remain in the pending count so the *next* + call can drain it. + """ + backend = MicroSpinBackend(host="ignored", port=0, timeout=2.0) + backend._writer = _FakeWriter() # type: ignore[assignment] + backend._reader = _FakeReader( # type: ignore[assignment] + [b"ACK! home 5\r\n"], # ACK arrives, but the terminator never does + hang_on_empty=True, + ) + + with self.assertRaises(asyncio.TimeoutError): + await asyncio.wait_for(backend.send_command("home"), timeout=0.05) + self.assertEqual(backend._pending_terminator_count, 1) + + class MicroSpinValidationTests(unittest.IsolatedAsyncioTestCase): """Argument validation that raises before any bytes hit the wire.""" @@ -288,7 +387,9 @@ async def fake_send(cmd, *, timeout=None): await backend.abort(timeout=5.0) self.assertEqual(seen, [("abort", 5.0)]) - async def test_wait_for_spindle_stopped_uses_extended_default_timeout(self): + async def test_wait_for_spindle_stopped_uses_poll_interval_per_call(self): + """Each individual ``status`` is bounded by ``poll_interval``, not by + the overall ``timeout``.""" backend = MicroSpinBackend(host="ignored", port=0, timeout=2.0) seen: list = [] @@ -296,13 +397,68 @@ async def fake_send(cmd, *, timeout=None): seen.append((cmd, timeout)) return [] - backend.send_command = fake_send # type: ignore[assignment] + backend.send_command = fake_send # type: ignore[method-assign] + # Defaults: poll_interval=60, total timeout=1800. First call should + # get poll_interval (or min(poll_interval, remaining) which == 60). await backend.wait_for_spindle_stopped() - self.assertEqual(seen, [("status", 300.0)]) + self.assertEqual(seen, [("status", 60.0)]) seen.clear() - await backend.wait_for_spindle_stopped(timeout=10.0) - self.assertEqual(seen, [("status", 10.0)]) + await backend.wait_for_spindle_stopped(poll_interval=5.0, timeout=100.0) + self.assertEqual(seen, [("status", 5.0)]) + + async def test_wait_for_spindle_stopped_retries_on_per_call_timeout(self): + """If the per-call status times out, we issue another one.""" + import asyncio as _asyncio + + backend = MicroSpinBackend(host="ignored", port=0, timeout=2.0) + call_count = 0 + + async def fake_send(cmd, *, timeout=None): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise _asyncio.TimeoutError("still spinning") + return ["Spindle Position: 1958", "Door Position: -457"] + + backend.send_command = fake_send # type: ignore[method-assign] + result = await backend.wait_for_spindle_stopped(poll_interval=0.01, timeout=10.0) + self.assertEqual(call_count, 3) + self.assertEqual(result, {"Spindle Position": "1958", "Door Position": "-457"}) + + async def test_wait_for_spindle_stopped_raises_when_total_budget_expires(self): + """With every poll timing out and a tight overall budget, we raise.""" + import asyncio as _asyncio + + backend = MicroSpinBackend(host="ignored", port=0, timeout=2.0) + + async def fake_send(cmd, *, timeout=None): + raise _asyncio.TimeoutError("still spinning") + + backend.send_command = fake_send # type: ignore[method-assign] + with self.assertRaises(_asyncio.TimeoutError): + await backend.wait_for_spindle_stopped(poll_interval=0.01, timeout=0.05) + + async def test_wait_for_spindle_stopped_propagates_microspin_error(self): + """An ERROR! from status is a real device-state error -- don't retry.""" + backend = MicroSpinBackend(host="ignored", port=0, timeout=2.0) + call_count = 0 + + async def fake_send(cmd, *, timeout=None): + nonlocal call_count + call_count += 1 + raise MicroSpinError("status", 1, ["Error: spindle wedged"]) + + backend.send_command = fake_send # type: ignore[method-assign] + with self.assertRaises(MicroSpinError): + await backend.wait_for_spindle_stopped(poll_interval=10.0, timeout=60.0) + self.assertEqual(call_count, 1) # NOT retried + + async def test_wait_for_spindle_stopped_rejects_non_positive_poll_interval(self): + backend = MicroSpinBackend(host="ignored", port=0) + for bad in (0, -1, -0.001): + with self.assertRaises(ValueError): + await backend.wait_for_spindle_stopped(poll_interval=bad) class MicroSpinConstructorTests(unittest.IsolatedAsyncioTestCase): From 5395851cd185cb84b677723576adb0e9343c4988 Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 18 May 2026 22:15:16 -0700 Subject: [PATCH 3/7] Treat open_door/close_door as maintenance-only on the MicroSpin There is no "open the door without choosing a bucket" workflow on the MicroSpin: `open ` (exposed as go_to_bucket1/2) does both in one step. Door *closing* happens automatically at the start of `spin` and `home`. The standalone `od` and `cd` wire commands are classified as maintenance commands in manual sec 6.7, the same tier as the four lock primitives (lockdoor/unlockdoor/locknest/unlocknest) we already gate. Apply the same NotImplementedError treatment to open_door() and close_door() so callers can't accidentally drive the firmware into a half-managed state; the underlying commands remain reachable via `backend.send_command('od' | 'cd')` for service use cases. Also: - Mock server's spin handler now auto-closes the door (matches real firmware) instead of rejecting spin if the door is open. - Notebook section 5 retitled "Positioning buckets" (no door section); example cell shows only go_to_bucket1/2 with a comment that close is automatic. - Pre-spin checklist no longer lists "door closed" as a caller responsibility; it now notes that the firmware handles closure. --- .../centrifuge/highres_microspin.ipynb | 30 ++++---- .../centrifuge/highres/microspin_backend.py | 70 +++++++++++++++---- .../centrifuge/highres/microspin_tests.py | 16 ++++- pylabrobot/centrifuge/highres/mock_server.py | 12 +++- .../centrifuge/highres/mock_server_tests.py | 29 ++++---- 5 files changed, 104 insertions(+), 53 deletions(-) diff --git a/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb b/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb index 9a27b73e4cf..e6cd9d3ed01 100644 --- a/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb +++ b/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb @@ -126,9 +126,11 @@ "id": "7fb071d2", "metadata": {}, "source": [ - "## 5. Positioning buckets and operating the door\n", + "## 5. Positioning buckets\n", "\n", - "The MicroSpin's `open ` firmware command both opens the door and rotates the chosen bucket into the load position; PLR exposes this as `go_to_bucket1()` / `go_to_bucket2()`. The door's pneumatic lock and the nest-pin that holds plates during loading are managed by the firmware as part of `open ` and `spin` -- they are not exposed as standalone calls on the MicroSpin backend (see manual §6.7, which classifies the underlying `lockdoor` / `unlockdoor` / `locknest` / `unlocknest` commands as maintenance-only).\n" + "The MicroSpin's `open ` firmware command both opens the door and rotates the chosen bucket into the load position; PLR exposes this as `go_to_bucket1()` / `go_to_bucket2()`. There is no separate \"open the door without a bucket\" workflow.\n", + "\n", + "Door closing is handled automatically by the firmware at the start of `spin` and `home`, so application code never has to issue a close. The pneumatic door lock and the nest-pin that holds plates during loading are likewise managed by the firmware as part of `open ` and `spin` -- none of these primitives (`od`, `cd`, `lockdoor`, `unlockdoor`, `locknest`, `unlocknest`) are exposed as standalone calls on the MicroSpin backend; the manual classifies them all as maintenance commands (§6.7).\n" ] }, { @@ -138,23 +140,14 @@ "metadata": {}, "outputs": [], "source": [ - "# Present bucket 1 at the load position (this also opens the door)\n", + "# Open the door and present bucket 1 in one step\n", "await cf.go_to_bucket1()\n", "\n", - "# After placing/removing a plate:\n", - "await cf.close_door()\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "be7c5024", - "metadata": {}, - "outputs": [], - "source": [ - "# Optionally just open the door without rotating a bucket (e.g. for inspection)\n", - "# await cf.open_door()\n", - "# await cf.close_door()\n" + "# Or bucket 2\n", + "# await cf.go_to_bucket2()\n", + "\n", + "# The next spin or home will close the door automatically; no explicit\n", + "# close call is needed (and is in fact disabled on this backend).\n" ] }, { @@ -172,9 +165,10 @@ "- Both buckets must be properly seated on the rotor pins, swing freely, and the bucket pivot pins must not protrude.\n", "- The payload must be balanced (max 15 g imbalance at full speed, up to 75 g at lower speeds).\n", "- The chamber must be free of debris.\n", - "- The door must be closed.\n", "- Compressed air must be supplied at 70-135 psi.\n", "\n", + "(The door is closed automatically by the firmware at the start of `spin` / `home`, so it does not need to be closed by the caller.)\n", + "\n", "The MicroSpin does not have biosafety seals -- do not centrifuge hazardous, flammable, or corrosive materials.\n", "```\n", "\n", diff --git a/pylabrobot/centrifuge/highres/microspin_backend.py b/pylabrobot/centrifuge/highres/microspin_backend.py index b925be580f1..ad3ca78136a 100644 --- a/pylabrobot/centrifuge/highres/microspin_backend.py +++ b/pylabrobot/centrifuge/highres/microspin_backend.py @@ -251,14 +251,6 @@ async def _send_command_no_lock(self, command: str) -> List[str]: # ------------------------------ CentrifugeBackend abstract methods -------- - async def open_door(self) -> None: - """Open the plate-loading door. Sends the firmware ``od`` command.""" - await self.send_command("od") - - async def close_door(self) -> None: - """Close the plate-loading door. Sends the firmware ``cd`` command.""" - await self.send_command("cd") - async def go_to_bucket1(self) -> None: """Present bucket 1 at the load position. @@ -272,15 +264,63 @@ async def go_to_bucket2(self) -> None: """Present bucket 2 at the load position (also opens the door).""" await self.send_command("open 2", timeout=max(self.timeout, 60.0)) - # The four lock/unlock primitives below are declared abstract by + # The six door/lock primitives below are declared abstract by # CentrifugeBackend, but on the MicroSpin they are firmware-internal # maintenance commands (see manual §6.7) that the higher-level commands - # (``open ``, ``spin``, ``home``) already handle automatically. We - # deliberately do NOT forward them to the wire so callers can't put the - # device into a half-managed state by issuing them out-of-band. If you - # really need to drive the underlying ``lockdoor`` / ``unlockdoor`` / - # ``locknest`` / ``unlocknest`` commands (e.g. for service), use - # :meth:`send_command` directly. + # (``open ``, ``spin``, ``home``) already handle automatically: + # + # * ``open_door`` / ``close_door`` -- on the MicroSpin there is no + # "open the door without choosing a bucket" workflow; door opening + # happens as a side effect of ``open ``, and door closing + # happens automatically at the start of ``spin`` and ``home``. + # * ``lock_door`` / ``unlock_door`` -- pneumatic door lock, driven by + # the firmware during ``spin``. + # * ``lock_bucket`` / ``unlock_bucket`` -- nest-lock pin, driven by + # the firmware during ``open ``. + # + # We deliberately do NOT forward them to the wire so callers can't put + # the device into a half-managed state by issuing them out-of-band. If + # you really need to drive the underlying maintenance commands + # (``od`` / ``cd`` / ``lockdoor`` / ``unlockdoor`` / ``locknest`` / + # ``unlocknest``) directly -- e.g. for service -- use + # :meth:`send_command`. + + async def open_door(self) -> None: # pragma: no cover -- always raises + """Not supported on the MicroSpin: there is no door-only open workflow. + + Always raises :class:`NotImplementedError`. The MicroSpin's + ``open `` firmware command opens the door *and* presents the + requested bucket in one shot, which is what callers actually want + 99% of the time -- see :meth:`go_to_bucket1` / :meth:`go_to_bucket2`. + The standalone ``od`` wire command is documented as maintenance-only + in manual §6.7 and is deliberately not exposed here. If you really + need to drive it (e.g. for service), use + ``backend.send_command("od")`` directly. + """ + raise NotImplementedError( + "There is no standalone door-open workflow on the MicroSpin. Use " + "`go_to_bucket1()` / `go_to_bucket2()` to open the door and present " + "a bucket in one step. The underlying `od` command is a " + "maintenance primitive (manual §6.7); if you really need it, use " + "`backend.send_command('od')`." + ) + + async def close_door(self) -> None: # pragma: no cover -- always raises + """Not supported on the MicroSpin: door closing is firmware-managed. + + Always raises :class:`NotImplementedError`. The MicroSpin firmware + closes the door automatically at the start of :meth:`spin` and + :meth:`home`, so application code never needs to issue an explicit + close. The standalone ``cd`` wire command is documented as + maintenance-only in manual §6.7 and is deliberately not exposed here. + If you really need it, use ``backend.send_command("cd")`` directly. + """ + raise NotImplementedError( + "Door closing on the MicroSpin happens automatically as part of " + "`spin` and `home`. The underlying `cd` command is a maintenance " + "primitive (manual §6.7); if you really need it, use " + "`backend.send_command('cd')`." + ) async def lock_door(self) -> None: # pragma: no cover -- always raises """Not supported on the MicroSpin: door locking is firmware-managed. diff --git a/pylabrobot/centrifuge/highres/microspin_tests.py b/pylabrobot/centrifuge/highres/microspin_tests.py index fcf0b52f4aa..b953c454fa5 100644 --- a/pylabrobot/centrifuge/highres/microspin_tests.py +++ b/pylabrobot/centrifuge/highres/microspin_tests.py @@ -276,9 +276,21 @@ async def test_spin_does_not_warn_at_or_above_threshold(self): ] self.assertEqual(low_g_warnings, []) - async def test_maintenance_lock_methods_raise_not_implemented(self): + async def test_maintenance_door_and_lock_methods_raise_not_implemented(self): + """open_door / close_door / the four lock primitives are maintenance-only + on the MicroSpin (manual §6.7). They must raise rather than silently + sending bytes; the firmware handles door + lock state internally as + part of `open `, `spin`, and `home`. + """ backend, writer = _make_backend([]) - for method_name in ("lock_door", "unlock_door", "lock_bucket", "unlock_bucket"): + for method_name in ( + "open_door", + "close_door", + "lock_door", + "unlock_door", + "lock_bucket", + "unlock_bucket", + ): with self.assertRaises(NotImplementedError): await getattr(backend, method_name)() self.assertEqual(_sent_commands(writer), []) diff --git a/pylabrobot/centrifuge/highres/mock_server.py b/pylabrobot/centrifuge/highres/mock_server.py index 18c7d9d092e..404ae77f02b 100644 --- a/pylabrobot/centrifuge/highres/mock_server.py +++ b/pylabrobot/centrifuge/highres/mock_server.py @@ -459,8 +459,12 @@ async def _h_spin(self, args, cmd_id): raise _MockError([f"Error: duration too short: {duration}"]) self._require_homed() self._require_not_aborted() - if self.state.door_position > 0: # door not closed - raise _MockError(["Error: door must be closed before spin"]) + + # Real-device behaviour: `spin` (and `home`) close the door automatically + # before doing anything else. We mirror that here -- if the door is open + # when spin is issued, we close it as the first step of the spin motion + # rather than rejecting the command. + door_was_open = self.state.door_position > 0 # Compute a simulated dwell that scales with duration but is short by # default so tests don't sleep for a minute. Tests can override. @@ -469,6 +473,10 @@ async def _h_spin(self, args, cmd_id): async def do_spin(): self.state.spinning = True try: + if door_was_open: + await asyncio.sleep(self.motion_dwell["cd"]) + self.state.door_position = -258 + self.state.at_bucket = None await asyncio.sleep(dwell) finally: self.state.spinning = False diff --git a/pylabrobot/centrifuge/highres/mock_server_tests.py b/pylabrobot/centrifuge/highres/mock_server_tests.py index a597ba7d5a9..e69b5c14346 100644 --- a/pylabrobot/centrifuge/highres/mock_server_tests.py +++ b/pylabrobot/centrifuge/highres/mock_server_tests.py @@ -41,16 +41,6 @@ class MockServerCommandMappingTests(_MockServerTestBase): server gives us higher confidence the wire format and ordering are right. """ - async def test_open_door_emits_od(self): - self.assertLess(self.server.state.door_position, 0) - await self.backend.open_door() - self.assertGreater(self.server.state.door_position, 0) - - async def test_close_door_emits_cd(self): - await self.backend.open_door() - await self.backend.close_door() - self.assertLess(self.server.state.door_position, 0) - async def test_go_to_bucket1_after_home_lands_at_bucket_1(self): await self.backend.home() await self.backend.go_to_bucket1() @@ -150,18 +140,25 @@ async def test_open_after_home_succeeds(self): await self.backend.go_to_bucket1() self.assertEqual(self.server.state.at_bucket, 1) - async def test_spin_requires_homed_and_door_closed(self): + async def test_spin_requires_homed(self): # not homed -> error with self.assertRaises(MicroSpinError): await self.backend.spin(g=100, duration=1) - # home, then leave door open via go_to_bucket1 + # home -> spin works + await self.backend.home() + await self.backend.spin(g=100, duration=1) + + async def test_spin_auto_closes_open_door(self): + """`spin` should close the door automatically -- callers never have to.""" await self.backend.home() await self.backend.go_to_bucket1() - with self.assertRaises(MicroSpinError): - await self.backend.spin(g=100, duration=1) - # close door -> spin works - await self.backend.close_door() + # Sanity: bucket is now presented and door is open + self.assertEqual(self.server.state.at_bucket, 1) + self.assertGreater(self.server.state.door_position, 0) + # Spin succeeds despite the open door; afterwards the door is closed. await self.backend.spin(g=100, duration=1) + self.assertLess(self.server.state.door_position, 0) + self.assertIsNone(self.server.state.at_bucket) async def test_unknown_command_errors(self): with self.assertRaises(MicroSpinError): From b45f61fb41ae16e40b8d1d5e389c7e62a4767d7b Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 18 May 2026 22:18:30 -0700 Subject: [PATCH 4/7] Revert centrifuge per-vendor folder refactor Hold off on the agilent/ subpackage split (and its deprecation shims) until the broader machine-interface architecture lands (see #1000). This PR now only adds the new HighRes MicroSpin files; existing VSpin imports are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 - docs/api/pylabrobot.centrifuge.rst | 5 +- .../centrifuge/_centrifuge.md | 3 +- pylabrobot/centrifuge/__init__.py | 24 +- pylabrobot/centrifuge/access2.py | 30 +- pylabrobot/centrifuge/agilent/__init__.py | 8 - pylabrobot/centrifuge/agilent/access2.py | 26 - .../centrifuge/agilent/vspin_backend.py | 649 ----------------- pylabrobot/centrifuge/vspin_backend.py | 650 +++++++++++++++++- 9 files changed, 673 insertions(+), 726 deletions(-) delete mode 100644 pylabrobot/centrifuge/agilent/__init__.py delete mode 100644 pylabrobot/centrifuge/agilent/access2.py delete mode 100644 pylabrobot/centrifuge/agilent/vspin_backend.py diff --git a/CHANGELOG.md b/CHANGELOG.md index daeaae3b7d5..b0c4231645a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `MicroSpinBackend.reset()` recovery helper that issues `abort` -> `clearbuttonabort` -> `status`, using the last as the gate that genuinely confirms the rotor has stopped. - User guide notebook for the MicroSpin (`docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb`). -### Changed - -- Refactored `pylabrobot.centrifuge` to a per-vendor folder layout (`agilent/`, `highres/`) mirroring `pylabrobot.plate_reading`. Existing imports from `pylabrobot.centrifuge.vspin_backend` and `pylabrobot.centrifuge.access2` continue to work via deprecation shims. - ### Fixed - Imported `unittest.mock` in `pylabrobot/centrifuge/centrifuge_tests.py` (pre-existing bug that prevented the test class from running). diff --git a/docs/api/pylabrobot.centrifuge.rst b/docs/api/pylabrobot.centrifuge.rst index 8abd3e50ddb..3449bdfd62d 100644 --- a/docs/api/pylabrobot.centrifuge.rst +++ b/docs/api/pylabrobot.centrifuge.rst @@ -22,10 +22,7 @@ Backends :nosignatures: :recursive: - chatterbox.CentrifugeChatterboxBackend - chatterbox.LoaderChatterboxBackend - agilent.vspin_backend.VSpinBackend - agilent.vspin_backend.Access2Backend + vspin_backend.VSpinBackend highres.microspin_backend.MicroSpinBackend diff --git a/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md b/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md index 26ce2df415f..0aa460e01c0 100644 --- a/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md +++ b/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md @@ -12,7 +12,8 @@ The {class}`~pylabrobot.centrifuge.centrifuge.Centrifuge` class has a number of - {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.unlock_bucket`: Unlock centrifuge buckets. - {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.go_to_bucket1`: Rotate to Bucket 1. - {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.go_to_bucket2`: Rotate to Bucket 2. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.spin`: Start a spin cycle. (The older {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.start_spin_cycle` method is a deprecated alias.) +- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.rotate_distance`: Rotate the buckets a specified distance (8000 = 360 degrees). +- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.start_spin_cycle`: Start centrifuge spin cycle. PLR supports the following centrifuges: diff --git a/pylabrobot/centrifuge/__init__.py b/pylabrobot/centrifuge/__init__.py index 96a4973b7a8..33293289dfa 100644 --- a/pylabrobot/centrifuge/__init__.py +++ b/pylabrobot/centrifuge/__init__.py @@ -1,4 +1,4 @@ -from .agilent import Access2, Access2Backend, VSpinBackend +from .access2 import Access2 from .centrifuge import Centrifuge, Loader from .highres import ( MicroSpin, @@ -13,24 +13,4 @@ LoaderNoPlateError, NotAtBucketError, ) - -__all__ = [ - # Front-end - "Centrifuge", - "Loader", - # Standard / errors - "BucketHasPlateError", - "BucketNoPlateError", - "CentrifugeDoorError", - "LoaderNoPlateError", - "NotAtBucketError", - # Agilent (VSpin + Access2 loader) - "Access2", - "Access2Backend", - "VSpinBackend", - # HighRes Biosolutions (MicroSpin) - "MicroSpin", - "MicroSpinBackend", - "MicroSpinError", - "MicroSpinProtocolError", -] +from .vspin_backend import Access2Backend, VSpinBackend diff --git a/pylabrobot/centrifuge/access2.py b/pylabrobot/centrifuge/access2.py index 611b605623f..8f773914ab7 100644 --- a/pylabrobot/centrifuge/access2.py +++ b/pylabrobot/centrifuge/access2.py @@ -1,8 +1,26 @@ -import warnings +from typing import Tuple -from .agilent.access2 import Access2 # noqa: F401 +from pylabrobot.centrifuge.centrifuge import Centrifuge, Loader +from pylabrobot.centrifuge.vspin_backend import Access2Backend, VSpinBackend +from pylabrobot.resources import Coordinate -warnings.warn( - "pylabrobot.centrifuge.access2 is deprecated and will be removed in a future release. " - "Please use pylabrobot.centrifuge.agilent.access2 instead.", -) + +def Access2(name: str, device_id: str, vspin: VSpinBackend) -> Tuple[Centrifuge, Loader]: + centrifuge = Centrifuge( + backend=vspin, + size_x=0, # TODO + size_y=0, # TODO + size_z=0, # TODO + name=name + "_centrifuge", + ) + # Use `python -m pylibftdi.examples.list_devices` to find the device id for each + loader = Loader( + name=name, + size_x=0, # TODO + size_y=0, # TODO + size_z=0, # TODO + backend=Access2Backend(device_id=device_id), + centrifuge=centrifuge, + child_location=Coordinate.zero(), + ) + return centrifuge, loader diff --git a/pylabrobot/centrifuge/agilent/__init__.py b/pylabrobot/centrifuge/agilent/__init__.py deleted file mode 100644 index 1697838e576..00000000000 --- a/pylabrobot/centrifuge/agilent/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .access2 import Access2 -from .vspin_backend import Access2Backend, VSpinBackend - -__all__ = [ - "Access2", - "Access2Backend", - "VSpinBackend", -] diff --git a/pylabrobot/centrifuge/agilent/access2.py b/pylabrobot/centrifuge/agilent/access2.py deleted file mode 100644 index 5ec6d8fc1b1..00000000000 --- a/pylabrobot/centrifuge/agilent/access2.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import Tuple - -from pylabrobot.centrifuge.agilent.vspin_backend import Access2Backend, VSpinBackend -from pylabrobot.centrifuge.centrifuge import Centrifuge, Loader -from pylabrobot.resources import Coordinate - - -def Access2(name: str, device_id: str, vspin: VSpinBackend) -> Tuple[Centrifuge, Loader]: - centrifuge = Centrifuge( - backend=vspin, - size_x=0, # TODO - size_y=0, # TODO - size_z=0, # TODO - name=name + "_centrifuge", - ) - # Use `python -m pylibftdi.examples.list_devices` to find the device id for each - loader = Loader( - name=name, - size_x=0, # TODO - size_y=0, # TODO - size_z=0, # TODO - backend=Access2Backend(device_id=device_id), - centrifuge=centrifuge, - child_location=Coordinate.zero(), - ) - return centrifuge, loader diff --git a/pylabrobot/centrifuge/agilent/vspin_backend.py b/pylabrobot/centrifuge/agilent/vspin_backend.py deleted file mode 100644 index 66834d3f815..00000000000 --- a/pylabrobot/centrifuge/agilent/vspin_backend.py +++ /dev/null @@ -1,649 +0,0 @@ -import asyncio -import ctypes -import json -import logging -import math -import os -import time -import warnings -from typing import Optional - -from pylabrobot.io.ftdi import FTDI - -from ..backend import CentrifugeBackend, LoaderBackend -from ..standard import LoaderNoPlateError - -logger = logging.getLogger(__name__) - - -class Access2Backend(LoaderBackend): - def __init__( - self, - device_id: str, - timeout: int = 60, - ): - """ - Args: - device_id: The libftdi id for the loader. Find using - `python3 -m pylibftdi.examples.list_devices` - """ - self.io = FTDI(human_readable_device_name="Agilent Access2 Loader", device_id=device_id) - self.timeout = timeout - - async def _read(self) -> bytes: - x = b"" - r = None - start = time.time() - while r != b"" or x == b"": - r = await self.io.read(1) - x += r - if r == b"": - await asyncio.sleep(0.1) - if x == b"" and (time.time() - start) > self.timeout: - raise TimeoutError("No data received within the specified timeout period") - return x - - async def send_command(self, command: bytes) -> bytes: - logger.debug("[loader] Sending %s", command.hex()) - await self.io.write(command) - return await self._read() - - async def setup(self): - logger.debug("[loader] setup") - - await self.io.setup() - await self.io.set_baudrate(115384) - - status = await self.get_status() - if not status.startswith(bytes.fromhex("1105")): - raise RuntimeError("Failed to get status") - - await self.send_command(bytes.fromhex("110500030014000072b1")) - await self.send_command(bytes.fromhex("1105000300100000ae71")) - await self.send_command(bytes.fromhex("110500070024040000008000be89")) - await self.send_command(bytes.fromhex("11050007002404008000800063b1")) - await self.send_command(bytes.fromhex("11050007002404000001800089b9")) - await self.send_command(bytes.fromhex("1105000700240400800180005481")) - await self.send_command(bytes.fromhex("110500070024040000024000c6bd")) - await self.send_command(bytes.fromhex("1105000300400000f0bf")) - await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) - # await self.send_command(bytes.fromhex("11050003002000006bd4")) - await self.send_command(bytes.fromhex("1105000e00440b00000000000000007041020203c7")) - # await self.send_command(bytes.fromhex("11050003002000006bd4")) - - async def stop(self): - logger.debug("[loader] stop") - await self.io.stop() - - def serialize(self): - return {"io": self.io.serialize(), "timeout": self.timeout} - - async def get_status(self) -> bytes: - logger.debug("[loader] get_status") - return await self.send_command(bytes.fromhex("11050003002000006bd4")) - - async def park(self): - logger.debug("[loader] park") - await self.send_command(bytes.fromhex("1105000e00440b0000000000410000704103007539")) - - async def close(self): - logger.debug("[loader] close") - await self.send_command(bytes.fromhex("1105000a00420700010000803f02008c64")) - - async def open(self): - logger.debug("[loader] open") - await self.send_command(bytes.fromhex("1105000a0042070001000080bf0200b73e")) - - async def load(self): - """only tested for 1cm plate, 3mm pickup height""" - logger.debug("[loader] load") - - await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) - await self.send_command(bytes.fromhex("1105000e00440b000100004040000020410200a5cb")) - - # laser check - r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) - if r == bytes.fromhex("1105000800510500000300000079f1"): - raise LoaderNoPlateError("no plate found on stage") - - await self.send_command(bytes.fromhex("1105000a00460700018fc2b540020023dc")) - await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410300ee00")) - await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) - await self.send_command(bytes.fromhex("1105000e00440b0000000040400000204102007d82")) - - async def unload(self): - """only tested for 1cm plate, 3mm pickup height""" - logger.debug("[loader] unload") - - await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) - await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410200dd31")) - - # laser check - r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) - if r == bytes.fromhex("1105000800510500000300000079f1"): - raise LoaderNoPlateError("no plate found in centrifuge") - - await self.send_command(bytes.fromhex("1105000a00460700017b14b6400200d57a")) - await self.send_command(bytes.fromhex("1105000e00440b00010000404000002041030096fa")) - await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) - await self.send_command(bytes.fromhex("1105000e00440b00000000000000002041020056be")) - # await self.send_command(bytes.fromhex("11050003002000006bd4")) - - -_vspin_bucket_calibrations_path = os.path.join( - os.path.expanduser("~"), - ".pylabrobot", - "vspin_bucket_calibrations.json", -) - - -def _load_vspin_calibrations(device_id: str) -> Optional[int]: - if not os.path.exists(_vspin_bucket_calibrations_path): - warnings.warn( - f"No calibration found for VSpin with device id {device_id}. " - "Please set the bucket 1 position using `set_bucket_1_position_to_current` method after setup.", - UserWarning, - ) - return None - with open(_vspin_bucket_calibrations_path, "r") as f: - return json.load(f).get(device_id) # type: ignore - - -def _save_vspin_calibrations(device_id, remainder: int): - if os.path.exists(_vspin_bucket_calibrations_path): - with open(_vspin_bucket_calibrations_path, "r") as f: - data = json.load(f) - else: - data = {} - data[device_id] = remainder - os.makedirs(os.path.dirname(_vspin_bucket_calibrations_path), exist_ok=True) - with open(_vspin_bucket_calibrations_path, "w") as f: - json.dump(data, f) - - -FULL_ROTATION: int = 8000 - - -bucket_1_not_set_error = RuntimeError( - "Bucket 1 position not set. " - "Please rotate the bucket to bucket 1 using VSpinBackend.go_to_position and " - "then calling VSpinBackend.set_bucket_1_position_to_current." -) - - -class VSpinBackend(CentrifugeBackend): - """Backend for the Agilent Centrifuge. - Note that this is not a complete implementation.""" - - def __init__(self, device_id: Optional[str] = None): - """ - Args: - device_id: The libftdi id for the centrifuge. Find using `python -m pylibftdi.examples.list_devices` - """ - self.io = FTDI(human_readable_device_name="Agilent VSpin Centrifuge", device_id=device_id) - self._bucket_1_remainder: Optional[int] = None - # only attempt loading calibration if device_id is not None - # if it is None, we will load it after setup when we can query the device id from the io - if device_id is not None: - self._bucket_1_remainder = _load_vspin_calibrations(device_id) - - async def setup(self): - await self.io.setup() - # TODO: add functionality where if robot has been initialized before nothing needs to happen - for _ in range(3): - await self.configure_and_initialize() - await self._send_command(bytes.fromhex("aa002101ff21")) - await self._send_command(bytes.fromhex("aa002101ff21")) - await self._send_command(bytes.fromhex("aa01132034")) - await self._send_command(bytes.fromhex("aa002102ff22")) - await self._send_command(bytes.fromhex("aa02132035")) - await self._send_command(bytes.fromhex("aa002103ff23")) - await self._send_command(bytes.fromhex("aaff1a142d")) - - await self.io.set_baudrate(57600) - await self.io.set_rts(True) - await self.io.set_dtr(True) - - await self._send_command(bytes.fromhex("aa01121f32")) - for _ in range(8): - await self._send_command(bytes.fromhex("aa0220ff0f30")) - await self._send_command(bytes.fromhex("aa0220df0f10")) - await self._send_command(bytes.fromhex("aa0220df0e0f")) - await self._send_command(bytes.fromhex("aa0220df0c0d")) - await self._send_command(bytes.fromhex("aa0220df0809")) - for _ in range(4): - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa02120317")) - for _ in range(5): - await self._send_command(bytes.fromhex("aa0226200048")) - await self._send_command(bytes.fromhex("aa0226000028")) - await self.lock_door() - - await self._send_command(bytes.fromhex("aa0226000028")) - - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa010001")) - await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) - await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) - await self._send_command(bytes.fromhex("aa01192842")) - - resp = 0x89 - while resp == 0x89: - resp = (await self._get_positions_and_tachometer()).status - - # --- almost the same as go to position --- - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - new_position = (0).to_bytes(4, byteorder="little") # arbitrary - # rpm = 600, - # acceleration = 75.09289617486338 - await self._send_command( - bytes.fromhex("aa01d497") + new_position + bytes.fromhex("c3f52800d71a000049") - ) - # ----------------------------------------- - - resp = 0x08 - while resp != 0x09: - resp = (await self._get_positions_and_tachometer()).status - - await self._send_command(bytes.fromhex("aa0117021a")) - - await self.lock_door() - - # If we have not set the calibration yet, load it now. - if self._bucket_1_remainder is None: - device_id = await self.io.get_serial() - self._bucket_1_remainder = _load_vspin_calibrations(device_id) - - @property - def bucket_1_remainder(self) -> int: - if self._bucket_1_remainder is None: - raise bucket_1_not_set_error - return self._bucket_1_remainder - - async def set_bucket_1_position_to_current(self) -> None: - """Set the current position as bucket 1 position and save calibration.""" - current_position = await self.get_position() - device_id = await self.io.get_serial() - remainder = await self.get_home_position() - current_position - self._bucket_1_remainder = current_position % FULL_ROTATION - _save_vspin_calibrations(device_id, remainder) - - async def get_bucket_1_position(self) -> int: - """Get the bucket 1 position based on calibration. - Normally it is the home position minus the remainder (calibration). - The bucket 1 position must be greater than the current position, so we find - the first position greater than the current position by adding full rotations if needed. - """ - if self._bucket_1_remainder is None: - raise bucket_1_not_set_error - home_position = await self.get_home_position() - bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder - # first number after current position that matches bucket 1 position mod FULL_ROTATION - current_position = await self.get_position() - bucket_1_position = ( - FULL_ROTATION - * math.floor((current_position - bucket_1_position_mod_full_rotation) / FULL_ROTATION + 1) - + bucket_1_position_mod_full_rotation - ) - return bucket_1_position - - async def stop(self): - await self.configure_and_initialize() - await self.io.stop() - - class _StatusPositionTachometer(ctypes.LittleEndianStructure): - _pack_ = 1 - _fields_ = [ - ("status", ctypes.c_uint8), - ("current_position", ctypes.c_uint32), - ("unknown1", ctypes.c_uint8), - ("tachometer", ctypes.c_int16), - ("unknown2", ctypes.c_uint8), - ("home_position", ctypes.c_uint32), - ("checksum", ctypes.c_uint8), - ] - - async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: - """Returns 14 bytes - - Example: - 11 22 25 00 00 4f 00 00 18 e0 05 00 00 a4 - ^^ checksum - ^^ ^^ ^^ ^^ home position - ^^ ? (probably binary status objects) - ^^ ^^ tachometer - ^^ ? (probably binary status objects) - ^^ ^^ ^^ ^^ current position - ^^ - - First byte (index 0): - - 11 = 0b0001011 = idle - - 13 = 0b0001101 = unknown - - 08 = 0b0001000 = spinning - - 09 = 0b0001001 = also spinning but different - - 19 = 0b0010011 = unknown - - 88 = 0b1011000 = unknown - - 89 = 0b1011001 = unknown - - 10th to 13th byte (index 9-12) = Homing Position - - Last byte (index 13) = checksum - """ - resp = await self._send_command(bytes.fromhex("aa010e0f")) - if len(resp) == 0: - raise IOError("Empty status from centrifuge") - return VSpinBackend._StatusPositionTachometer.from_buffer_copy(resp) - - async def get_position(self) -> int: - return (await self._get_positions_and_tachometer()).current_position # type: ignore - - async def get_tachometer(self) -> int: - """current speed in rpm""" - tack_to_rpm = -14.69320388 # R^2 = 0.9999 when spinning, but not specific at single-digit RPM - return (await self._get_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore - - async def get_home_position(self) -> int: - """changes during a run, but the bucket 1 position relative to it does not""" - return (await self._get_positions_and_tachometer()).home_position # type: ignore - - async def _get_status(self): - """ - examples: - - 0080d0015 - - 0080f0015 - """ - - resp = await self._send_command(bytes.fromhex("aa020e10")) - if len(resp) == 0: - raise IOError("Empty status from centrifuge. Is the machine on?") - return resp - - async def get_bucket_locked(self) -> bool: - resp = await self._get_status() - return resp[2] & 0b0001 != 0 # type: ignore - - async def get_door_open(self) -> bool: - resp = await self._get_status() - return resp[2] & 0b0010 != 0 # type: ignore - - async def get_door_locked(self) -> bool: - resp = await self._get_status() - return resp[2] & 0b0100 == 0 # type: ignore - - # Centrifuge communication: read_resp, send - - async def _read_resp(self, timeout: float = 20) -> bytes: - """Read a response from the centrifuge. If the timeout is reached, return the data that has - been read so far.""" - data = b"" - end_byte_found = False - start_time = time.time() - - while True: - chunk = await self.io.read(25) - if chunk: - data += chunk - end_byte_found = data[-1] == 0x0D - if len(chunk) < 25 and end_byte_found: - break - else: - if end_byte_found or time.time() - start_time > timeout: - break - await asyncio.sleep(0.0001) - - logger.debug("Read %s", data.hex()) - return data - - async def _send_command(self, cmd: bytes, read_timeout=0.2) -> bytes: - written = await self.io.write(bytes(cmd)) - - if written != len(cmd): - raise RuntimeError("Failed to write all bytes") - return await self._read_resp(timeout=read_timeout) - - async def configure_and_initialize(self): - await self.set_configuration_data() - await self.initialize() - - async def set_configuration_data(self): - """Set the device configuration data.""" - await self.io.set_latency_timer(16) - await self.io.set_line_property(bits=8, stopbits=1, parity=0) - await self.io.set_flowctrl(0) - await self.io.set_baudrate(19200) - - async def initialize(self): - await self.io.write(b"\x00" * 20) - for i in range(33): - packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 - await self.io.write(packet) - await self._send_command(bytes.fromhex("aaff0f0e")) - - # Centrifuge operations - - async def open_door(self): - if await self.get_door_open(): - return - # used to be: aa022600072f - await self._send_command(bytes.fromhex("aa022600062e")) # same as unlock door - - # we can't tell when the door is fully open, so we just wait a bit - await asyncio.sleep(4) - - async def close_door(self): - if not (await self.get_door_open()): - return - # used to be: aa022600052d - await self._send_command(bytes.fromhex("aa022600042c")) # same as unlock door - # we can't tell when the door is fully closed, so we just wait a bit - await asyncio.sleep(2) - - async def lock_door(self): - if await self.get_door_open(): - raise RuntimeError("Cannot lock door while it is open.") - if await self.get_door_locked(): - return - # used to be aa0226000129 - await self._send_command(bytes.fromhex("aa0226000028")) - - async def unlock_door(self): - if not await self.get_door_locked(): - return - # used to be aa022600052d - await self._send_command(bytes.fromhex("aa022600042c")) # same as close door - - async def lock_bucket(self): - if await self.get_bucket_locked(): - return - await self._send_command(bytes.fromhex("aa022600072f")) - - async def unlock_bucket(self): - if not await self.get_bucket_locked(): - return - await self._send_command(bytes.fromhex("aa022600062e")) # same as open door - - async def go_to_bucket1(self): - await self.go_to_position(await self.get_bucket_1_position()) - - async def go_to_bucket2(self): - await self.go_to_position(await self.get_bucket_1_position() + FULL_ROTATION // 2) - - async def go_to_position(self, position: int): - await self.close_door() - await self.lock_door() - - position_bytes = position.to_bytes(4, byteorder="little") - byte_string = bytes.fromhex("aa01d497") + position_bytes + bytes.fromhex("c3f52800d71a0000") - sum_byte = (sum(byte_string) - 0xAA) & 0xFF - byte_string += sum_byte.to_bytes(1, byteorder="little") - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(byte_string) - - # await self._send_command(bytes.fromhex("aa0117021a")) - while ( - abs(await self.get_position() - position) > 10 - ): # 10 tacks tolerance (10/8000 * 360 = 0.45 degrees) - await asyncio.sleep(0.1) - await self.open_door() - - @staticmethod - def g_to_rpm(g: float) -> int: - # https://en.wikipedia.org/wiki/Centrifugation#Mathematical_formula - r = 10 - rpm = int((g / (1.118 * 10**-5 * r)) ** 0.5) - return rpm - - async def spin( - self, - g: float = 500, - duration: float = 60, - acceleration: float = 0.8, - deceleration: float = 0.8, - ) -> None: - """Start a spin cycle. spin spin spin spin - - Args: - g: relative centrifugal force, also known as g-force - duration: time in seconds spent at speed (g) - acceleration: 0-1 of total acceleration - deceleration: 0-1 of total deceleration - """ - - if acceleration <= 0 or acceleration > 1: - raise ValueError("Acceleration must be within 0-1.") - if deceleration <= 0 or deceleration > 1: - raise ValueError("Deceleration must be within 0-1.") - if g < 1 or g > 1000: - raise ValueError("G-force must be within 1-1000") - if duration < 1: - raise ValueError("Spin time must be at least 1 second") - - if await self.get_door_open(): - await self.close_door() - if not await self.get_door_locked(): - await self.lock_door() - if await self.get_bucket_locked(): - await self.unlock_bucket() - - # 1 - compute the final position - rpm = VSpinBackend.g_to_rpm(g) - - # compute the distance traveled during the acceleration period - # distance = 1/2 * v^2 / a. area under 0 to t (triangle). t = a/v_max - # 12903.2 ticks/s^2 is 100% acceleration - acceleration_ticks_per_second2 = 12903.2 * acceleration - rounds_per_second = rpm / 60 - ticks_per_second = rounds_per_second * 8000 - distance_during_acceleration = int(0.5 * (ticks_per_second**2) / acceleration_ticks_per_second2) - - # compute the distance traveled at speed - distance_at_speed = ticks_per_second * duration - - current_position = await self.get_position() - final_position = int(current_position + distance_during_acceleration + distance_at_speed) - - if final_position > 2**32 - 1: - # this is almost 3 hours of spinning at 3000 rpm (max speed), - # so we assume nobody will ever hit this. - raise NotImplementedError( - "We don't know what happens if the destination position exceeds 2^32-1. " - "Please report this issue on discuss.pylabrobot.org." - ) - - # 2 - send "go to position" command with computed final position and rpm - position_b = final_position.to_bytes(4, byteorder="little") - rpm_b = int(rpm * 4473.925).to_bytes(4, byteorder="little") - acceleration_b = int(9.15 * 100 * acceleration).to_bytes(4, byteorder="little") - - byte_string = bytes.fromhex("aa01d497") + position_b + rpm_b + acceleration_b - checksum = (sum(byte_string) - 0xAA) & 0xFF - byte_string += checksum.to_bytes(1, byteorder="little") - - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) - - await self._send_command(byte_string) - - # 3 - wait for acceleration to the set rpm - # we also check the position to avoid waiting forever if the speed is not reached (e.g. short spin...) - while await self.get_tachometer() < rpm * 0.95 and await self.get_position() < final_position: - await asyncio.sleep(0.1) - - # 4 - once the speed is reached, compute the position at which to start deceleration - # this is different than computed above, because above we assumed constant acceleration from 0 to rpm. - # however, in reality there is jerk and the acceleration is not constant, so we have to adjust as we go. - # this is what the vendor software does too. - # if we are already past that position, we skip this part. - if await self.get_position() < final_position: - decel_start_position = await self.get_position() + distance_at_speed - - # then wait until we reach that position - while await self.get_position() < decel_start_position: - await asyncio.sleep(0.1) - - # 5 - send deceleration command - await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) - # aa0194b600000000dc02000029: decel at 80 - # aa0194b6000000000a03000058: decel at 85 - # aa0194b61283000012010000f3: used in setup (30%) - decc = int(9.15 * 100 * deceleration).to_bytes(2, byteorder="little") - decel_command = bytes.fromhex("aa0194b600000000") + decc + bytes.fromhex("0000") - decel_command += ((sum(decel_command) - 0xAA) & 0xFF).to_bytes(1, byteorder="little") - await self._send_command(decel_command) - - await asyncio.sleep(2) - - # 6 - reset position back to 0ish - # this part is aneeded because otherwise calling go_to_position will not work after - async def _reset_to_zero(): - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa010001")) # set position back to 0 (exactly) - await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) - await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) - await self._send_command(bytes.fromhex("aa01192842")) # it starts moving again - - await _reset_to_zero() - - # 7 - wait for home position to change - # go_to_bucket{1,2} does not work until the home position changes - start = await self.get_home_position() - num_tries = 0 - while await self.get_home_position() == start: - await asyncio.sleep(0.1) - num_tries += 1 - if num_tries % 25 == 0: - await _reset_to_zero() - if num_tries > 100: - raise RuntimeError("Home position did not change after spin.") - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 - - -class VSpin: - def __init__(self, *args, **kwargs): - raise RuntimeError("`VSpin` is deprecated. Please use `VSpinBackend` instead. ") diff --git a/pylabrobot/centrifuge/vspin_backend.py b/pylabrobot/centrifuge/vspin_backend.py index 7198c1eeec1..021a550f359 100644 --- a/pylabrobot/centrifuge/vspin_backend.py +++ b/pylabrobot/centrifuge/vspin_backend.py @@ -1,11 +1,649 @@ +import asyncio +import ctypes +import json +import logging +import math +import os +import time import warnings +from typing import Optional -from .agilent.vspin_backend import ( # noqa: F401 - Access2Backend, - VSpinBackend, +from pylabrobot.io.ftdi import FTDI + +from .backend import CentrifugeBackend, LoaderBackend +from .standard import LoaderNoPlateError + +logger = logging.getLogger(__name__) + + +class Access2Backend(LoaderBackend): + def __init__( + self, + device_id: str, + timeout: int = 60, + ): + """ + Args: + device_id: The libftdi id for the loader. Find using + `python3 -m pylibftdi.examples.list_devices` + """ + self.io = FTDI(human_readable_device_name="Agilent Access2 Loader", device_id=device_id) + self.timeout = timeout + + async def _read(self) -> bytes: + x = b"" + r = None + start = time.time() + while r != b"" or x == b"": + r = await self.io.read(1) + x += r + if r == b"": + await asyncio.sleep(0.1) + if x == b"" and (time.time() - start) > self.timeout: + raise TimeoutError("No data received within the specified timeout period") + return x + + async def send_command(self, command: bytes) -> bytes: + logger.debug("[loader] Sending %s", command.hex()) + await self.io.write(command) + return await self._read() + + async def setup(self): + logger.debug("[loader] setup") + + await self.io.setup() + await self.io.set_baudrate(115384) + + status = await self.get_status() + if not status.startswith(bytes.fromhex("1105")): + raise RuntimeError("Failed to get status") + + await self.send_command(bytes.fromhex("110500030014000072b1")) + await self.send_command(bytes.fromhex("1105000300100000ae71")) + await self.send_command(bytes.fromhex("110500070024040000008000be89")) + await self.send_command(bytes.fromhex("11050007002404008000800063b1")) + await self.send_command(bytes.fromhex("11050007002404000001800089b9")) + await self.send_command(bytes.fromhex("1105000700240400800180005481")) + await self.send_command(bytes.fromhex("110500070024040000024000c6bd")) + await self.send_command(bytes.fromhex("1105000300400000f0bf")) + await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) + # await self.send_command(bytes.fromhex("11050003002000006bd4")) + await self.send_command(bytes.fromhex("1105000e00440b00000000000000007041020203c7")) + # await self.send_command(bytes.fromhex("11050003002000006bd4")) + + async def stop(self): + logger.debug("[loader] stop") + await self.io.stop() + + def serialize(self): + return {"io": self.io.serialize(), "timeout": self.timeout} + + async def get_status(self) -> bytes: + logger.debug("[loader] get_status") + return await self.send_command(bytes.fromhex("11050003002000006bd4")) + + async def park(self): + logger.debug("[loader] park") + await self.send_command(bytes.fromhex("1105000e00440b0000000000410000704103007539")) + + async def close(self): + logger.debug("[loader] close") + await self.send_command(bytes.fromhex("1105000a00420700010000803f02008c64")) + + async def open(self): + logger.debug("[loader] open") + await self.send_command(bytes.fromhex("1105000a0042070001000080bf0200b73e")) + + async def load(self): + """only tested for 1cm plate, 3mm pickup height""" + logger.debug("[loader] load") + + await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) + await self.send_command(bytes.fromhex("1105000e00440b000100004040000020410200a5cb")) + + # laser check + r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) + if r == bytes.fromhex("1105000800510500000300000079f1"): + raise LoaderNoPlateError("no plate found on stage") + + await self.send_command(bytes.fromhex("1105000a00460700018fc2b540020023dc")) + await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410300ee00")) + await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) + await self.send_command(bytes.fromhex("1105000e00440b0000000040400000204102007d82")) + + async def unload(self): + """only tested for 1cm plate, 3mm pickup height""" + logger.debug("[loader] unload") + + await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) + await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410200dd31")) + + # laser check + r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) + if r == bytes.fromhex("1105000800510500000300000079f1"): + raise LoaderNoPlateError("no plate found in centrifuge") + + await self.send_command(bytes.fromhex("1105000a00460700017b14b6400200d57a")) + await self.send_command(bytes.fromhex("1105000e00440b00010000404000002041030096fa")) + await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) + await self.send_command(bytes.fromhex("1105000e00440b00000000000000002041020056be")) + # await self.send_command(bytes.fromhex("11050003002000006bd4")) + + +_vspin_bucket_calibrations_path = os.path.join( + os.path.expanduser("~"), + ".pylabrobot", + "vspin_bucket_calibrations.json", ) -warnings.warn( - "pylabrobot.centrifuge.vspin_backend is deprecated and will be removed in a future release. " - "Please use pylabrobot.centrifuge.agilent.vspin_backend instead.", + +def _load_vspin_calibrations(device_id: str) -> Optional[int]: + if not os.path.exists(_vspin_bucket_calibrations_path): + warnings.warn( + f"No calibration found for VSpin with device id {device_id}. " + "Please set the bucket 1 position using `set_bucket_1_position_to_current` method after setup.", + UserWarning, + ) + return None + with open(_vspin_bucket_calibrations_path, "r") as f: + return json.load(f).get(device_id) # type: ignore + + +def _save_vspin_calibrations(device_id, remainder: int): + if os.path.exists(_vspin_bucket_calibrations_path): + with open(_vspin_bucket_calibrations_path, "r") as f: + data = json.load(f) + else: + data = {} + data[device_id] = remainder + os.makedirs(os.path.dirname(_vspin_bucket_calibrations_path), exist_ok=True) + with open(_vspin_bucket_calibrations_path, "w") as f: + json.dump(data, f) + + +FULL_ROTATION: int = 8000 + + +bucket_1_not_set_error = RuntimeError( + "Bucket 1 position not set. " + "Please rotate the bucket to bucket 1 using VSpinBackend.go_to_position and " + "then calling VSpinBackend.set_bucket_1_position_to_current." ) + + +class VSpinBackend(CentrifugeBackend): + """Backend for the Agilent Centrifuge. + Note that this is not a complete implementation.""" + + def __init__(self, device_id: Optional[str] = None): + """ + Args: + device_id: The libftdi id for the centrifuge. Find using `python -m pylibftdi.examples.list_devices` + """ + self.io = FTDI(human_readable_device_name="Agilent VSpin Centrifuge", device_id=device_id) + self._bucket_1_remainder: Optional[int] = None + # only attempt loading calibration if device_id is not None + # if it is None, we will load it after setup when we can query the device id from the io + if device_id is not None: + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + + async def setup(self): + await self.io.setup() + # TODO: add functionality where if robot has been initialized before nothing needs to happen + for _ in range(3): + await self.configure_and_initialize() + await self._send_command(bytes.fromhex("aa002101ff21")) + await self._send_command(bytes.fromhex("aa002101ff21")) + await self._send_command(bytes.fromhex("aa01132034")) + await self._send_command(bytes.fromhex("aa002102ff22")) + await self._send_command(bytes.fromhex("aa02132035")) + await self._send_command(bytes.fromhex("aa002103ff23")) + await self._send_command(bytes.fromhex("aaff1a142d")) + + await self.io.set_baudrate(57600) + await self.io.set_rts(True) + await self.io.set_dtr(True) + + await self._send_command(bytes.fromhex("aa01121f32")) + for _ in range(8): + await self._send_command(bytes.fromhex("aa0220ff0f30")) + await self._send_command(bytes.fromhex("aa0220df0f10")) + await self._send_command(bytes.fromhex("aa0220df0e0f")) + await self._send_command(bytes.fromhex("aa0220df0c0d")) + await self._send_command(bytes.fromhex("aa0220df0809")) + for _ in range(4): + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa02120317")) + for _ in range(5): + await self._send_command(bytes.fromhex("aa0226200048")) + await self._send_command(bytes.fromhex("aa0226000028")) + await self.lock_door() + + await self._send_command(bytes.fromhex("aa0226000028")) + + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa010001")) + await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await self._send_command(bytes.fromhex("aa01192842")) + + resp = 0x89 + while resp == 0x89: + resp = (await self._get_positions_and_tachometer()).status + + # --- almost the same as go to position --- + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + new_position = (0).to_bytes(4, byteorder="little") # arbitrary + # rpm = 600, + # acceleration = 75.09289617486338 + await self._send_command( + bytes.fromhex("aa01d497") + new_position + bytes.fromhex("c3f52800d71a000049") + ) + # ----------------------------------------- + + resp = 0x08 + while resp != 0x09: + resp = (await self._get_positions_and_tachometer()).status + + await self._send_command(bytes.fromhex("aa0117021a")) + + await self.lock_door() + + # If we have not set the calibration yet, load it now. + if self._bucket_1_remainder is None: + device_id = await self.io.get_serial() + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + + @property + def bucket_1_remainder(self) -> int: + if self._bucket_1_remainder is None: + raise bucket_1_not_set_error + return self._bucket_1_remainder + + async def set_bucket_1_position_to_current(self) -> None: + """Set the current position as bucket 1 position and save calibration.""" + current_position = await self.get_position() + device_id = await self.io.get_serial() + remainder = await self.get_home_position() - current_position + self._bucket_1_remainder = current_position % FULL_ROTATION + _save_vspin_calibrations(device_id, remainder) + + async def get_bucket_1_position(self) -> int: + """Get the bucket 1 position based on calibration. + Normally it is the home position minus the remainder (calibration). + The bucket 1 position must be greater than the current position, so we find + the first position greater than the current position by adding full rotations if needed. + """ + if self._bucket_1_remainder is None: + raise bucket_1_not_set_error + home_position = await self.get_home_position() + bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder + # first number after current position that matches bucket 1 position mod FULL_ROTATION + current_position = await self.get_position() + bucket_1_position = ( + FULL_ROTATION + * math.floor((current_position - bucket_1_position_mod_full_rotation) / FULL_ROTATION + 1) + + bucket_1_position_mod_full_rotation + ) + return bucket_1_position + + async def stop(self): + await self.configure_and_initialize() + await self.io.stop() + + class _StatusPositionTachometer(ctypes.LittleEndianStructure): + _pack_ = 1 + _fields_ = [ + ("status", ctypes.c_uint8), + ("current_position", ctypes.c_uint32), + ("unknown1", ctypes.c_uint8), + ("tachometer", ctypes.c_int16), + ("unknown2", ctypes.c_uint8), + ("home_position", ctypes.c_uint32), + ("checksum", ctypes.c_uint8), + ] + + async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: + """Returns 14 bytes + + Example: + 11 22 25 00 00 4f 00 00 18 e0 05 00 00 a4 + ^^ checksum + ^^ ^^ ^^ ^^ home position + ^^ ? (probably binary status objects) + ^^ ^^ tachometer + ^^ ? (probably binary status objects) + ^^ ^^ ^^ ^^ current position + ^^ + - First byte (index 0): + - 11 = 0b0001011 = idle + - 13 = 0b0001101 = unknown + - 08 = 0b0001000 = spinning + - 09 = 0b0001001 = also spinning but different + - 19 = 0b0010011 = unknown + - 88 = 0b1011000 = unknown + - 89 = 0b1011001 = unknown + - 10th to 13th byte (index 9-12) = Homing Position + - Last byte (index 13) = checksum + """ + resp = await self._send_command(bytes.fromhex("aa010e0f")) + if len(resp) == 0: + raise IOError("Empty status from centrifuge") + return VSpinBackend._StatusPositionTachometer.from_buffer_copy(resp) + + async def get_position(self) -> int: + return (await self._get_positions_and_tachometer()).current_position # type: ignore + + async def get_tachometer(self) -> int: + """current speed in rpm""" + tack_to_rpm = -14.69320388 # R^2 = 0.9999 when spinning, but not specific at single-digit RPM + return (await self._get_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore + + async def get_home_position(self) -> int: + """changes during a run, but the bucket 1 position relative to it does not""" + return (await self._get_positions_and_tachometer()).home_position # type: ignore + + async def _get_status(self): + """ + examples: + - 0080d0015 + - 0080f0015 + """ + + resp = await self._send_command(bytes.fromhex("aa020e10")) + if len(resp) == 0: + raise IOError("Empty status from centrifuge. Is the machine on?") + return resp + + async def get_bucket_locked(self) -> bool: + resp = await self._get_status() + return resp[2] & 0b0001 != 0 # type: ignore + + async def get_door_open(self) -> bool: + resp = await self._get_status() + return resp[2] & 0b0010 != 0 # type: ignore + + async def get_door_locked(self) -> bool: + resp = await self._get_status() + return resp[2] & 0b0100 == 0 # type: ignore + + # Centrifuge communication: read_resp, send + + async def _read_resp(self, timeout: float = 20) -> bytes: + """Read a response from the centrifuge. If the timeout is reached, return the data that has + been read so far.""" + data = b"" + end_byte_found = False + start_time = time.time() + + while True: + chunk = await self.io.read(25) + if chunk: + data += chunk + end_byte_found = data[-1] == 0x0D + if len(chunk) < 25 and end_byte_found: + break + else: + if end_byte_found or time.time() - start_time > timeout: + break + await asyncio.sleep(0.0001) + + logger.debug("Read %s", data.hex()) + return data + + async def _send_command(self, cmd: bytes, read_timeout=0.2) -> bytes: + written = await self.io.write(bytes(cmd)) + + if written != len(cmd): + raise RuntimeError("Failed to write all bytes") + return await self._read_resp(timeout=read_timeout) + + async def configure_and_initialize(self): + await self.set_configuration_data() + await self.initialize() + + async def set_configuration_data(self): + """Set the device configuration data.""" + await self.io.set_latency_timer(16) + await self.io.set_line_property(bits=8, stopbits=1, parity=0) + await self.io.set_flowctrl(0) + await self.io.set_baudrate(19200) + + async def initialize(self): + await self.io.write(b"\x00" * 20) + for i in range(33): + packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 + await self.io.write(packet) + await self._send_command(bytes.fromhex("aaff0f0e")) + + # Centrifuge operations + + async def open_door(self): + if await self.get_door_open(): + return + # used to be: aa022600072f + await self._send_command(bytes.fromhex("aa022600062e")) # same as unlock door + + # we can't tell when the door is fully open, so we just wait a bit + await asyncio.sleep(4) + + async def close_door(self): + if not (await self.get_door_open()): + return + # used to be: aa022600052d + await self._send_command(bytes.fromhex("aa022600042c")) # same as unlock door + # we can't tell when the door is fully closed, so we just wait a bit + await asyncio.sleep(2) + + async def lock_door(self): + if await self.get_door_open(): + raise RuntimeError("Cannot lock door while it is open.") + if await self.get_door_locked(): + return + # used to be aa0226000129 + await self._send_command(bytes.fromhex("aa0226000028")) + + async def unlock_door(self): + if not await self.get_door_locked(): + return + # used to be aa022600052d + await self._send_command(bytes.fromhex("aa022600042c")) # same as close door + + async def lock_bucket(self): + if await self.get_bucket_locked(): + return + await self._send_command(bytes.fromhex("aa022600072f")) + + async def unlock_bucket(self): + if not await self.get_bucket_locked(): + return + await self._send_command(bytes.fromhex("aa022600062e")) # same as open door + + async def go_to_bucket1(self): + await self.go_to_position(await self.get_bucket_1_position()) + + async def go_to_bucket2(self): + await self.go_to_position(await self.get_bucket_1_position() + FULL_ROTATION // 2) + + async def go_to_position(self, position: int): + await self.close_door() + await self.lock_door() + + position_bytes = position.to_bytes(4, byteorder="little") + byte_string = bytes.fromhex("aa01d497") + position_bytes + bytes.fromhex("c3f52800d71a0000") + sum_byte = (sum(byte_string) - 0xAA) & 0xFF + byte_string += sum_byte.to_bytes(1, byteorder="little") + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(byte_string) + + # await self._send_command(bytes.fromhex("aa0117021a")) + while ( + abs(await self.get_position() - position) > 10 + ): # 10 tacks tolerance (10/8000 * 360 = 0.45 degrees) + await asyncio.sleep(0.1) + await self.open_door() + + @staticmethod + def g_to_rpm(g: float) -> int: + # https://en.wikipedia.org/wiki/Centrifugation#Mathematical_formula + r = 10 + rpm = int((g / (1.118 * 10**-5 * r)) ** 0.5) + return rpm + + async def spin( + self, + g: float = 500, + duration: float = 60, + acceleration: float = 0.8, + deceleration: float = 0.8, + ) -> None: + """Start a spin cycle. spin spin spin spin + + Args: + g: relative centrifugal force, also known as g-force + duration: time in seconds spent at speed (g) + acceleration: 0-1 of total acceleration + deceleration: 0-1 of total deceleration + """ + + if acceleration <= 0 or acceleration > 1: + raise ValueError("Acceleration must be within 0-1.") + if deceleration <= 0 or deceleration > 1: + raise ValueError("Deceleration must be within 0-1.") + if g < 1 or g > 1000: + raise ValueError("G-force must be within 1-1000") + if duration < 1: + raise ValueError("Spin time must be at least 1 second") + + if await self.get_door_open(): + await self.close_door() + if not await self.get_door_locked(): + await self.lock_door() + if await self.get_bucket_locked(): + await self.unlock_bucket() + + # 1 - compute the final position + rpm = VSpinBackend.g_to_rpm(g) + + # compute the distance traveled during the acceleration period + # distance = 1/2 * v^2 / a. area under 0 to t (triangle). t = a/v_max + # 12903.2 ticks/s^2 is 100% acceleration + acceleration_ticks_per_second2 = 12903.2 * acceleration + rounds_per_second = rpm / 60 + ticks_per_second = rounds_per_second * 8000 + distance_during_acceleration = int(0.5 * (ticks_per_second**2) / acceleration_ticks_per_second2) + + # compute the distance traveled at speed + distance_at_speed = ticks_per_second * duration + + current_position = await self.get_position() + final_position = int(current_position + distance_during_acceleration + distance_at_speed) + + if final_position > 2**32 - 1: + # this is almost 3 hours of spinning at 3000 rpm (max speed), + # so we assume nobody will ever hit this. + raise NotImplementedError( + "We don't know what happens if the destination position exceeds 2^32-1. " + "Please report this issue on discuss.pylabrobot.org." + ) + + # 2 - send "go to position" command with computed final position and rpm + position_b = final_position.to_bytes(4, byteorder="little") + rpm_b = int(rpm * 4473.925).to_bytes(4, byteorder="little") + acceleration_b = int(9.15 * 100 * acceleration).to_bytes(4, byteorder="little") + + byte_string = bytes.fromhex("aa01d497") + position_b + rpm_b + acceleration_b + checksum = (sum(byte_string) - 0xAA) & 0xFF + byte_string += checksum.to_bytes(1, byteorder="little") + + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + + await self._send_command(byte_string) + + # 3 - wait for acceleration to the set rpm + # we also check the position to avoid waiting forever if the speed is not reached (e.g. short spin...) + while await self.get_tachometer() < rpm * 0.95 and await self.get_position() < final_position: + await asyncio.sleep(0.1) + + # 4 - once the speed is reached, compute the position at which to start deceleration + # this is different than computed above, because above we assumed constant acceleration from 0 to rpm. + # however, in reality there is jerk and the acceleration is not constant, so we have to adjust as we go. + # this is what the vendor software does too. + # if we are already past that position, we skip this part. + if await self.get_position() < final_position: + decel_start_position = await self.get_position() + distance_at_speed + + # then wait until we reach that position + while await self.get_position() < decel_start_position: + await asyncio.sleep(0.1) + + # 5 - send deceleration command + await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + # aa0194b600000000dc02000029: decel at 80 + # aa0194b6000000000a03000058: decel at 85 + # aa0194b61283000012010000f3: used in setup (30%) + decc = int(9.15 * 100 * deceleration).to_bytes(2, byteorder="little") + decel_command = bytes.fromhex("aa0194b600000000") + decc + bytes.fromhex("0000") + decel_command += ((sum(decel_command) - 0xAA) & 0xFF).to_bytes(1, byteorder="little") + await self._send_command(decel_command) + + await asyncio.sleep(2) + + # 6 - reset position back to 0ish + # this part is aneeded because otherwise calling go_to_position will not work after + async def _reset_to_zero(): + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa010001")) # set position back to 0 (exactly) + await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await self._send_command(bytes.fromhex("aa01192842")) # it starts moving again + + await _reset_to_zero() + + # 7 - wait for home position to change + # go_to_bucket{1,2} does not work until the home position changes + start = await self.get_home_position() + num_tries = 0 + while await self.get_home_position() == start: + await asyncio.sleep(0.1) + num_tries += 1 + if num_tries % 25 == 0: + await _reset_to_zero() + if num_tries > 100: + raise RuntimeError("Home position did not change after spin.") + + +# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) +# https://github.com/PyLabRobot/pylabrobot/issues/466 + + +class VSpin: + def __init__(self, *args, **kwargs): + raise RuntimeError("`VSpin` is deprecated. Please use `VSpinBackend` instead. ") From 8b355f8e33ee427c94927d70f9067963626ed3d9 Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 18 May 2026 22:32:25 -0700 Subject: [PATCH 5/7] Mock server: only commands available via netcat; add ABORTED!; decel warnings Three related changes. 1) Mock-server cleanup: only the public command set lives in the mock The mock previously carried two maintenance-only commands (`od`, `cd`) and an alias (`hi -> history`) that the real device does not include in its `list` output. It also lacked two commands the device does list (`commandstat`/`cstat`, `logcommands`). The mock is now driven by a single _COMMAND_TABLE that mirrors the real device's public-command list verbatim, used to drive both dispatch and the response text of `list` / `info` / `help`. Maintenance commands (od, cd, lockdoor, unlockdoor, locknest, unlocknest, r, copleyget, copleyset, ddio, ...) are deliberately not modelled. Sending one to the mock now produces the same 'Command "X" not recognized!' response the real device gives for any unknown command. Naming cleanup: the mock's `_wait_for_spindle_stopped` helper was inlined (it was a one-line wrapper around `spindle_settled.wait()`); the name collided with the backend's public method and made it look like there was a wire command of that name when there isn't. Response text for list, info, help, and abort was lifted verbatim from real-device netcat sessions, including `abort`'s 'Issue the clearbuttonabort (cba) command to re-enable the machine' data line. 2) ABORTED! is a real third terminator The real device emits `ABORTED!` (not `ERROR!`) for commands cancelled by an `abort`, and for motion commands issued while the abort latch is set (between `abort` and `clearbuttonabort`). Examples from a real session: spin 1000 100 100 30 ACK! spin 1000 100 100 30 122 ABORTED! spin 1000 100 100 30 122 The backend now recognises `ABORTED!` as a third terminator status and raises the new `MicroSpinAbortedError` (a subclass of `MicroSpinError` so callers that just catch `MicroSpinError` still work). The mock emits `ABORTED!` in the same situations the real device does. Both the `_send_command_no_lock` parser and the stale-drain regex (`_ANY_TERMINATOR_RE`) handle the new terminator. 3) Deceleration warnings at two tiers Spinning with very low deceleration is empirically problematic, in two distinct ways the warnings now distinguish: decel <0.40 (40 %): slow but legitimate spin-down. A tested `spin 1000 100 20 10` (decel = 0.20) took ~7 minutes from full speed to stopped. UserWarning advises ensuring timeouts allow for it. decel <0.20 (20 %): possible firmware hang. A tested `spin 1000 100 10 10` (decel = 0.10) ran for >30 minutes without ever reporting spin-down. UserWarning advises the abort/cba/power- cycle recovery path. Only one warning per call -- the stuck-decel warning suppresses the slow-decel one when both thresholds are crossed. Same pattern as the existing g<30 warning. Tests cover both tiers and the safe boundary (decel = 0.40 emits no warning). Tests: 86 passing (was 82). Stub-based: parse ABORTED! correctly, four decel-warning cases. Mock-server-based: 13 new (list/info/help text matches, alias resolution, maintenance commands rejected, logcommands / cstat / whoami / abort surface) and 4 new ABORTED!-flow tests including in-flight cancellation across two TCP connections. Notebook section 6 (Spinning) now describes the two decel warning tiers alongside the existing low-G warning. --- .../centrifuge/highres_microspin.ipynb | 7 + pylabrobot/centrifuge/__init__.py | 1 + pylabrobot/centrifuge/highres/__init__.py | 2 + .../centrifuge/highres/microspin_backend.py | 92 +++- .../centrifuge/highres/microspin_tests.py | 75 +++ pylabrobot/centrifuge/highres/mock_server.py | 511 ++++++++++++------ .../centrifuge/highres/mock_server_tests.py | 170 ++++++ 7 files changed, 690 insertions(+), 168 deletions(-) diff --git a/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb b/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb index e6cd9d3ed01..34bb5f32185 100644 --- a/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb +++ b/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb @@ -202,6 +202,13 @@ "id": "e3a6810c", "metadata": {}, "source": [ + "```{warning}\n", + "**Slow / stuck deceleration:** Low `deceleration` values cause noticeably long spin-downs, and very low ones can hang the firmware entirely. The backend emits a `UserWarning` at two tiers:\n", + "\n", + "* `deceleration < 0.40` (40 %) -- \"long spin-down expected\". A tested `spin 1000 100 20 10` (decel = 0.20) took ~7 minutes from full speed to stopped. Make sure your `wait_for_spindle_stopped` budget covers this.\n", + "* `deceleration < 0.20` (20 %) -- \"possible firmware hang\". A tested `spin 1000 100 10 10` (decel = 0.10) ran for >30 minutes without ever reporting spin-down. Recovery is the same as for the low-G hang below.\n", + "```\n", + "\n", "```{warning}\n", "**Low-G hang:** Spinning at less than ~30 ×g is known to occasionally hang the firmware. The spindle \"stopped\" sensor sometimes fails to latch when the rotor decelerates from a very low speed, so no `OK!` completion line is emitted and every subsequent command times out. The backend will emit a `UserWarning` if you call `spin()` with `g < 30`. If you hit the hang, the recovery path is:\n", "\n", diff --git a/pylabrobot/centrifuge/__init__.py b/pylabrobot/centrifuge/__init__.py index 33293289dfa..f910c01a64e 100644 --- a/pylabrobot/centrifuge/__init__.py +++ b/pylabrobot/centrifuge/__init__.py @@ -2,6 +2,7 @@ from .centrifuge import Centrifuge, Loader from .highres import ( MicroSpin, + MicroSpinAbortedError, MicroSpinBackend, MicroSpinError, MicroSpinProtocolError, diff --git a/pylabrobot/centrifuge/highres/__init__.py b/pylabrobot/centrifuge/highres/__init__.py index 90ff90c679a..fa4117dbff1 100644 --- a/pylabrobot/centrifuge/highres/__init__.py +++ b/pylabrobot/centrifuge/highres/__init__.py @@ -1,5 +1,6 @@ from .microspin import MicroSpin from .microspin_backend import ( + MicroSpinAbortedError, MicroSpinBackend, MicroSpinError, MicroSpinProtocolError, @@ -9,5 +10,6 @@ "MicroSpin", "MicroSpinBackend", "MicroSpinError", + "MicroSpinAbortedError", "MicroSpinProtocolError", ] diff --git a/pylabrobot/centrifuge/highres/microspin_backend.py b/pylabrobot/centrifuge/highres/microspin_backend.py index ad3ca78136a..221439b0141 100644 --- a/pylabrobot/centrifuge/highres/microspin_backend.py +++ b/pylabrobot/centrifuge/highres/microspin_backend.py @@ -45,11 +45,15 @@ _ACK_RE = re.compile(r"^ACK!\s+(?P.*?)\s+(?P\d+)\s*$") -#: Matches any ``OK!``/``ERROR!`` terminator line, regardless of command id. +#: The three terminator statuses the device can send. ``ABORTED!`` is +#: emitted for commands cancelled by an ``abort`` and for motion commands +#: issued while the abort latch is set. +_END_STATUSES = "OK!|ERROR!|ABORTED!" +#: Matches any terminator line, regardless of command id. #: Used by :meth:`MicroSpinBackend._drain_stale_responses` to identify #: terminators from previously-cancelled commands without needing to know #: their cmd-ids. -_ANY_TERMINATOR_RE = re.compile(r"^(?:OK!|ERROR!)\s+.*\s+\d+\s*$") +_ANY_TERMINATOR_RE = re.compile(rf"^(?:{_END_STATUSES})\s+.*\s+\d+\s*$") class MicroSpinError(RuntimeError): @@ -60,19 +64,43 @@ class MicroSpinError(RuntimeError): command_id: The numeric command id assigned by the device, or -1 if the ACK could not be parsed. error_lines: The diagnostic lines (``Error : ...``) the device emitted - before the ``ERROR!`` terminator. + before the ``ERROR!`` (or ``ABORTED!``) terminator. """ + #: The terminator keyword that triggered this exception. Overridden in + #: subclasses (e.g. :class:`MicroSpinAbortedError`). + TERMINATOR: str = "ERROR!" + def __init__(self, command: str, command_id: int, error_lines: List[str]): self.command = command self.command_id = command_id self.error_lines = list(error_lines) super().__init__( - f"MicroSpin returned ERROR! for {command!r} (id={command_id}):\n " + f"MicroSpin returned {self.TERMINATOR} for {command!r} (id={command_id}):\n " + ("\n ".join(error_lines) if error_lines else "(no error detail)") ) +class MicroSpinAbortedError(MicroSpinError): + """Raised when the MicroSpin terminates a command with ``ABORTED!``. + + The firmware emits ``ABORTED!`` (rather than ``ERROR!``) when: + + * a motion command (``home``, ``open``, ``spin``) was cancelled mid-flight + by an :meth:`MicroSpinBackend.abort`, or + * a motion command was issued while the device's abort latch was set + (i.e. before :meth:`MicroSpinBackend.clear_button_abort` had been + called). In this case the firmware sends ``ACK!`` followed immediately + by ``ABORTED!`` with no diagnostic data lines. + + Recovery is the same as for an :meth:`abort` event: call + :meth:`MicroSpinBackend.reset` to clear the latch and confirm the rotor + has stopped. + """ + + TERMINATOR = "ABORTED!" + + class MicroSpinProtocolError(RuntimeError): """Raised when the MicroSpin emits a response we cannot parse.""" @@ -96,6 +124,16 @@ class MicroSpinBackend(CentrifugeBackend): #: detect spin-down. Spinning below this triggers a :class:`UserWarning`; #: see :meth:`spin` for the failure mode. LOW_G_WARNING_THRESHOLD = 30 + #: Deceleration fraction (0-1) below which spin-down is *slow but + #: legitimate*: a tested ``spin 1000 100 20 10`` (decel = 0.20) took ~7 + #: minutes to fully stop. Spinning below this threshold triggers a + #: :class:`UserWarning` so callers can plan their timeouts accordingly. + SLOW_DECEL_WARNING_THRESHOLD = 0.40 + #: Deceleration fraction (0-1) below which the firmware appears to *hang* + #: rather than just be slow: a tested ``spin 1000 100 10 10`` (decel = + #: 0.10) ran for >30 minutes without ever reporting spin-down. Spinning + #: below this threshold triggers a stronger :class:`UserWarning`. + STUCK_DECEL_WARNING_THRESHOLD = 0.20 def __init__( self, @@ -235,17 +273,20 @@ async def _send_command_no_lock(self, command: str) -> List[str]: command_id = int(m.group("id")) logger.debug("[microspin] <<< ACK id=%d", command_id) - # Stages 3+4: data lines until OK!/ERROR! - end_re = re.compile(rf"^(?POK!|ERROR!)\s+.*\s+{command_id}\s*$") + # Stages 3+4: data lines until OK!/ERROR!/ABORTED! + end_re = re.compile(rf"^(?P{_END_STATUSES})\s+.*\s+{command_id}\s*$") data: List[str] = [] while True: line = await self._readline() end = end_re.match(line) if end: self._pending_terminator_count -= 1 - if end.group("status") == "OK!": + status = end.group("status") + if status == "OK!": logger.debug("[microspin] <<< OK (%d data lines)", len(data)) return data + if status == "ABORTED!": + raise MicroSpinAbortedError(command, command_id, data) raise MicroSpinError(command, command_id, data) data.append(line) @@ -413,6 +454,20 @@ async def spin( because the firmware still considers a spin in progress. If you hit this, the recovery path is :meth:`abort` followed by :meth:`clear_button_abort` (and possibly a power-cycle). + UserWarning: If ``deceleration`` is below + :attr:`STUCK_DECEL_WARNING_THRESHOLD` (0.20 = 20 % of max). Very + low decel rates have empirically failed to ever report spin-down + in real-world testing (``spin 1000 100 10 10`` ran for >30 min + with no completion). The recovery path is the same as for the + low-G hang above. + UserWarning: If ``deceleration`` is below + :attr:`SLOW_DECEL_WARNING_THRESHOLD` (0.40 = 40 % of max) but at + or above the stuck threshold. Spin-down completes correctly here + but is slow: tested ``spin 1000 100 20 10`` (decel = 0.20) took + ~7 minutes. Make sure your :meth:`wait_for_spindle_stopped` + budget (default 30 min) and any application-level timeouts allow + for this. Only one decel warning is emitted per call; if both + thresholds are crossed, the stuck-decel warning takes precedence. Safety: See the module-level docstring for the pre-spin checklist. This method @@ -438,6 +493,29 @@ async def spin( stacklevel=2, ) + if deceleration < self.STUCK_DECEL_WARNING_THRESHOLD: + warnings.warn( + f"Spinning the MicroSpin with deceleration={deceleration} " + f"(<{self.STUCK_DECEL_WARNING_THRESHOLD}, i.e. <{int(self.STUCK_DECEL_WARNING_THRESHOLD * 100)} %) " + "may trigger a firmware bug where the rotor never reports having " + "spun down: a tested `spin 1000 100 10 10` ran for >30 minutes " + "without ever completing. If this happens, call `abort()` followed " + "by `clear_button_abort()`, and power-cycle if the device stays stuck.", + UserWarning, + stacklevel=2, + ) + elif deceleration < self.SLOW_DECEL_WARNING_THRESHOLD: + warnings.warn( + f"Spinning the MicroSpin with deceleration={deceleration} " + f"(<{self.SLOW_DECEL_WARNING_THRESHOLD}, i.e. <{int(self.SLOW_DECEL_WARNING_THRESHOLD * 100)} %) " + "results in a long spin-down: a tested `spin 1000 100 20 10` " + "took ~7 minutes from full speed to stopped. Make sure your " + "`wait_for_spindle_stopped` budget and any application-level " + "timeouts allow for this.", + UserWarning, + stacklevel=2, + ) + g_int = int(round(g)) duration_int = int(round(duration)) accel_pct = max(1, min(100, int(round(acceleration * 100)))) diff --git a/pylabrobot/centrifuge/highres/microspin_tests.py b/pylabrobot/centrifuge/highres/microspin_tests.py index b953c454fa5..fadd12430f8 100644 --- a/pylabrobot/centrifuge/highres/microspin_tests.py +++ b/pylabrobot/centrifuge/highres/microspin_tests.py @@ -24,6 +24,7 @@ from typing import List, Tuple from pylabrobot.centrifuge.highres.microspin_backend import ( + MicroSpinAbortedError, MicroSpinBackend, MicroSpinError, MicroSpinProtocolError, @@ -117,6 +118,27 @@ async def test_error_response_carries_diagnostic_lines(self): self.assertEqual(cm.exception.command, "spin 0 0 0 1") self.assertEqual(cm.exception.error_lines, ["Error 1: (00:00:01) -12: bad params"]) + async def test_aborted_terminator_raises_aborted_error(self): + """The device emits ``ABORTED!`` (a third terminator) for commands + cancelled by an abort or issued while the abort latch is set; the + parser must distinguish these from regular ``ERROR!`` responses. + """ + backend, _ = _make_backend( + [ + "ACK! spin 500 100 100 5 21\r\n", + "ABORTED! spin 500 100 100 5 21\r\n", + ] + ) + with self.assertRaises(MicroSpinAbortedError) as cm: + await backend.send_command("spin 500 100 100 5") + self.assertEqual(cm.exception.command_id, 21) + self.assertEqual(cm.exception.command, "spin 500 100 100 5") + # ABORTED! typically arrives with no preceding diagnostic data lines. + self.assertEqual(cm.exception.error_lines, []) + # And it's a MicroSpinError subclass so plain `except MicroSpinError` + # in caller code still catches aborts. + self.assertIsInstance(cm.exception, MicroSpinError) + class MicroSpinStreamResyncTests(unittest.IsolatedAsyncioTestCase): """After a cancelled/timed-out command, the next command must transparently @@ -276,6 +298,59 @@ async def test_spin_does_not_warn_at_or_above_threshold(self): ] self.assertEqual(low_g_warnings, []) + async def test_spin_warns_at_slow_decel(self): + """decel in [0.20, 0.40) -> 'long spin-down' warning, command still runs.""" + backend, writer = _make_backend( + [ + "ACK! spin 1000 100 30 10 1\r\n", + "OK! spin 1000 100 30 10 1\r\n", + ] + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await backend.spin(g=1000, duration=10, acceleration=1.0, deceleration=0.30) + decel_warnings = [ + w for w in caught if issubclass(w.category, UserWarning) and "deceleration" in str(w.message) + ] + self.assertEqual(len(decel_warnings), 1, [str(w.message) for w in caught]) + self.assertIn("long spin-down", str(decel_warnings[0].message)) + self.assertEqual(_sent_commands(writer), ["spin 1000 100 30 10"]) + + async def test_spin_warns_at_stuck_decel_threshold(self): + """decel < 0.20 -> 'firmware hang' warning takes precedence.""" + backend, writer = _make_backend( + [ + "ACK! spin 1000 100 10 10 1\r\n", + "OK! spin 1000 100 10 10 1\r\n", + ] + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await backend.spin(g=1000, duration=10, acceleration=1.0, deceleration=0.10) + decel_warnings = [ + w for w in caught if issubclass(w.category, UserWarning) and "deceleration" in str(w.message) + ] + # Exactly one decel warning: the more severe (stuck) one wins. + self.assertEqual(len(decel_warnings), 1, [str(w.message) for w in caught]) + self.assertIn("firmware bug", str(decel_warnings[0].message)) + self.assertEqual(_sent_commands(writer), ["spin 1000 100 10 10"]) + + async def test_spin_does_not_warn_at_safe_decel(self): + """decel >= 0.40 is fully safe and emits no decel warning.""" + backend, _ = _make_backend( + [ + "ACK! spin 1000 100 40 10 1\r\n", + "OK! spin 1000 100 40 10 1\r\n", + ] + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await backend.spin(g=1000, duration=10, acceleration=1.0, deceleration=0.40) + decel_warnings = [ + w for w in caught if issubclass(w.category, UserWarning) and "deceleration" in str(w.message) + ] + self.assertEqual(decel_warnings, []) + async def test_maintenance_door_and_lock_methods_raise_not_implemented(self): """open_door / close_door / the four lock primitives are maintenance-only on the MicroSpin (manual §6.7). They must raise rather than silently diff --git a/pylabrobot/centrifuge/highres/mock_server.py b/pylabrobot/centrifuge/highres/mock_server.py index 404ae77f02b..243926183c9 100644 --- a/pylabrobot/centrifuge/highres/mock_server.py +++ b/pylabrobot/centrifuge/highres/mock_server.py @@ -8,29 +8,40 @@ path of the backend (not just stubs that replay canned bytes). * Hand-driving via ``nc`` / ``telnet`` while developing or debugging. * Reproducing tricky firmware behaviours -- e.g. the "``status`` blocks until - the spindle has truly stopped" gate that :meth:`MicroSpinBackend.reset` - relies on, or the low-G hang we warn about in :meth:`MicroSpinBackend.spin`. - -The server is small and *not* a perfect emulator -- it implements only the -commands pylabrobot uses, plus a few handy ones for diagnostics (``status``, -``hss``, ``errors``, ``version``, ``list``). - -Usage from Python:: - - async with MicroSpinMockServer() as srv: - print(f"listening on {srv.host}:{srv.port}") - backend = MicroSpinBackend(host=srv.host, port=srv.port) - await backend.setup() - ... - -Or as a script:: - - $ python -m pylabrobot.centrifuge.highres.mock_server --port 1000 - # in another shell: - $ nc 127.0.0.1 1000 + the spindle has truly stopped" gate, the ``ABORTED!`` terminator the + device emits for commands cancelled by an ``abort``, and the low-G + spin-down-detection hang. + +The mock implements **only** the commands the real device's ``list`` +enumerates (manual \u00a76.7's public set): + + clearbuttonabort, cba + commandstat, cstat + disconnect, d + errors, e + help + info, ?? + list, ? + logcommands + version, v + whoami + abort, a home - ACK! home 1 - OK! home 1 + homedstatus, hss + open + spin, sp + status, s + +Maintenance commands (``od``, ``cd``, ``lockdoor``, ``unlockdoor``, +``locknest``, ``unlocknest``, ``r``, ``copleyget``, ``copleyset``, ``ddio``, +etc.) are deliberately not modelled. Sending one to the mock will produce +the same ``Command "" not recognized!`` response the real device gives +to unknown commands. + +Response text -- including the layout of ``list``/``info``/``help``, the +"Issue the 'clearbuttonabort' (cba) command to re-enable the machine" line +emitted by ``abort``, and the use of the ``ABORTED!`` terminator -- was +captured verbatim from real-device netcat sessions. """ from __future__ import annotations @@ -45,13 +56,36 @@ logger = logging.getLogger(__name__) +# ============================================================================ +# Internal handler-control exceptions +# ============================================================================ + + class _MockError(Exception): - """Raised inside a command handler to produce an ``ERROR!`` terminator.""" + """Raised inside a command handler to produce an ``ERROR!`` terminator. + + ``error_lines`` are written as data lines before the terminator, just + like the firmware emits ``Error N: ...`` entries before ``ERROR!``. + """ def __init__(self, error_lines: List[str]): self.error_lines = list(error_lines) +class _MockAborted(Exception): + """Raised inside a command handler to produce an ``ABORTED!`` terminator. + + The real device emits ``ABORTED!`` (not ``ERROR!``) when a motion command + is cancelled by an ``abort``, or when a motion command is issued while the + abort latch is set (i.e. before ``clearbuttonabort``). + """ + + +# ============================================================================ +# Mutable mock device state +# ============================================================================ + + @dataclass class MockState: """In-memory model of the MicroSpin's relevant firmware state.""" @@ -71,7 +105,7 @@ class MockState: # False and any subsequent `status` waits forever, reproducing the # firmware bug we warn about in :meth:`MicroSpinBackend.spin`. spindle_settled: asyncio.Event = field(default_factory=asyncio.Event) - # When True, motion handlers refuse to mark the spindle as settled. + #: When True, motion handlers refuse to mark the spindle as settled. simulate_low_g_hang: bool = False def __post_init__(self): @@ -83,14 +117,208 @@ def push_error(self, code: int, message: str) -> None: self.errors.append(f"Error {len(self.errors) + 1}: ({ts}) {code}: {message}") -class MicroSpinMockServer: - """A localhost TCP server that speaks the MicroSpin remote-control protocol. +# ============================================================================ +# Command specification table -- single source of truth for list/info/help +# ============================================================================ + + +@dataclass(frozen=True) +class _CommandSpec: + """Static metadata about one wire command. - Multiple clients can connect concurrently (the real firmware allows up to - 10); each gets its own command-id counter is *not* shared across clients, - which matches the real device's behaviour. + ``description`` is the single-line summary shown by ``list``. + ``params_signature`` is the parameter-line ``info``/``help`` print + immediately under the description (e.g. ``"(No parameters)"`` or + ``"Parameters(1): "``). + ``params_description`` are additional indented lines printed under the + signature in ``info``/``help`` (often a description of each parameter). """ + name: str + aliases: tuple # tuple of alias strings (may be empty) + description: str + params_signature: str + params_description: tuple # tuple of extra info lines + + @property + def display_name(self) -> str: + """e.g. ``"homedstatus, hss"`` or ``"home"`` (used by list/info).""" + if not self.aliases: + return self.name + return f"{self.name}, {', '.join(self.aliases)}" + + +# Width of the name+alias column in `list`/`info` output (real device uses 32). +_NAME_COLUMN_WIDTH = 32 + + +# This table drives the responses to `list`, `info`, and `help `. The +# command order matches the real device's output: client/server-side commands +# first, then machine-side commands. +# +# IMPORTANT: any change here is visible on the wire and affects every test +# that scrapes list/info output. +_COMMAND_TABLE: List[_CommandSpec] = [ + _CommandSpec( + name="clearbuttonabort", + aliases=("cba",), + description="Clear abort state.", + params_signature="(No parameters)", + params_description=(), + ), + _CommandSpec( + name="commandstat", + aliases=("cstat",), + description="Gets status of a command.", + params_signature="Parameters(1): ", + params_description=("",), # real device emits a trailing blank info line + ), + _CommandSpec( + name="disconnect", + aliases=("d",), + description="Close the current client's connection.", + params_signature="(No parameters)", + params_description=(), + ), + _CommandSpec( + name="errors", + aliases=("e",), + description="Display the top 10 errors on the error stack.", + params_signature="Parameters(0 - 1): []", + params_description=("Optional parameter specifies the max number of errors to display.",), + ), + _CommandSpec( + name="help", + aliases=(), + description="Displays the parameter information for a specific command.", + params_signature="Parameters(1): ", + params_description=("Where is the name of the command to view information about.",), + ), + _CommandSpec( + name="info", + aliases=("??",), + description="Displays the list of user commands with parameter information.", + params_signature="Parameters(0 - 1): [all]", + params_description=("If 'all' is specified, maintenance commands will be included.",), + ), + _CommandSpec( + name="list", + aliases=("?",), + description="Displays the list of user commands that the server recognizes.", + params_signature="Parameters(0 - 1): [all]", + params_description=("If 'all' is specified, maintenance commands will be included.",), + ), + _CommandSpec( + name="logcommands", + aliases=(), + description="Log all received commands to a file.", + params_signature="Parameters(1): yes|no", + params_description=("Yes will enable logging. No will disable logging.",), + ), + _CommandSpec( + name="version", + aliases=("v",), + description="Return the software version report.", + params_signature="(No parameters)", + params_description=(), + ), + _CommandSpec( + name="whoami", + aliases=(), + description="Get the current client's client number.", + params_signature="(No parameters)", + params_description=(), + ), + _CommandSpec( + name="abort", + aliases=("a",), + description="Stop current machine operation.", + params_signature="(No parameters)", + params_description=(), + ), + _CommandSpec( + name="home", + aliases=(), + description="Homes the system", + params_signature="(No parameters)", + params_description=(), + ), + _CommandSpec( + name="homedstatus", + aliases=("hss",), + description="returns whether the device is in a homed state", + params_signature="(No parameters)", + params_description=(), + ), + _CommandSpec( + name="open", + aliases=(), + description="Open the door and present the specified bucket.", + params_signature="Parameters(1): ", + params_description=(" the bucket number to present",), + ), + _CommandSpec( + name="spin", + aliases=("sp",), + description="Spin the centrifuge.", + params_signature="Parameters(4):