Skip to content

Add HighRes Biosolutions MicroSpin centrifuge backend#1047

Merged
rickwierenga merged 7 commits into
PyLabRobot:mainfrom
c-reiter:add-highres-microspin-centrifuge
May 19, 2026
Merged

Add HighRes Biosolutions MicroSpin centrifuge backend#1047
rickwierenga merged 7 commits into
PyLabRobot:mainfrom
c-reiter:add-highres-microspin-centrifuge

Conversation

@c-reiter
Copy link
Copy Markdown
Contributor

Add HighRes Biosolutions MicroSpin centrifuge backend

This PR adds an integration for the HighRes Biosolutions MicroSpin automated microplate centrifuge, plus an in-process mock TCP server that emulates the device's wire protocol for hardware-free development.

It also refactors the pylabrobot.centrifuge module to a per-vendor folder layout matching pylabrobot.plate_reading.

What's added

Backend — pylabrobot.centrifuge.highres.MicroSpinBackend

  • Speaks the MicroSpin's 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, §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 <bucket> and spin commands handle locking internally, and exposing the primitives would let callers put the device into half-managed states. An escape hatch via backend.send_command(...) exists for service techs who really need them.
  • MicroSpin-specific helpers: home(), is_homed(), abort(), clear_button_abort(), get_status(), get_version(), get_errors(), wait_for_spindle_stopped(), and reset().
  • reset() issues abortclearbuttonabortstatus. The third step is the real "we are stopped" gate: the first two return OK! immediately on the wire (just acknowledgements), but status is queued behind any active motion by the firmware and only answers once the rotor is genuinely stopped.
  • Spin parameters are validated; 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.

Mock server — pylabrobot.centrifuge.highres.MicroSpinMockServer

A localhost asyncio TCP server speaking the same wire protocol, usable two ways:

async with MicroSpinMockServer() as srv:
    cf = MicroSpin(name="mock", host=srv.host, port=srv.port)
    await cf.setup()
    # ... drive it like a real device ...
python -m pylabrobot.centrifuge.highres.mock_server --port 1000
nc 127.0.0.1 1000
  • Implements the firmware's "status blocks until the spindle has truly stopped" semantics — this is what makes reset() work as a synchronisation primitive.
  • simulate_low_g_hang flag reproduces the firmware quirk above so the recovery path can be tested deterministically.

Refactor: per-vendor folder layout

The pylabrobot.centrifuge module now mirrors pylabrobot.plate_reading:

pylabrobot/centrifuge/
├── __init__.py             (re-exports all vendors)
├── access2.py              (deprecation shim → agilent/)
├── backend.py
├── centrifuge.py
├── centrifuge_tests.py
├── chatterbox.py
├── standard.py
├── vspin_backend.py        (deprecation shim → agilent/)
│
├── agilent/                ← NEW (Agilent VSpin + Access2 loader)
│   ├── __init__.py
│   ├── access2.py
│   └── vspin_backend.py
│
└── highres/                ← NEW (HighRes Biosolutions MicroSpin)
    ├── __init__.py
    ├── microspin.py
    ├── microspin_backend.py
    ├── microspin_tests.py
    ├── mock_server.py
    └── mock_server_tests.py

Existing imports keep working. pylabrobot.centrifuge.vspin_backend and .access2 re-export from .agilent.* and emit DeprecationWarning, identical to the pattern used by plate_reading.biotek_backend etc.

Other fixes

  • Imported unittest.mock in centrifuge_tests.py (pre-existing bug that prevented the test class from running on Python 3.12).

Tests

File Count What
microspin_tests.py 23 Stub-based: protocol edge cases (bad ACK, EOF), argument validation, reset() error branches, timeout extension logic, serialization.
mock_server_tests.py 24 Real-TCP integration: every backend method against the mock, plus the status-blocks-during-motion gate and the low-G hang reproduction.
centrifuge_tests.py 13 Unchanged front-end tests.

60 new centrifuge tests all pass in ~3 s. Full project suite still passes (1643 tests).

Docs

  • New user-guide notebook: docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb
    • Network configuration (direct-attached, DHCP, IP discovery)
    • Connecting from PLR with port customisation
    • Homing, status, version, bucket positioning, door operation
    • Spinning with a pre-spin checklist quoting the manual
    • The low-G hang warning + recovery path
    • reset() and the three-step abort/cba/status sequence
    • Developing without hardware — section pointing developers at the mock server
  • docs/api/pylabrobot.centrifuge.rst updated to the multi-vendor pattern (backends grouped under agilent.* and highres.* paths).

Contributor-guide compliance

