Add HighRes Biosolutions MicroSpin centrifuge backend#1047
Conversation
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).
|
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.
|
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>
will you be able to test this on a machine? |
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 |
|
can I rewrite this using |
…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.
thanks!
makes sense - happy with the revert
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
thanks
go for it |
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>
|
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 |
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.centrifugemodule to a per-vendor folder layout matchingpylabrobot.plate_reading.What's added
Backend —
pylabrobot.centrifuge.highres.MicroSpinBackendSERVER_PORTsetting).list all/help/settingsintrospection commands.CentrifugeBackendAPI to the right wire commands. The four maintenance-only lock primitives (lockdoor,unlockdoor,locknest,unlocknest) raiseNotImplementedErrorper the contributor guide: the higher-levelopen <bucket>andspincommands handle locking internally, and exposing the primitives would let callers put the device into half-managed states. An escape hatch viabackend.send_command(...)exists for service techs who really need them.home(),is_homed(),abort(),clear_button_abort(),get_status(),get_version(),get_errors(),wait_for_spindle_stopped(), andreset().reset()issuesabort→clearbuttonabort→status. The third step is the real "we are stopped" gate: the first two returnOK!immediately on the wire (just acknowledgements), butstatusis queued behind any active motion by the firmware and only answers once the rotor is genuinely stopped.spin()withg < 30emits aUserWarning— 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.MicroSpinMockServerA localhost asyncio TCP server speaking the same wire protocol, usable two ways:
statusblocks until the spindle has truly stopped" semantics — this is what makesreset()work as a synchronisation primitive.simulate_low_g_hangflag reproduces the firmware quirk above so the recovery path can be tested deterministically.Refactor: per-vendor folder layout
The
pylabrobot.centrifugemodule now mirrorspylabrobot.plate_reading:Existing imports keep working.
pylabrobot.centrifuge.vspin_backendand.access2re-export from.agilent.*and emitDeprecationWarning, identical to the pattern used byplate_reading.biotek_backendetc.Other fixes
unittest.mockincentrifuge_tests.py(pre-existing bug that prevented the test class from running on Python 3.12).Tests
microspin_tests.pyreset()error branches, timeout extension logic, serialization.mock_server_tests.pycentrifuge_tests.py60 new centrifuge tests all pass in ~3 s. Full project suite still passes (1643 tests).
Docs
docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynbreset()and the three-step abort/cba/status sequencedocs/api/pylabrobot.centrifuge.rstupdated to the multi-vendor pattern (backends grouped underagilent.*andhighres.*paths).Contributor-guide compliance
make testmake lint(ruff)make format-check(ruff format + import order)make typecheck(mypy--check-untyped-defs)make docs(strict, warnings-as-errors)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_backendandpylabrobot.centrifuge.access2.