Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
75d7097
Upgrade VSpin and Access2 protocol drivers
rickwierenga Aug 30, 2026
7f7e3b6
Complete Access2 motion verification
rickwierenga Aug 30, 2026
6ed7eed
Complete VSpin lifecycle verification
rickwierenga Aug 30, 2026
05c8228
Fix VSpin NMC network synchronization
rickwierenga Sep 2, 2026
089ed4e
Align Access2 setup and gripper behavior
rickwierenga Sep 2, 2026
128377d
Match VSpin servo transition timing
rickwierenga Sep 3, 2026
aa5adba
Synchronize VSpin NMC startup reads
rickwierenga Sep 3, 2026
25ae578
Serialize VSpin state-changing commands
rickwierenga Sep 3, 2026
eb7e739
Validate Access2 gripper closure from status
rickwierenga Sep 4, 2026
662004e
Add VSpin and Access2 state machines
rickwierenga Sep 5, 2026
78574bf
Update vspin centrifuge status from 'mostly' to 'full'
rickwierenga Sep 6, 2026
7311067
Accept completed Access2 gripper status
rickwierenga Sep 6, 2026
e63b2b6
Merge branch 'main' into vspin-access2-upgrade
rickwierenga Sep 6, 2026
298e9e4
Fix VSpin preparation cancellation and compatibility
rickwierenga Sep 7, 2026
504a055
Stop VSpin after spin trajectory reply failures
rickwierenga Sep 7, 2026
556b912
Turn off VSpin motor after setup motion failures
rickwierenga Sep 7, 2026
38dc25c
Turn off VSpin motor after spin preparation failures
rickwierenga Sep 7, 2026
cd79823
Document VSpin and Access2 state machines with build-generated diagrams
rickwierenga Sep 8, 2026
64ca5e5
Make Access2 plate and motion settings per-operation arguments
rickwierenga Sep 8, 2026
9716e0d
Merge remote-tracking branch 'origin/main' into vspin-access2-upgrade
rickwierenga Sep 8, 2026
8f7f857
Keep Access2 support status full on the VSpin upgrade branch
rickwierenga Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,18 @@ jobs:
with:
python-version: '3.11'

- name: Install Graphviz
run: |
sudo apt-get update
sudo apt-get install -y graphviz

- name: Install dependencies
run: pip install -e '.[dev]'

- name: Check documentation
run: |
rm -rf docs/build docs/_autosummary
make docs-check
make docs

deploy_docs:
name: Build and deploy documentation
Expand All @@ -55,6 +60,11 @@ jobs:
with:
python-version: '3.11'

- name: Install Graphviz
run: |
sudo apt-get update
sudo apt-get install -y graphviz

- name: Install dependencies
run: pip install -e '.[dev]'

Expand Down
5 changes: 3 additions & 2 deletions docs/_static/devices.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"capabilities": [
"centrifuging"
],
"status": "mostly",
"status": "full",
"api": "pylabrobot.agilent.vspin.VSpin",
"api_version": "v1",
"code_slug": "agilent/vspin",
Expand All @@ -53,10 +53,11 @@
"capabilities": [
"centrifuging"
],
"status": "basic",
"status": "full",
"api": "pylabrobot.agilent.vspin.Access2",
"api_version": "v1",
"code_slug": "agilent/vspin",
"doc_slug": "agilent/vspin/hello-world",
"manager": "https://discuss.pylabrobot.org/u/rickwierenga",
"oem": "https://www.agilent.com/en/product/automated-liquid-handling/automated-microplate-management/microplate-centrifuge"
},
Expand Down
13 changes: 13 additions & 0 deletions docs/_static/graphviz.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Link each generated SVG to its full-size view without depending on Sphinx's hashed filename. */
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("object.graphviz").forEach((diagram) => {
const paragraph = document.createElement("p");
const link = document.createElement("a");
link.href = diagram.data;
link.textContent = "Open full-size diagram";
link.target = "_blank";
link.rel = "noopener";
paragraph.appendChild(link);
diagram.parentElement.appendChild(paragraph);
});
});
7 changes: 7 additions & 0 deletions docs/api/pylabrobot.agilent.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,14 @@ BioTek Synergy H1
SynergyH1


.. _vspin-api:

VSpin
-----

For operation states, plate-transfer coordination, and recovery behavior, see the
:doc:`VSpin and Access2 state-machine guide </user_guide/agilent/vspin/state-machine>`.

.. currentmodule:: pylabrobot.agilent.vspin

.. autosummary::
Expand All @@ -99,6 +104,8 @@ VSpin
VSpin
Access2
Access2Driver
ServoStatus
Access2Status


PlateLoc
Expand Down
5 changes: 5 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"sphinx.ext.autosectionlabel",
"sphinx.ext.intersphinx",
"sphinx.ext.mathjax",
"sphinx.ext.graphviz",
"myst_nb",
"sphinx_copybutton",
"IPython.sphinxext.ipython_console_highlighting",
Expand Down Expand Up @@ -87,6 +88,9 @@
#
html_theme = "pydata_sphinx_theme"

# Render DOT diagrams as scalable images during HTML builds.
graphviz_output_format = "svg"

# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
Expand All @@ -103,6 +107,7 @@
html_css_files.append("plr_cards.css") # served from _static/plr_cards.css

html_js_files = list(globals().get("html_js_files", []))
html_js_files.append("graphviz.js")
if "plr_cards.js" not in html_js_files:
html_js_files.append("plr_cards.js") # served from _static/plr_cards.js

Expand Down
5 changes: 5 additions & 0 deletions docs/contributor_guide/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ Use PyLabRobot's [default units](../user_guide/getting-started/units.md) in publ

It is important that you write documentation for your code. As a rule of thumb, all functions and classes, whether public or private, are required to have a docstring. PyLabRobot uses [Google Style Python Docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html). In addition, PyLabRobot uses [type hints](https://docs.python.org/3/library/typing.html) to document the types of variables.

Install the Graphviz system package before building documentation (`sudo apt-get install graphviz`
on Debian/Ubuntu or `brew install graphviz` on macOS). The `dot` executable must be on `PATH`.
Sphinx renders diagrams from their `.dot` sources during the build; edit the source rather than
saving generated SVG files in the documentation source tree.

To build the documentation, run `make docs` in the root directory. The documentation will be built in `docs/build`. Run `open docs/build/index.html` to open the documentation in your browser.

## Common Tasks
Expand Down
2 changes: 2 additions & 0 deletions docs/contributor_guide/device-driver-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Keep it small and idiomatic to PyLabRobot.

The public surface must expose **no non-idempotent commands.** If the hardware only offers a raw toggle/flip, keep it private (`_toggle_x`) and expose move-to-state methods (`move_x_out` / `move_x_in`) that read current state, act only if needed, then confirm. This keeps the API safe to call repeatedly — the caller states intent ("be open"), not a blind toggle.

Keep connection, calibration, and state on the device; pass operation-specific settings (plate geometry, grip, offsets, speed, duration) as method arguments without carrying them between calls.

### Unverified drivers

If the driver hasn't been checked against real hardware, say so loudly: `setup()` should `logger.warning(...)` that it's untested and invite a change once someone verifies it. Don't quietly present untested code as ready.
Expand Down
100 changes: 98 additions & 2 deletions docs/user_guide/agilent/vspin/hello-world.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"\n",
"The Agilent VSpin is a two-bucket microplate centrifuge. This quickstart connects directly to the\n",
"centrifuge, calibrates its bucket positions, runs one balanced spin, and disconnects. The optional\n",
"Access2 plate loader is not covered here.\n",
"Access2 plate loader settings are covered at the end.\n",
"\n",
"| Property | Value |\n",
"|---|---|\n",
Expand All @@ -22,7 +22,7 @@
"```{warning}\n",
"Follow the centrifuge manufacturer's installation, plate-compatibility, balancing, and safety\n",
"instructions. Before every run, use two opposing loads of equal mass and make sure both are fully\n",
"seated. The driver does not provide a public software abort command, so keep the instrument's\n",
"seated. `stop_spin()` provides a controlled software abort, but always keep the instrument's\n",
"physical controls accessible.\n",
"```"
]
Expand Down Expand Up @@ -275,6 +275,22 @@
")"
]
},
{
"cell_type": "markdown",
"id": "vspin-abort-md",
"metadata": {},
"source": [
"## Abort an active spin safely\n",
"\n",
"If another task needs to abort a running `spin()` call, use `stop_spin()`. It sends a controlled\n",
"zero-velocity trajectory and returns only after the tachometer confirms that the rotor stopped.\n",
"The physical emergency stop remains the authority for an emergency.\n",
"\n",
"```python\n",
"await vspin.stop_spin(deceleration=0.8)\n",
"```"
]
},
{
"cell_type": "markdown",
"id": "vspin-return-bucket1-md",
Expand Down Expand Up @@ -356,6 +372,86 @@
"source": [
"await vspin.stop()"
]
},
{
"cell_type": "markdown",
"id": "access2-operation-parameters",
"metadata": {},
"source": [
"## Optional Access2 loader parameters\n",
"\n",
"Use these examples before disconnecting, with an initialized Access2 loader named `loader`\n",
"paired with the connected, homed `vspin`.\n",
"Before `load()`, present an empty bucket and place and assign the plate on the loader stage;\n",
"before `unload()`, present the occupied bucket and leave the loader stage empty. See the\n",
"[state-machine guide](state-machine.md#access2-plate-transfers) for transfer preconditions.\n",
"\n",
"Pass plate-specific settings to each `load()` or `unload()` call. They are not stored on\n",
"the loader, and changing a plate resource's dimensions does not change the motion settings.\n",
"All values below are in millimeters:\n",
"\n",
"| Parameter | `load()` default | `unload()` default |\n",
"|---|---:|---:|\n",
"| `plate_height` | 10 | 10 |\n",
"| `source_z_offset` | 3 | 3 |\n",
"| `destination_z_offset` | 3 | 3 |\n",
"| `park_z_offset` | 3 | 0 |\n",
"| `gripper_open_position` | 0 | 0 |\n",
"| `gripper_closed_position` | 5.68 | 5.68 |\n",
"| `gripper_close_threshold` | 1.5 | 1.5 |\n",
"\n",
"`source_z_offset` applies at pickup and `destination_z_offset` at placement: the source\n",
"is the loader stage for `load()` and the presented bucket for `unload()`. `plate_height`\n",
"is passed to the controller for all three teachpoint moves, including return to park.\n",
"Gripper positions are absolute axis coordinates, not plate widths or jaw gaps.\n",
"The gripper can stop on plate contact before its closed target; acceptance requires\n",
"motion completion, the close threshold, and plate detection.\n",
"\n",
"For example, explicitly passing the default settings for a load looks like:\n",
"\n",
"```python\n",
"await loader.load(\n",
" plate_height=10,\n",
" source_z_offset=3,\n",
" destination_z_offset=3,\n",
" park_z_offset=3,\n",
" gripper_open_position=0,\n",
" gripper_closed_position=5.68,\n",
" gripper_close_threshold=1.5,\n",
")\n",
"```\n",
"\n",
"Use settings established for the plate and teachpoints in your setup. Every value must\n",
"be finite, height must be positive, and gripper settings must satisfy\n",
"`gripper_open_position < gripper_close_threshold <= gripper_closed_position`.\n",
"Invalid settings are rejected before loader actuation.\n",
"\n",
"Each transfer movement also accepts a speed preset: `\"slow\"`, `\"medium\"`, or `\"fast\"`.\n",
"These are controller presets, not velocities in millimeters per second.\n",
"\n",
"| Parameter | Movement | Default for both directions |\n",
"|---|---|---|\n",
"| `source_speed` | Approach the pickup teachpoint | `\"slow\"` |\n",
"| `destination_speed` | Carry the plate to placement | `\"slow\"` |\n",
"| `park_speed` | Return to park after release | `\"slow\"` |\n",
"| `gripper_open_speed` | Open before pickup | `\"fast\"` |\n",
"| `gripper_close_speed` | Close on the plate | `\"slow\"` |\n",
"| `gripper_release_speed` | Open to release the plate | `\"slow\"` |\n",
"\n",
"Standalone loader movements follow the same per-call pattern:\n",
"\n",
"```python\n",
"await loader.driver.park(plate_height=15, z_offset=8, speed=\"slow\")\n",
"await loader.driver.open_gripper(gripper_open_position=0, speed=\"slow\")\n",
"await loader.driver.close_gripper(\n",
" gripper_closed_position=5.68, gripper_close_threshold=1.5, speed=\"slow\"\n",
")\n",
"```\n",
"\n",
"The examples show method defaults. Standalone `park()` has its own height and offset\n",
"defaults; it does not reuse the preceding transfer's settings. Speed presets are\n",
"validated before actuation, including movements scheduled later in a transfer."
]
}
],
"metadata": {
Expand Down
33 changes: 33 additions & 0 deletions docs/user_guide/agilent/vspin/images/access2-transfer.dot
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
digraph access2_transfer {
graph [bgcolor="#f8fafc", pad=0.2, rankdir=TB, ranksep=0.5, nodesep=0.35,
splines=polyline];
node [shape=box, style="rounded,filled", fillcolor="#dcfce7", color="#16a34a",
fontcolor="#0f172a", fontname="Helvetica", fontsize=14, margin="0.12,0.09"];
edge [color="#475569", fontcolor="#475569", fontname="Helvetica", fontsize=12];

start [label="PARKED\nIDLE", group="left"];
approaching [label="APPROACHING\nSOURCE", group="middle"];
at_source [label="AT SOURCE", group="right"];
gripping [label="GRIPPING", group="right"];
holding [label="HOLDING", group="left"];
moving [label="MOVING TO\nDESTINATION", group="left"];
at_destination [label="AT DESTINATION", group="middle"];
releasing [label="RELEASING", group="right"];
returning [label="RETURNING\nTO PARK", group="right"];

{rank=same; start; approaching; at_source;}
{rank=same; holding; gripping;}
{rank=same; moving; at_destination; releasing;}


start -> approaching;
approaching -> at_source;
at_source -> gripping [label="plate detected", weight=10];
// Reverse the drawn arrows on alternate rows to keep the flow compact.
holding -> gripping [dir=back, label="grip verified"];
holding -> moving [weight=10];
moving -> at_destination;
at_destination -> releasing;
releasing -> returning [label="gripper open", weight=10];
returning -> start [label="park confirmed", constraint=false];
}
45 changes: 45 additions & 0 deletions docs/user_guide/agilent/vspin/images/lifecycle.dot
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
digraph vspin_lifecycle {
graph [bgcolor="#f8fafc", pad=0.2, rankdir=TB, ranksep=0.35, nodesep=0.4];
node [shape=box, style="rounded,filled", fillcolor="#e0f2fe", color="#0284c7",
fontcolor="#0f172a", fontname="Helvetica", fontsize=14, margin="0.12,0.09"];
edge [color="#475569", fontcolor="#475569", fontname="Helvetica", fontsize=12];

connection [label="Connection", shape=plain, fontname="Helvetica-Bold"];
initialization [label="Initialization", shape=plain, fontname="Helvetica-Bold"];
homing [label="Homing", shape=plain, fontname="Helvetica-Bold"];
disconnected [label="DISCONNECTED\ninit/home UNKNOWN"];
connecting [label="CONNECTING"];
connected [label="CONNECTED"];
disconnecting [label="DISCONNECTING"];
init_unknown [label="UNKNOWN"];
initializing [label="INITIALIZING"];
initialized [label="INITIALIZED"];
home_unknown [label="UNKNOWN"];
home_in_progress [label="HOMING"];
homed [label="HOMED"];

{rank=same; connection; initialization; homing;}
{rank=same; disconnected; init_unknown; home_unknown;}
{rank=same; connecting; initializing; home_in_progress;}
{rank=same; connected; initialized; homed;}

connection -> disconnected [style=invis];
initialization -> init_unknown [style=invis];
homing -> home_unknown [style=invis];
disconnected -> connecting [label="setup"];
connecting -> connected [label="transport open"];
connected -> disconnecting [label="stop"];
disconnecting -> disconnected [constraint=false, label="closed"];
init_unknown -> initializing [label="initialize"];
initializing -> initialized [label="confirmed"];
home_unknown -> home_in_progress [label="home"];
home_in_progress -> homed [label="confirmed"];

connected -> initializing [label="required", color="#0369a1", fontcolor="#0369a1", style=dashed, constraint=false];
initialized -> home_in_progress [label="required", color="#0369a1", fontcolor="#0369a1", style=dashed, constraint=false];
ready [label="Semantic readiness\nIDLE + no recovery required", fillcolor="#dcfce7", color="#16a34a"];
connected -> ready [style=dashed, color="#0369a1"];
initialized -> ready [style=dashed, color="#0369a1"];
homed -> ready [style=dashed, color="#0369a1"];
disconnecting -> ready [style=invis];
}
30 changes: 30 additions & 0 deletions docs/user_guide/agilent/vspin/images/state-machine.dot
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
digraph vspin_activity {
graph [bgcolor="#f8fafc", pad=0.2, layout=neato, overlap=true, splines=true, outputorder=edgesfirst];
node [shape=box, style="rounded,filled", fillcolor="#ede9fe", color="#8b5cf6",
fontcolor="#0f172a", fontname="Helvetica", fontsize=16, pin=true, margin="0.12,0.09"];
edge [color="#475569", fontcolor="#475569", fontname="Helvetica", fontsize=14];

idle [pos="3,6!", label="IDLE", penwidth=2];
interlocks [pos="0,6!", label="CHANGING\nINTERLOCKS"];
positioning [pos="0,3.5!", label="POSITIONING"];
transferring [pos="0,1!", label="TRANSFERRING"];
preparing [pos="6,6!", label="PREPARING\nTO SPIN"];
accelerating [pos="6,3.5!", label="ACCELERATING"];
at_speed [pos="6,1!", label="AT SPEED"];
decelerating [pos="3,1!", label="DECELERATING"];

idle -> interlocks [label="door / lock"];
interlocks -> idle [label="confirmed", constraint=false];
idle -> positioning [label="position"];
positioning -> idle [label="confirmed", constraint=false];
idle -> transferring [label="load / unload"];
transferring -> idle [xlabel="loader parked", constraint=false];
idle -> preparing [label="spin"];
preparing -> accelerating [label="trajectory starts"];
accelerating -> at_speed [label="speed verified"];
at_speed -> decelerating [label="duration /\nstop_spin"];
decelerating -> idle [label="stopped", constraint=false];
preparing -> idle [label="stop before motion", color="#0369a1",
fontcolor="#0369a1", constraint=false];
accelerating -> decelerating [label="stop_spin", color="#0369a1", fontcolor="#0369a1"];
}
1 change: 1 addition & 0 deletions docs/user_guide/agilent/vspin/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
:maxdepth: 1

hello-world
state-machine
events
```
Loading
Loading