Check Status
make test ✅ 1643 passed, 1 skipped
make lint (ruff)
make format-check (ruff format + import order)
make typecheck (mypy --check-untyped-defs)
make docs (strict, warnings-as-errors)
Google-style docstrings on all functions/classes
Type hints
Pre-commit hooks (ruff-format, ruff-check, typos)
OS-agnostic (no Win/Mac/Linux-specific code) ✅ (pure stdlib asyncio)

Safety note

This integration was developed against a physical HighRes MicroSpin during the reverse-engineering phase, but the spin command itself was never executed by the integration code — all wire-level behaviour in the test suite runs against the mock server. The first real spin on any given unit should follow the manual's commissioning checklist (§§5.3, 7, 8): tie-wraps removed, buckets seated and balanced, chamber clean, air pressure 70-135 psi, door closed.

Backwards compatibility

No breaking changes. The deprecation shims preserve all existing import paths from pylabrobot.centrifuge.vspin_backend and pylabrobot.centrifuge.access2.

c-reiter added 2 commits May 18, 2026 21:50
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 <bucket>` 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.
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).
@rickwierenga
Copy link
Copy Markdown
Member

thanks for the PR this is sick! first high res device in PLR

There is no "open the door without choosing a bucket" workflow on the
MicroSpin: `open <bucket>` (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.
@rickwierenga
Copy link
Copy Markdown
Member

while the vendor refactor is a good idea, we are actually about to move to a new architecture (see #1000, https://discuss.pylabrobot.org/t/updating-plr-api-for-machine-interfaces-discussion/445) and I do not want to introduce more deprecation warnings before then..

Hold off on the agilent/ subpackage split (and its deprecation shims) until
the broader machine-interface architecture lands (see PyLabRobot#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) <noreply@anthropic.com>
@rickwierenga
Copy link
Copy Markdown
Member

This integration was developed against a physical HighRes MicroSpin during the reverse-engineering phase, but the spin command itself was never executed by the integration code

will you be able to test this on a machine?

@rickwierenga
Copy link
Copy Markdown
Member

There is no separate "open the door without a bucket" workflow.

nice, this will be useful for the refactor (we usually/always to have tweak the abstraction layer when adding a 2nd machine with a particular capability). for now yes I think the not implemented errors are correct to minimize changes to the existing Centrifuge in the v0 api

@rickwierenga
Copy link
Copy Markdown
Member

can I rewrite this using pylabrobot.io.Socket? this is a thin wrapper around StreamReader and StreamWriter you already use, but has nice logging and validation

c-reiter added 2 commits May 18, 2026 22:33
…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.
The mock previously emitted error data lines as bare "Error: <message>"
strings and only ever included the single new error. The real device
emits errors in the format

    Error N: (HH:MM:SS) <code>: <message>

where N is the position in a persistent error stack, code is typically
-12 for parsing/argument issues, and the device dumps up to the last 10
entries from the stack as data lines before every ERROR! terminator
(per the manual's wording on the `errors` command help: "Display the
top 10 errors on the error stack.").

This commit refactors `_MockError` to carry a (message, code) pair, and
moves the wire-formatting + stack accumulation into the dispatcher. All
existing in-handler raises drop the "Error: " prefix from their
messages -- they now pass just the message text, exactly as the real
device's underlying error-generation paths do.

Side-by-side vs. the real device:

  Real device:
    Error 35: (04:46:32) -12: Command "close" not recognized!
  Mock (this commit):
    Error 2:  (05:42:20) -12: Command "close" not recognized!

Identical apart from the per-session stack index and current timestamp.

86 tests still passing.
@c-reiter
Copy link
Copy Markdown
Contributor Author

thanks for the PR this is sick! first high res device in PLR

thanks!

while the vendor refactor is a good idea, we are actually about to move to a new architecture...

makes sense - happy with the revert

will you be able to test this on a machine?

yep, just ran the integration script end-to-end on the real unit. 300g 5s spin, door auto-closed, no new errors on the stack. all good

for now yes I think the not implemented errors are correct...

thanks

can I rewrite this using pylabrobot.io.Socket?

go for it

@rickwierenga
Copy link
Copy Markdown
Member

makes sense - happy with the revert

already done in b45f61f :)

- Replace hand-rolled asyncio.open_connection with pylabrobot.io.socket.Socket
  for capture/validation support and consistency with other TCP backends.
- Rename `get_status`/`get_version`/`get_errors` to `request_*` per the PLR
  convention for methods that query the device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rickwierenga
Copy link
Copy Markdown
Member

rewrote using plr.io.socket layer. would you please be able to just confirm everything works as expected still? should be fine since I was able to test with the mock server

@rickwierenga rickwierenga merged commit 50f08a4 into PyLabRobot:main May 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants