diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index f81b4494b8d..5d24b500160 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -13,6 +13,17 @@ runs: - name: Update packages run: sudo apt-get update shell: bash + - name: Install PyGObject build dependencies + # PyGObject has no wheels, so pip builds it from source. Its build needs + # cairo (via pycairo) and girepository headers, which the runner lacks. + # Only the cytation-microscopy extra pulls it in; `all` does not. + run: | + if [ "${{ inputs.extra }}" = "cytation-microscopy" ]; then + sudo apt-get install -y pkg-config libcairo2-dev libglib2.0-dev + sudo apt-get install -y libgirepository-2.0-dev \ + || sudo apt-get install -y libgirepository1.0-dev + fi + shell: bash - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dc61754c2ce..33e206a3310 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - extra: ["", serial, usb, ftdi, hid, modbus, opentrons, sila, microscopy, pico] + extra: ["", serial, usb, ftdi, hid, modbus, opentrons, sila, cytation-microscopy, pico] name: Tests (${{ matrix.extra }}, py3.12) runs-on: ${{ matrix.os }} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..6ba2db8b33f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# Agent guide + +Orientation for agents (and humans) working in this repo. + +## Guides + +- **Writing a device driver + its hello-world notebook:** [`docs/contributor_guide/device-driver-guide.md`](docs/contributor_guide/device-driver-guide.md) + +## Working principles + +- **Scope tightly.** Make the change that was asked for and nothing else — no drive-by rewrites of comments, constants, or logic, no unrequested docs/config side-quests. +- **Code documents what it does, not its history.** No diary/changelog comments ("NEW", "now", "previously"), no provenance/origin stories in code, commits, or PRs. Describe what the thing *is*. +- **Keep the repo permanent.** No one-time/backfill/migration scripts committed — run those ad-hoc. +- **Lint before shipping:** ruff + mypy (2-space indent), matching the repo's config. +- **Never drive real hardware without explicit, per-run approval.** diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e2b2fd3351..870ab59605e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `StackerRetrieval` capability (`pylabrobot.capabilities.automated_retrieval.StackerRetrieval`) for sequential ("stacking access") plate storage: one or more single-ended LIFO `ResourceStack` stacks plus a loading tray, with `downstack`/`upstack` operations and a `StackerBackend` interface (plus `StackerChatterboxBackend`). Intended for devices like the Agilent BenchCel and HighRes MicroServe (#1113). +- `AutomatedRetrieval` base capability (`pylabrobot.capabilities.automated_retrieval.AutomatedRetrieval`) that owns the loading tray and the plate-movement plumbing shared by the random-access `RandomAccessRetrieval` and the sequential `StackerRetrieval`. The former random-access `AutomatedRetrieval` is now `RandomAccessRetrieval` and extends this base. - 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. diff --git a/README.md b/README.md index 0430d0fe1ec..b447511b56f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@
Docs | Forum | -Installation | +Installation | Getting started
@@ -193,7 +193,7 @@ await tc.run_pcr_profile( [docs.pylabrobot.org](https://docs.pylabrobot.org) -- [Installation](https://docs.pylabrobot.org/stable/user_guide/_getting-started/installation.html) +- [Installation](https://docs.pylabrobot.org/stable/user_guide/getting-started/installation.html) - [Getting Started](https://docs.pylabrobot.org/stable/user_guide/index.html) - [Contributing](https://docs.pylabrobot.org/stable/contributor_guide/index.html) - [API Reference](https://docs.pylabrobot.org/stable/api/pylabrobot.html) diff --git a/_typos.toml b/_typos.toml index 0f3ba4d4cf4..9a066135d51 100644 --- a/_typos.toml +++ b/_typos.toml @@ -30,6 +30,22 @@ RHE = "RHE" caf = "caf" scap = "scap" +# Sartorius Entris II, a product name. +Entris = "Entris" + +# FluidX IntelliXcap firmware vocabulary. `*WasNotSuccesful` are fault tokens +# keyed in ERROR_MESSAGES and passed to _wait_for_idle as the expected error +# frame; `onnOK` and `LOK` are reply frames. Renaming them breaks the driver. +Succesful = "Succesful" +onn = "onn" +LOK = "LOK" + +# HighRes LidValet settings keys. HighResLidValetSettings.from_lines matches +# each dataclass field name uppercased against the device's `settings` output, +# so these spellings are the device's, not ours. +ouput = "ouput" +hegiht = "hegiht" + [files] extend-exclude = [ "*.ipynb" diff --git a/docs/_exts/plr_devices/__init__.py b/docs/_exts/plr_devices/__init__.py new file mode 100644 index 00000000000..7ace642a06d --- /dev/null +++ b/docs/_exts/plr_devices/__init__.py @@ -0,0 +1 @@ +from .directive import setup # re-export for Sphinx diff --git a/docs/_exts/plr_devices/directive.py b/docs/_exts/plr_devices/directive.py new file mode 100644 index 00000000000..d5977537150 --- /dev/null +++ b/docs/_exts/plr_devices/directive.py @@ -0,0 +1,124 @@ +"""Sphinx directive that renders a 'Supported hardware' table from devices.json. + +Usage in MyST markdown:: + + ```{supported-devices} shaking + ``` + +Or with multiple capabilities:: + + ```{supported-devices} heating, shaking + ``` + +The directive filters devices.json to rows where the device's ``capabilities`` +list intersects with the requested set, then renders a native docutils table +styled by the active Sphinx theme. +""" + +import json +from pathlib import Path + +from docutils import nodes +from docutils.parsers.rst import Directive + + +_DEVICES = None + + +def _load_devices(): + global _DEVICES + if _DEVICES is None: + json_path = Path(__file__).resolve().parents[2] / "_static" / "devices.json" + with open(json_path, encoding="utf-8") as f: + _DEVICES = json.load(f) + return _DEVICES + + +class SupportedDevices(Directive): + """Render a table of devices that have the requested capabilities.""" + + has_content = False + required_arguments = 1 + optional_arguments = 0 + final_argument_whitespace = True + + def run(self): + requested = {c.strip() for c in self.arguments[0].split(",")} + devices = _load_devices() + matches = [ + d for d in devices if requested & set(d.get("capabilities", [])) + ] + + if not matches: + para = nodes.paragraph( + text=f"No supported devices found for: {', '.join(requested)}" + ) + return [para] + + matches.sort(key=lambda d: (d["vendor"], d["name"])) + + # Build a native docutils table + table = nodes.table() + table["classes"].append("table") + + tgroup = nodes.tgroup(cols=4) + table += tgroup + for _ in range(4): + tgroup += nodes.colspec() + + # Header + thead = nodes.thead() + tgroup += thead + header_row = nodes.row() + thead += header_row + for title in ("Device", "Vendor", "Status", "Links"): + entry = nodes.entry() + entry += nodes.paragraph(text=title) + header_row += entry + + # Body + tbody = nodes.tbody() + tgroup += tbody + for d in matches: + row = nodes.row() + tbody += row + + # Device name (bold) + name_entry = nodes.entry() + name_entry += nodes.strong(text=d["name"]) + row += name_entry + + # Vendor + vendor_entry = nodes.entry() + vendor_entry += nodes.paragraph(text=d["vendor"]) + row += vendor_entry + + # Status + status_entry = nodes.entry() + status_entry += nodes.paragraph(text=d.get("status", "")) + row += status_entry + + # Links + links_entry = nodes.entry() + link_nodes = [] + if d.get("docs"): + ref = nodes.reference("", "docs", refuri=d["docs"]) + link_nodes.append(ref) + if d.get("oem"): + if link_nodes: + link_nodes.append(nodes.Text(" · ")) + ref = nodes.reference("", "oem", refuri=d["oem"]) + link_nodes.append(ref) + if link_nodes: + para = nodes.paragraph() + for n in link_nodes: + para += n + links_entry += para + row += links_entry + + return [table] + + +def setup(app): + app.add_directive("supported-devices", SupportedDevices) + return {"version": "0.2", "parallel_read_safe": True} diff --git a/docs/_static/devices.json b/docs/_static/devices.json new file mode 100644 index 00000000000..f950490f658 --- /dev/null +++ b/docs/_static/devices.json @@ -0,0 +1,690 @@ +[ + { + "vendor": "Hamilton", + "name": "STAR(let)", + "capabilities": [ + "liquid handling", + "arm" + ], + "status": "Full", + "docs": "/user_guide/00_liquid-handling/hamilton-star/_hamilton-star.html", + "oem": "https://www.hamiltoncompany.com/microlab-star" + }, + { + "vendor": "Hamilton", + "name": "Vantage", + "capabilities": [ + "liquid handling", + "arm" + ], + "status": "Mostly", + "docs": "/user_guide/00_liquid-handling/hamilton-vantage/_hamilton-vantage.html", + "oem": "https://www.hamiltoncompany.com/microlab-vantage" + }, + { + "vendor": "Hamilton", + "name": "Prep", + "capabilities": [ + "liquid handling" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.hamiltoncompany.com/microlab-prep" + }, + { + "vendor": "Hamilton", + "name": "Nimbus", + "capabilities": [ + "liquid handling", + "arm" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.hamiltoncompany.com/microlab-nimbus" + }, + { + "vendor": "Tecan", + "name": "Freedom EVO", + "capabilities": [ + "liquid handling", + "arm" + ], + "status": "Basic", + "docs": "/user_guide/00_liquid-handling/tecan-evo/_tecan-evo.html", + "oem": "https://lifesciences.tecan.com/freedom-evo-platform" + }, + { + "vendor": "Opentrons", + "name": "OT-2", + "capabilities": [ + "liquid handling" + ], + "status": "Mostly", + "docs": "/user_guide/00_liquid-handling/opentrons-ot2/_opentrons-ot2.html", + "oem": "https://opentrons.com/products/ot-2-robot" + }, + { + "vendor": "Cole Parmer", + "name": "Masterflex L/S", + "capabilities": [ + "pumping" + ], + "status": "Full", + "docs": "/user_guide/00_liquid-handling/pumps/cole-parmer-masterflex.html", + "oem": "https://www.masterflex.nl/assets/uploads/2017/09/07551-xx.pdf" + }, + { + "vendor": "Agrowtek", + "name": "Pump Array", + "capabilities": [ + "pumping" + ], + "status": "Full", + "docs": null, + "oem": "https://www.agrowtek.com/index.php/products/dosing_systems/dosing-pumps/agrowdose-adi-digital-persitaltic-dosing-pumps-detail" + }, + { + "vendor": "Agilent (BioTek)", + "name": "EL406", + "capabilities": [ + "plate washing" + ], + "status": "Mostly", + "docs": "/user_guide/00_liquid-handling/plate-washing/biotek-el406.html", + "oem": "https://www.agilent.com/en/product/microplate-instrumentation/microplate-washers-dispensers/biotek-el406-washer-dispenser-795212" + }, + { + "vendor": "Brooks", + "name": "PreciseFlex PF400", + "capabilities": [ + "arm" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/arms/c_scara/precise-flex-pf400/_precise-flex-pf400.html", + "oem": "https://www.brooks.com/laboratory-automation/collaborative-robots/preciseflex-400/" + }, + { + "vendor": "Brooks", + "name": "PreciseFlex PF3400", + "capabilities": [ + "arm" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/arms/c_scara/precise-flex-pf400/_precise-flex-pf400.html", + "oem": "https://www.brooks.com/laboratory-automation/collaborative-robots/preciseflex-400/" + }, + { + "vendor": "PAA", + "name": "KX2", + "capabilities": [ + "arm" + ], + "status": "WIP", + "docs": null, + "oem": null + }, + { + "vendor": "Hamilton", + "name": "HEPA Fan", + "capabilities": [ + "air filtration" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/fans/fans.html", + "oem": null + }, + { + "vendor": "Inheco", + "name": "Thermoshake RM", + "capabilities": [ + "heating", + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/inheco.html", + "oem": "https://www.inheco.com/thermoshake-classic.html" + }, + { + "vendor": "Inheco", + "name": "Thermoshake", + "capabilities": [ + "heating", + "cooling", + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/inheco.html", + "oem": "https://www.inheco.com/thermoshake.html" + }, + { + "vendor": "Inheco", + "name": "Thermoshake AC", + "capabilities": [ + "heating", + "cooling", + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/inheco.html", + "oem": "https://www.inheco.com/thermoshake-ac.html" + }, + { + "vendor": "Opentrons", + "name": "Heater Shaker", + "capabilities": [ + "heating", + "shaking" + ], + "status": "Full", + "docs": null, + "oem": "https://opentrons.com/products/heater-shaker-module" + }, + { + "vendor": "Hamilton", + "name": "Heater Shaker", + "capabilities": [ + "heating", + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/hamilton.html", + "oem": "https://www.hamiltoncompany.com/temperature-control/hamilton-heater-shaker" + }, + { + "vendor": "QInstruments", + "name": "BioShake 3000", + "capabilities": [ + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/qinstruments.html", + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake 3000 elm", + "capabilities": [ + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/qinstruments.html", + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake 3000 elm DWP", + "capabilities": [ + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/qinstruments.html", + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake Q1", + "capabilities": [ + "heating", + "cooling", + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/qinstruments.html", + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake D30 elm", + "capabilities": [ + "shaking" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake 5000 elm", + "capabilities": [ + "shaking" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake 3000-T", + "capabilities": [ + "heating", + "shaking" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake 3000-T elm", + "capabilities": [ + "heating", + "shaking" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake D30-T elm", + "capabilities": [ + "heating", + "shaking" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake Q2", + "capabilities": [ + "heating", + "cooling", + "shaking" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "Heatplate", + "capabilities": [ + "heating" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "ColdPlate", + "capabilities": [ + "heating", + "cooling" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "Thermo Fisher", + "name": "Cytomat 6000", + "capabilities": [ + "heating", + "storage" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/incubators/cytomat.html", + "oem": "https://assets.thermofisher.com/TFS-Assets/CMD/brochures/br-90468-cytomat-2-c-lin-br90468-en.pdf" + }, + { + "vendor": "Thermo Fisher", + "name": "Cytomat 6002", + "capabilities": [ + "heating", + "storage" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/incubators/cytomat.html", + "oem": "https://www.thermofisher.com/order/catalog/product/50075279" + }, + { + "vendor": "Thermo Fisher", + "name": "Cytomat 2 C_50", + "capabilities": [ + "heating", + "storage" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/incubators/cytomat.html", + "oem": null + }, + { + "vendor": "Thermo Fisher", + "name": "Cytomat 2 C425", + "capabilities": [ + "heating", + "cooling", + "storage" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/incubators/cytomat.html", + "oem": "https://www.thermofisher.com/order/catalog/product/51033032" + }, + { + "vendor": "Thermo Fisher", + "name": "Cytomat 2 C450 Shake", + "capabilities": [ + "heating", + "shaking", + "storage" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/incubators/cytomat.html", + "oem": "https://www.thermofisher.com/order/catalog/product/51033035" + }, + { + "vendor": "Thermo Fisher", + "name": "Cytomat 5C", + "capabilities": [ + "heating", + "storage" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/incubators/cytomat.html", + "oem": "https://www.thermofisher.com/order/catalog/product/51031526" + }, + { + "vendor": "Thermo/Liconic", + "name": "Heraeus Cytomat", + "capabilities": [ + "heating", + "storage" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/incubators/cytomat.html", + "oem": null + }, + { + "vendor": "Inheco", + "name": "Incubator Shaker", + "capabilities": [ + "heating", + "shaking", + "storage" + ], + "status": "Mostly", + "docs": null, + "oem": "https://www.inheco.com/incubator-shaker.html" + }, + { + "vendor": "Inheco", + "name": "SCILA", + "capabilities": [ + "heating", + "shaking", + "storage" + ], + "status": "Mostly", + "docs": null, + "oem": "https://www.inheco.com/scila.html" + }, + { + "vendor": "Azenta", + "name": "XPeel", + "capabilities": [ + "peeling" + ], + "status": "Full", + "docs": null, + "oem": "https://www.azenta.com/products/automated-plate-seal-remover-formerly-xpeel" + }, + { + "vendor": "Azenta", + "name": "a4S", + "capabilities": [ + "sealing" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/sealers/a4s.html", + "oem": "https://www.azenta.com/products/automated-roll-heat-sealer-formerly-a4s" + }, + { + "vendor": "Opentrons", + "name": "Thermocycler", + "capabilities": [ + "heating", + "cooling" + ], + "status": "Full", + "docs": null, + "oem": "https://opentrons.com/products/thermocycler-module-1" + }, + { + "vendor": "Thermo Fisher", + "name": "ATC", + "capabilities": [ + "heating", + "cooling" + ], + "status": "Full", + "docs": null, + "oem": "https://www.thermofisher.com/us/en/home/life-science/pcr/thermal-cyclers-realtime-instruments/thermal-cyclers/automated-thermal-cycler-atc.html" + }, + { + "vendor": "Thermo Fisher", + "name": "ProFlex", + "capabilities": [ + "heating", + "cooling" + ], + "status": "Full", + "docs": null, + "oem": "https://www.thermofisher.com/us/en/home/life-science/pcr/thermal-cyclers-realtime-instruments/thermal-cyclers/proflex-pcr-system.html" + }, + { + "vendor": "Inheco", + "name": "ODTC", + "capabilities": [ + "heating", + "cooling" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.inheco.com/odtc.html" + }, + { + "vendor": "Opentrons", + "name": "Temperature Module", + "capabilities": [ + "heating", + "cooling" + ], + "status": "Mostly", + "docs": "/user_guide/01_material-handling/temperature.html", + "oem": "https://opentrons.com/products/temperature-module-gen2" + }, + { + "vendor": "Inheco", + "name": "CPAC", + "capabilities": [ + "heating", + "cooling" + ], + "status": "Full", + "docs": null, + "oem": "https://www.inheco.com/cpac.html" + }, + { + "vendor": "Hamilton", + "name": "Tilt Module", + "capabilities": [ + "tilting" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/tilting.html", + "oem": "https://www.hamiltoncompany.com/other-robotics/188061" + }, + { + "vendor": "Agilent", + "name": "VSpin", + "capabilities": [ + "centrifuging" + ], + "status": "Mostly", + "docs": "/user_guide/01_material-handling/centrifuge/agilent_vspin.html", + "oem": "https://www.agilent.com/en/product/automated-liquid-handling/automated-microplate-management/microplate-centrifuge" + }, + { + "vendor": "Agilent", + "name": "VSpin Access2 Loader", + "capabilities": [ + "centrifuging" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/centrifuge/agilent_vspin.html#loader", + "oem": "https://www.agilent.com/en/product/automated-liquid-handling/automated-microplate-management/microplate-centrifuge" + }, + { + "vendor": "BMG Labtech", + "name": "CLARIOstar Plus", + "capabilities": [ + "absorbance", + "fluorescence", + "luminescence" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/plate-reading/bmg-clariostar.html", + "oem": "https://www.bmglabtech.com/en/clariostar-plus/" + }, + { + "vendor": "Agilent (BioTek)", + "name": "Cytation 1", + "capabilities": [ + "microscopy" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/plate-reading/cytation.html", + "oem": "https://www.agilent.com/en/product/cell-analysis/cell-imaging-microscopy/cell-imaging-multimode-readers/biotek-cytation-1-cell-imaging-multimode-reader-1623200" + }, + { + "vendor": "Agilent (BioTek)", + "name": "Cytation 5", + "capabilities": [ + "absorbance", + "fluorescence", + "luminescence", + "microscopy" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/plate-reading/cytation.html", + "oem": "https://www.agilent.com/en/product/cell-analysis/cell-imaging-microscopy/cell-imaging-multimode-readers/biotek-cytation-5-cell-imaging-multimode-reader-1623202" + }, + { + "vendor": "Agilent (BioTek)", + "name": "Synergy H1", + "capabilities": [ + "absorbance", + "fluorescence", + "luminescence" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/plate-reading/synergyh1.html", + "oem": "https://www.agilent.com/en/product/microplate-instrumentation/microplate-readers/multimode-microplate-readers/biotek-synergy-h1-multimode-reader-1623193" + }, + { + "vendor": "Byonoy", + "name": "Absorbance 96 Automate", + "capabilities": [ + "absorbance" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/plate-reading/byonoy/absorbance.html", + "oem": "https://byonoy.com/absorbance-96-automate/" + }, + { + "vendor": "Byonoy", + "name": "Luminescence 96", + "capabilities": [ + "luminescence" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/plate-reading/byonoy/luminescence.html", + "oem": "https://byonoy.com/luminescence-96/" + }, + { + "vendor": "Byonoy", + "name": "Luminescence 96 Automate", + "capabilities": [ + "luminescence" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/plate-reading/byonoy/luminescence.html", + "oem": "https://byonoy.com/luminescence-96-automate/" + }, + { + "vendor": "Molecular Devices", + "name": "SpectraMax M5e", + "capabilities": [ + "absorbance", + "fluorescence" + ], + "status": "Full", + "docs": null, + "oem": "https://www.moleculardevices.com/products/microplate-readers/multi-mode-readers/spectramax-m-series-readers" + }, + { + "vendor": "Molecular Devices", + "name": "SpectraMax 384plus", + "capabilities": [ + "absorbance" + ], + "status": "Full", + "docs": null, + "oem": "https://www.moleculardevices.com/products/microplate-readers/absorbance-readers/spectramax-abs-plate-readers" + }, + { + "vendor": "Molecular Devices", + "name": "ImageXpress Pico", + "capabilities": [ + "microscopy" + ], + "status": "Basic", + "docs": "/user_guide/02_analytical/plate-reading/pico.html", + "oem": "https://www.moleculardevices.com/products/cellular-imaging-systems/high-content-imaging/imagexpress-pico" + }, + { + "vendor": "Tecan", + "name": "Infinite 200 PRO", + "capabilities": [ + "absorbance", + "fluorescence", + "luminescence" + ], + "status": "Mostly", + "docs": "/user_guide/tecan/infinite/hello-world.html", + "oem": "https://lifesciences.tecan.com/infinite-200-pro" + }, + { + "vendor": "Beckman Coulter", + "name": "CytoFLEX S", + "capabilities": [ + "flow cytometry" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.beckman.com/flow-cytometry/research-flow-cytometers/cytoflex-s" + }, + { + "vendor": "Thermo Fisher", + "name": "QuantStudio 5", + "capabilities": [ + "qPCR" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.thermofisher.com/order/catalog/product/A34322" + }, + { + "vendor": "Mettler Toledo", + "name": "WXS205SDU", + "capabilities": [ + "weighing" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/scales.html#mettler-toledo-wxs205sdu", + "oem": "https://www.mt.com/us/en/home/products/Industrial_Weighing_Solutions/high-precision-weigh-sensors/weigh-module-wxs205sdu-15-11121008.html" + } +] \ No newline at end of file diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst index 14c1a310f00..250097cde7a 100644 --- a/docs/_templates/autosummary/class.rst +++ b/docs/_templates/autosummary/class.rst @@ -1,5 +1,6 @@ :orphan: +{% set exclude_methods = ["from_bytes", "to_bytes"] %} {{ fullname | escape | underline }} .. currentmodule:: {{ module }} @@ -24,7 +25,7 @@ .. autosummary:: :toctree: . - {% for item in methods %} + {% for item in methods if item not in exclude_methods %} ~{{ objname }}.{{ item }} {%- endfor %} {% endif %} diff --git a/docs/api/pylabrobot.agilent.rst b/docs/api/pylabrobot.agilent.rst new file mode 100644 index 00000000000..59ace944201 --- /dev/null +++ b/docs/api/pylabrobot.agilent.rst @@ -0,0 +1,86 @@ +.. currentmodule:: pylabrobot.agilent + +pylabrobot.agilent package +========================== + +BioTek EL406 +------------ + +.. currentmodule:: pylabrobot.agilent.biotek.el406 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + EL406 + +.. currentmodule:: pylabrobot.agilent.biotek.el406.plate_washer + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PlateWasher + +.. currentmodule:: pylabrobot.agilent.biotek.el406.syringe_dispenser + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SyringeDispenser + +.. currentmodule:: pylabrobot.agilent.biotek.el406.peristaltic_dispenser + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PeristalticDispenser + + +BioTek Cytation +--------------- + +.. currentmodule:: pylabrobot.agilent.biotek.cytation + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Cytation1 + Cytation5 + CytationImagingConfig + + +BioTek Synergy H1 +------------------ + +.. currentmodule:: pylabrobot.agilent.biotek.synergy + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SynergyH1 + + +VSpin +----- + +.. currentmodule:: pylabrobot.agilent.vspin + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + VSpin + Access2 + Access2Driver diff --git a/docs/api/pylabrobot.azenta.rst b/docs/api/pylabrobot.azenta.rst new file mode 100644 index 00000000000..753e18ba923 --- /dev/null +++ b/docs/api/pylabrobot.azenta.rst @@ -0,0 +1,59 @@ +.. currentmodule:: pylabrobot.azenta + +pylabrobot.azenta package +========================= + +a4S Sealer +---------- + +.. currentmodule:: pylabrobot.azenta.a4s + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + A4S + A4SStatus + +.. autoclass:: pylabrobot.azenta.a4s.A4SStatus.SystemStatus + :members: + +.. autoclass:: pylabrobot.azenta.a4s.A4SStatus.HeaterBlockStatus + :members: + +.. autoclass:: pylabrobot.azenta.a4s.A4SStatus.SensorStatus + :members: + + +XPeel Peeler +------------ + +.. currentmodule:: pylabrobot.azenta.xpeel + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + XPeel + + +FluidX IntelliXcap 96 +--------------------- + +.. currentmodule:: pylabrobot.azenta.fluidx.intellixcap96 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + FluidXIntelliXcap96 + FluidXError + ExtendedStatus + CartridgeProfile + CartridgeInfo + FirmwareVersions + get_error_message + is_recoverable_error diff --git a/docs/api/pylabrobot.big_bear.rst b/docs/api/pylabrobot.big_bear.rst new file mode 100644 index 00000000000..adb6290553d --- /dev/null +++ b/docs/api/pylabrobot.big_bear.rst @@ -0,0 +1,14 @@ +.. currentmodule:: pylabrobot.big_bear + +pylabrobot.big_bear package +=========================== + +.. currentmodule:: pylabrobot.big_bear.orbital_shaker + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + BigBearOrbitalShaker + BigBearError diff --git a/docs/api/pylabrobot.brooks.rst b/docs/api/pylabrobot.brooks.rst new file mode 100644 index 00000000000..09a9679c491 --- /dev/null +++ b/docs/api/pylabrobot.brooks.rst @@ -0,0 +1,25 @@ +.. currentmodule:: pylabrobot.brooks + +pylabrobot.brooks package +========================= + +PreciseFlex +----------- + +.. currentmodule:: pylabrobot.brooks.precise_flex + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PreciseFlex + PreciseFlexConfiguration + PreciseFlexCartesianPose + WorkEnvelope + MotionProfile + Axis + Wrist + ElbowOrientation + PreciseFlexError + OutOfRangeOfMotionError diff --git a/docs/api/pylabrobot.byonoy.rst b/docs/api/pylabrobot.byonoy.rst new file mode 100644 index 00000000000..05bc72bc08a --- /dev/null +++ b/docs/api/pylabrobot.byonoy.rst @@ -0,0 +1,44 @@ +.. currentmodule:: pylabrobot.byonoy + +pylabrobot.byonoy package +========================= + +Absorbance 96 +------------- + +.. currentmodule:: pylabrobot.byonoy.absorbance_96 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + ByonoyAbsorbance96 + ByonoyAbsorbanceBaseUnit + AbsorbanceResult + byonoy_a96a + byonoy_a96a_detection_unit + byonoy_a96a_illumination_unit + byonoy_a96a_parking_unit + byonoy_sbs_adapter + +Luminescence 96 +--------------- + +.. currentmodule:: pylabrobot.byonoy.luminescence_96 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + ByonoyLuminescence96 + ByonoyLuminescenceBaseUnit + LuminescenceResult + Lum96IntegrationMode + byonoy_l96 + byonoy_l96_base_unit + byonoy_l96_reader_unit + byonoy_l96a + byonoy_l96a_base_unit + byonoy_l96a_reader_unit diff --git a/docs/api/pylabrobot.centrifuge.rst b/docs/api/pylabrobot.centrifuge.rst deleted file mode 100644 index f9916bea3eb..00000000000 --- a/docs/api/pylabrobot.centrifuge.rst +++ /dev/null @@ -1,67 +0,0 @@ -.. currentmodule:: pylabrobot.centrifuge - -pylabrobot.centrifuge package -================================ - -This package contains APIs for working with centrifuges. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - centrifuge.Centrifuge - centrifuge.Loader - - -Factory functions ------------------ - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - access2.Access2 - highres.microspin.MicroSpin - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - vspin_backend.VSpinBackend - 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.MicroSpinAbortedError - highres.microspin_backend.MicroSpinProtocolError - - -Testing utilities ------------------ - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - highres.mock_server.MicroSpinMockServer diff --git a/docs/api/pylabrobot.curiox.rst b/docs/api/pylabrobot.curiox.rst new file mode 100644 index 00000000000..f4b8314e1de --- /dev/null +++ b/docs/api/pylabrobot.curiox.rst @@ -0,0 +1,15 @@ +.. currentmodule:: pylabrobot.curiox + +pylabrobot.curiox package +========================= + +.. currentmodule:: pylabrobot.curiox.ht2000 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CurioxHT2000 + CurioxHT2000Error + StatusReport diff --git a/docs/api/pylabrobot.hamilton.rst b/docs/api/pylabrobot.hamilton.rst new file mode 100644 index 00000000000..45234392833 --- /dev/null +++ b/docs/api/pylabrobot.hamilton.rst @@ -0,0 +1,4 @@ +.. currentmodule:: pylabrobot.hamilton + +pylabrobot.hamilton package +=========================== diff --git a/docs/api/pylabrobot.heating_shaking.rst b/docs/api/pylabrobot.heating_shaking.rst deleted file mode 100644 index e5f7427f5d2..00000000000 --- a/docs/api/pylabrobot.heating_shaking.rst +++ /dev/null @@ -1,28 +0,0 @@ -.. currentmodule:: pylabrobot.heating_shaking - -pylabrobot.heating_shaking package -================================== - -This package contains APIs for working with heater shakers. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - heater_shaker.HeaterShaker - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - chatterbox.HeaterShakerChatterboxBackend - inheco.thermoshake_backend.InhecoThermoshakeBackend - inheco.thermoshake.inheco_thermoshake_ac - inheco.thermoshake.inheco_thermoshake - inheco.thermoshake.inheco_thermoshake_rm diff --git a/docs/api/pylabrobot.high_res.rst b/docs/api/pylabrobot.high_res.rst new file mode 100644 index 00000000000..9b66cf4eea1 --- /dev/null +++ b/docs/api/pylabrobot.high_res.rst @@ -0,0 +1,14 @@ +.. currentmodule:: pylabrobot.high_res + +pylabrobot.high_res package +=========================== + +.. currentmodule:: pylabrobot.high_res.lid_valet + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + HighResLidValet + HighResLidValetError diff --git a/docs/api/pylabrobot.inheco.rst b/docs/api/pylabrobot.inheco.rst new file mode 100644 index 00000000000..c149c30299e --- /dev/null +++ b/docs/api/pylabrobot.inheco.rst @@ -0,0 +1,61 @@ +.. currentmodule:: pylabrobot.inheco + +pylabrobot.inheco package +========================= + +TEC Control Box +--------------- + +.. currentmodule:: pylabrobot.inheco.control_box + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + InhecoTECControlBox + + +ThermoShake +----------- + +.. currentmodule:: pylabrobot.inheco.thermoshake + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + InhecoThermoShake + inheco_thermoshake + inheco_thermoshake_ac + inheco_thermoshake_rm + + +CPAC +---- + +.. currentmodule:: pylabrobot.inheco.cpac + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + InhecoCPAC + inheco_cpac_ultraflat + + +SCILA +----- + +.. currentmodule:: pylabrobot.inheco.scila.scila + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SCILA + SCILADrawerLoadingTray + DrawerStatus diff --git a/docs/api/pylabrobot.io.sila.rst b/docs/api/pylabrobot.io.sila.rst deleted file mode 100644 index d570e08cfdc..00000000000 --- a/docs/api/pylabrobot.io.sila.rst +++ /dev/null @@ -1,17 +0,0 @@ -.. currentmodule:: pylabrobot.io.sila - -pylabrobot.io.sila package -========================== - -This package provides utilities for working with `SiLA `_ instruments. - -Discovery ---------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - discovery.SiLADevice - discovery.discover diff --git a/docs/api/pylabrobot.kbioscience.rst b/docs/api/pylabrobot.kbioscience.rst new file mode 100644 index 00000000000..471452c591f --- /dev/null +++ b/docs/api/pylabrobot.kbioscience.rst @@ -0,0 +1,16 @@ +.. currentmodule:: pylabrobot.kbioscience + +pylabrobot.kbioscience package +============================== + +.. currentmodule:: pylabrobot.kbioscience.kube + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + KBioscienceKUBE + KUBEStatus + SealPreset + KBioscienceError diff --git a/docs/api/pylabrobot.kbiosystems.rst b/docs/api/pylabrobot.kbiosystems.rst new file mode 100644 index 00000000000..1a7da0b36c5 --- /dev/null +++ b/docs/api/pylabrobot.kbiosystems.rst @@ -0,0 +1,44 @@ +.. currentmodule:: pylabrobot.kbiosystems + +pylabrobot.kbiosystems package +============================== + +.. currentmodule:: pylabrobot.kbiosystems.sealer + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + KBiosystemsSealer + KBiosystemsError + +.. currentmodule:: pylabrobot.kbiosystems.ultraseal_epro + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + KBiosystemsUltrasealEPRO + UltrasealEPROStatus + +.. currentmodule:: pylabrobot.kbiosystems.ultraseal_pro + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + KBiosystemsUltrasealPRO + UltrasealPROStatus + +.. currentmodule:: pylabrobot.kbiosystems.ultraseal_xt_pro + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + KBiosystemsUltrasealXTPro + UltrasealXTProStatus diff --git a/docs/api/pylabrobot.liquid_handling.backends.rst b/docs/api/pylabrobot.liquid_handling.backends.rst deleted file mode 100644 index 0ac11abd163..00000000000 --- a/docs/api/pylabrobot.liquid_handling.backends.rst +++ /dev/null @@ -1,41 +0,0 @@ -.. currentmodule:: pylabrobot.liquid_handling - -pylabrobot.liquid_handling.backends package -=========================================== - -Backends are used to communicate with liquid handling devices on a low level. Using them directly can be useful when you want to have very low level control over the liquid handling device or want to use a feature that is not yet implemented in the front end. - -Abstract --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - backends.backend.LiquidHandlerBackend - backends.serializing_backend.SerializingBackend - -Hardware --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - backends.hamilton.base.HamiltonLiquidHandler - backends.hamilton.STAR_backend.STAR - backends.hamilton.vantage_backend.Vantage - backends.opentrons_backend.OpentronsOT2Backend - backends.tecan.EVO_backend.EVOBackend - -Testing -------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - backends.chatterbox.LiquidHandlerChatterboxBackend diff --git a/docs/api/pylabrobot.liquid_handling.rst b/docs/api/pylabrobot.liquid_handling.rst deleted file mode 100644 index 75b75b2b02c..00000000000 --- a/docs/api/pylabrobot.liquid_handling.rst +++ /dev/null @@ -1,52 +0,0 @@ -.. currentmodule:: pylabrobot.liquid_handling - -pylabrobot.liquid_handling package -================================== - -This package contains all APIs relevant to liquid handling. -.. See :doc:`Basic liquid handling ` for a simple example. - -Machine control is split into two parts: backends and front ends. Backends are used to control the -machine, and front ends are used to interact with the backend. Front ends are designed to be -largely backend agnostic, and can be used with any backend, meaning programs using this API can -be run on practically all supported hardware. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - liquid_handler.LiquidHandler - - -Backends --------- - -.. toctree:: - :maxdepth: 3 - - pylabrobot.liquid_handling.backends - - -Operations ----------- - -Operations are the main data holders used to transmit information from the liquid handler to a backend. They are the basis of "standard form". - - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - standard - - -Strictness ----------- - -.. toctree:: - :maxdepth: 1 - :caption: Strictness - - pylabrobot.liquid_handling.strictness diff --git a/docs/api/pylabrobot.liquid_handling.strictness.rst b/docs/api/pylabrobot.liquid_handling.strictness.rst deleted file mode 100644 index e454679b093..00000000000 --- a/docs/api/pylabrobot.liquid_handling.strictness.rst +++ /dev/null @@ -1,12 +0,0 @@ -pylabrobot.liquid_handling.strictness package -============================================= - -This package handles the strictness of robot specific functionality being used in an agnostic setting. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.liquid_handling.strictness.Strictness - pylabrobot.liquid_handling.strictness.set_strictness diff --git a/docs/api/pylabrobot.machine.rst b/docs/api/pylabrobot.machine.rst deleted file mode 100644 index fe0d3895da3..00000000000 --- a/docs/api/pylabrobot.machine.rst +++ /dev/null @@ -1,11 +0,0 @@ -.. currentmodule:: pylabrobot.machines - -Machine is a backend'd Resource. Check out the contributing section for more information. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - machine.Machine - backend.MachineBackend diff --git a/docs/api/pylabrobot.mettler_toledo.rst b/docs/api/pylabrobot.mettler_toledo.rst new file mode 100644 index 00000000000..81c19d837a0 --- /dev/null +++ b/docs/api/pylabrobot.mettler_toledo.rst @@ -0,0 +1,15 @@ +.. currentmodule:: pylabrobot.mettler_toledo + +pylabrobot.mettler_toledo package +================================= + +.. currentmodule:: pylabrobot.mettler_toledo.mettler_toledo + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MettlerToledoWXS205SDU + MettlerToledoResponse + MettlerToledoError diff --git a/docs/api/pylabrobot.molecular_devices.rst b/docs/api/pylabrobot.molecular_devices.rst new file mode 100644 index 00000000000..cfcfe7d7fc7 --- /dev/null +++ b/docs/api/pylabrobot.molecular_devices.rst @@ -0,0 +1,59 @@ +.. currentmodule:: pylabrobot.molecular_devices + +pylabrobot.molecular\_devices package +===================================== + +SpectraMax +---------- + +.. currentmodule:: pylabrobot.molecular_devices.spectramax + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SpectraMaxM5 + SpectraMax384Plus + MolecularDevicesPlateReader + MolecularDevicesSettings + AbsorbanceResult + FluorescenceResult + LuminescenceResult + ReadMode + ReadType + ReadOrder + Calibrate + CarriageSpeed + PmtGain + ShakeSettings + KineticSettings + SpectrumSettings + + +ImageXpress Pico +---------------- + +.. currentmodule:: pylabrobot.molecular_devices.imageXpress.pico + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Pico + +.. currentmodule:: pylabrobot.molecular_devices.imageXpress.pico.pico + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + ImagingMode + ImagingResult + Objective + Exposure + Gain + FocalPosition + WellBottomType diff --git a/docs/api/pylabrobot.only_fans.rst b/docs/api/pylabrobot.only_fans.rst deleted file mode 100644 index 177482e48b5..00000000000 --- a/docs/api/pylabrobot.only_fans.rst +++ /dev/null @@ -1,26 +0,0 @@ -.. currentmodule:: pylabrobot.only_fans - -pylabrobot.only_fans package -================================ - -This package contains APIs just for fans. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - fan.Fan - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - backend.FanBackend - chatterbox.FanChatterboxBackend - hamilton_hepa_fan_backend.HamiltonHepaFanBackend diff --git a/docs/api/pylabrobot.plate_reading.rst b/docs/api/pylabrobot.plate_reading.rst deleted file mode 100644 index cc918e4f8f5..00000000000 --- a/docs/api/pylabrobot.plate_reading.rst +++ /dev/null @@ -1,30 +0,0 @@ -.. currentmodule:: pylabrobot.plate_reading - -pylabrobot.plate_reading package -================================ - -This package contains APIs for working with plate readers. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - plate_reader.PlateReader - imager.Imager - standard.ImagingResult - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - chatterbox.PlateReaderChatterboxBackend - bmg_labtech.clario_star_backend.CLARIOstarBackend - agilent.biotek_cytation_backend.CytationBackend - agilent.biotek_synergyh1_backend.SynergyH1Backend - molecular_devices.spectramax_gemini_em_backend.MolecularDevicesSpectraMaxGeminiEMBackend diff --git a/docs/api/pylabrobot.pumps.rst b/docs/api/pylabrobot.pumps.rst deleted file mode 100644 index f59194b3014..00000000000 --- a/docs/api/pylabrobot.pumps.rst +++ /dev/null @@ -1,25 +0,0 @@ -.. currentmodule:: pylabrobot.pumps - -pylabrobot.pumps package -======================== - -This package contains APIs for working with pumps. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pump.Pump - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - chatterbox.PumpChatterboxBackend - cole_parmer.masterflex_backend.MasterflexBackend diff --git a/docs/api/pylabrobot.qinstruments.rst b/docs/api/pylabrobot.qinstruments.rst new file mode 100644 index 00000000000..45f08374f3f --- /dev/null +++ b/docs/api/pylabrobot.qinstruments.rst @@ -0,0 +1,25 @@ +.. currentmodule:: pylabrobot.qinstruments + +pylabrobot.qinstruments package +=============================== + +.. currentmodule:: pylabrobot.qinstruments.bioshake + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + BioShake + BioShake3000 + BioShake3000Elm + BioShake3000ElmDWP + BioShakeD30Elm + BioShake5000Elm + BioShake3000T + BioShake3000TElm + BioShakeD30TElm + BioShakeQ1 + BioShakeQ2 + Heatplate + ColdPlate diff --git a/docs/api/pylabrobot.resources.rst b/docs/api/pylabrobot.resources.rst index 5cbc3ceee2e..ccd5d0a3ee0 100644 --- a/docs/api/pylabrobot.resources.rst +++ b/docs/api/pylabrobot.resources.rst @@ -10,6 +10,7 @@ Resources represent on-deck liquid handling equipment, including tip racks, plat :nosignatures: :recursive: + barcode.Barcode Carrier Container Coordinate @@ -29,6 +30,8 @@ Resources represent on-deck liquid handling equipment, including tip racks, plat tip.Tip TipCarrier TipRack + tip_rack.TipSpot + Trash Trough Tube TubeCarrier diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index 051a61e8bd1..8e190c699fa 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -10,18 +10,28 @@ Subpackages :maxdepth: 1 pylabrobot.config - pylabrobot.centrifuge - pylabrobot.machine - pylabrobot.heating_shaking - pylabrobot.liquid_handling - pylabrobot.plate_reading - pylabrobot.pumps - pylabrobot.only_fans pylabrobot.resources - pylabrobot.scales - pylabrobot.io.sila - pylabrobot.shaking - pylabrobot.temperature_controlling - pylabrobot.thermocycling - pylabrobot.tilting pylabrobot.utils + +Manufacturers +------------- + +.. toctree:: + :maxdepth: 1 + + pylabrobot.agilent + pylabrobot.azenta + pylabrobot.big_bear + pylabrobot.brooks + pylabrobot.byonoy + pylabrobot.curiox + pylabrobot.hamilton + pylabrobot.high_res + pylabrobot.inheco + pylabrobot.kbioscience + pylabrobot.kbiosystems + pylabrobot.mettler_toledo + pylabrobot.molecular_devices + pylabrobot.qinstruments + pylabrobot.sartorius + pylabrobot.thermo_fisher diff --git a/docs/api/pylabrobot.sartorius.rst b/docs/api/pylabrobot.sartorius.rst new file mode 100644 index 00000000000..7bd4390ca70 --- /dev/null +++ b/docs/api/pylabrobot.sartorius.rst @@ -0,0 +1,14 @@ +.. currentmodule:: pylabrobot.sartorius + +pylabrobot.sartorius package +============================ + +.. currentmodule:: pylabrobot.sartorius.entris + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SartoriusEntris2 + SartoriusError diff --git a/docs/api/pylabrobot.scales.rst b/docs/api/pylabrobot.scales.rst deleted file mode 100644 index 2b8346e56ee..00000000000 --- a/docs/api/pylabrobot.scales.rst +++ /dev/null @@ -1,25 +0,0 @@ -.. currentmodule:: pylabrobot.scales - -pylabrobot.scales package -========================= - -This package contains APIs for working with scales. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - scale.Scale - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - chatterbox.ScaleChatterboxBackend - mettler_toledo_backend.MettlerToledoWXS205SDU diff --git a/docs/api/pylabrobot.shaking.rst b/docs/api/pylabrobot.shaking.rst deleted file mode 100644 index 66f4570ad1a..00000000000 --- a/docs/api/pylabrobot.shaking.rst +++ /dev/null @@ -1,25 +0,0 @@ -.. currentmodule:: pylabrobot.shaking - -pylabrobot.shaking package -========================== - -This package contains APIs for working with shakers. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - shaker.Shaker - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - backend.ShakerBackend - chatterbox.ShakerChatterboxBackend diff --git a/docs/api/pylabrobot.temperature_controlling.rst b/docs/api/pylabrobot.temperature_controlling.rst deleted file mode 100644 index 57879284985..00000000000 --- a/docs/api/pylabrobot.temperature_controlling.rst +++ /dev/null @@ -1,29 +0,0 @@ -.. currentmodule:: pylabrobot.temperature_controlling - -pylabrobot.temperature_controlling package -========================================== - -This package contains APIs for working with temperature controllers (heaters and coolers). - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - temperature_controller.TemperatureController - opentrons.OpentronsTemperatureModuleV2 - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - chatterbox.TemperatureControllerChatterboxBackend - opentrons_backend.OpentronsTemperatureModuleBackend - inheco.control_box.InhecoTECControlBox - inheco.cpac_backend.InhecoCPACBackend - inheco.cpac.inheco_cpac_ultraflat diff --git a/docs/api/pylabrobot.thermo_fisher.rst b/docs/api/pylabrobot.thermo_fisher.rst new file mode 100644 index 00000000000..27c3d5383c3 --- /dev/null +++ b/docs/api/pylabrobot.thermo_fisher.rst @@ -0,0 +1,48 @@ +.. currentmodule:: pylabrobot.thermo_fisher + +pylabrobot.thermo_fisher package +================================ + +ALPS Heat Sealers +----------------- + +.. currentmodule:: pylabrobot.thermo_fisher.alps.sealer + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + ThermoScientificALPSSealer + ThermoScientificALPSError + +.. currentmodule:: pylabrobot.thermo_fisher.alps.alps300 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + ThermoScientificALPS300 + ALPS300Status + +.. currentmodule:: pylabrobot.thermo_fisher.alps.alps3000 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + ThermoScientificALPS3000 + ALPS3000Status + +.. currentmodule:: pylabrobot.thermo_fisher.alps.alps5000 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + ThermoScientificALPS5000 + ALPS5000Status + diff --git a/docs/api/pylabrobot.thermocycling.rst b/docs/api/pylabrobot.thermocycling.rst deleted file mode 100644 index d89929abec7..00000000000 --- a/docs/api/pylabrobot.thermocycling.rst +++ /dev/null @@ -1,28 +0,0 @@ -.. currentmodule:: pylabrobot.thermocycling - -pylabrobot.thermocycling package -================================ - -This package contains APIs for working with thermocyclers. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - thermocycler.Thermocycler - opentrons.OpentronsThermocyclerModuleV1 - opentrons.OpentronsThermocyclerModuleV2 - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - backend.ThermocyclerBackend - chatterbox.ThermocyclerChatterboxBackend - opentrons_backend.OpentronsThermocyclerBackend diff --git a/docs/api/pylabrobot.tilting.rst b/docs/api/pylabrobot.tilting.rst deleted file mode 100644 index 564601d2765..00000000000 --- a/docs/api/pylabrobot.tilting.rst +++ /dev/null @@ -1,26 +0,0 @@ -.. currentmodule:: pylabrobot.tilting - -pylabrobot.tilting package -========================== - -This package contains APIs for working with tilt modules. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - tilter.Tilter - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - chatterbox.TilterChatterboxBackend - tilter_backend.TilterBackend - hamilton_backend.HamiltonTiltModuleBackend diff --git a/docs/conf.py b/docs/conf.py index a6e9359e41b..000239139e3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,6 +47,7 @@ "IPython.sphinxext.ipython_console_highlighting", "sphinx_reredirects", "sphinx_sitemap", + "plr_devices", ] intersphinx_mapping = { @@ -72,7 +73,9 @@ # 'undoc-members': False, "show-inheritance": True, # 'special-members': '__init__,__getitem__', - "exclude-members": "__weakref__", + # from_bytes/to_bytes are inherited from int by the IntEnum/IntFlag status + # types; CPython's docstring for them is not valid reStructuredText. + "exclude-members": "__weakref__,from_bytes,to_bytes", } default_role = "code" # allow single backticks for inline code @@ -176,8 +179,8 @@ "installation.html": "user_guide/installation.html", "contributing.html": "contributor_guide/index.html", "configuration.html": "user_guide/configuration.html", - "new-machine-type.html": "contributor_guide/new_machine_type.html", - "new-concrete-backend.html": "contributor_guide/new_concrete_backend.html", + "new-machine-type.html": "contributor_guide/device-driver-guide.html", + "new-concrete-backend.html": "contributor_guide/device-driver-guide.html", "how-to-open-source.html": "contributor_guide/how_to_open_source.html", "basic.html": "user_guide/basic.html", "using-the-visualizer.html": "user_guide/using_the_visualizer.html", diff --git a/docs/contributor_guide/contributing.md b/docs/contributor_guide/contributing.md index 270a33d2128..8fb4ead41ad 100644 --- a/docs/contributor_guide/contributing.md +++ b/docs/contributor_guide/contributing.md @@ -4,7 +4,7 @@ Thank you for your interest in contributing to PyLabRobot! This document will he ## Getting Started -See the installation instructions [here](../user_guide/_getting-started/installation.md). For contributing, you should install PyLabRobot from source. +See the installation instructions [here](../user_guide/getting-started/installation.md). For contributing, you should install PyLabRobot from source. If this is your first time contributing to open source, check out [How to Open Source](how-to-open-source.md) for an easy introduction. diff --git a/docs/contributor_guide/device-driver-guide.md b/docs/contributor_guide/device-driver-guide.md new file mode 100644 index 00000000000..5905d9346d8 --- /dev/null +++ b/docs/contributor_guide/device-driver-guide.md @@ -0,0 +1,58 @@ +# Writing PyLabRobot Device Drivers & Hello-World Guides + +How to add a device driver and its hello-world notebook, for humans and agents. Post on the [PyLabRobot forum](https://discuss.pylabrobot.org) before starting to avoid duplicated effort and get support. + +## 1. Understand the device + +Recover the protocol before writing code. + +- **Extract, don't guess.** Work from an authoritative source — firmware/protocol docs, manufacturer log files, or a reference binary. Capture the *complete* set of command frames, error codes, status values, and exact message text, filled with real values, not placeholders. +- **Get the wire format byte-exact:** transport (serial params / USB endpoint / socket), framing (delimiters, length fields, checksums), handshake (echo? ack? busy→ok?). +- **Note blocking vs non-blocking commands** and how faults (e-stop, jam) surface. + +## 2. Structure the driver + +Keep it small and idiomatic to PyLabRobot. + +- **One file, one plain class.** The old Driver/Backend split and capability machinery are deprecated — don't use them. Instead write a single plain class whose public methods are the device's operations, talking to hardware through a `pylabrobot.io` transport. Path `pylabrobot//.py`, re-exported from `__init__.py`. Promote to a `/` package only when it genuinely helps — a distinct subsystem, the protocol/framing layer, or a large command table — not by reflex. +- **Model the device's real objects.** When the hardware has distinct addressable parts — a shaker's daisy-chained nests, a gripper's arm, a multi-channel head — give them their own classes or objects so `nest[2].shake(...)` reads like the machine works. Let the physical layout guide the object model where it makes sense; don't invent a class hierarchy the device doesn't have. A one-part device stays one class. +- **Keep the logic client-side, in PLR.** Do as much control as possible from PLR rather than delegating to the device's firmware. When the hardware exposes both a canned high-level feature and the lower-level primitives it's built from, prefer driving the primitives so the sequencing, state, and decisions live in readable Python you can inspect and adapt — not in an opaque on-device routine. Reach for a firmware macro only when the primitives genuinely aren't exposed or the timing must be enforced on-device. +- **Stay OS-agnostic:** no OS-specific libraries or DLLs. Running on Windows, Mac, and Linux is what keeps experiments portable and reproducible. +- **Async `setup()` / `stop()`** plus public operation methods, over PyLabRobot's transport primitives (`pylabrobot.io.serial.Serial`, etc.) — never the OS directly. +- **Prefer string `Literal[...]` over enums**, especially anything user-facing. A `Literal["standard", "head", "pump"]` argument plus an internal dict mapping to wire codes reads better at the call site than an enum import. `IntEnum` is fine in narrow internal cases (e.g. a fixed set of wire/register codes never exposed to callers). +- **API docs:** add `docs/api/pylabrobot..rst` plus a line in `docs/api/pylabrobot.rst`. + +### Idempotent public API + +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. + +### 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. + +## 3. Code style + +- **Comments document what the code does, not its history.** No "NEW", "now", "previously", no emphasis-caps — the code is not a diary. State behavior as fact; plain rationale ("why") is welcome. +- **No provenance stories.** Describe what the thing *is*, not where it came from — in code, commits, or PRs. +- **No dead code**, and **no one-time scripts in the repo.** The codebase holds permanent software only; run backfills/migrations ad-hoc as thin inline invocations of the module's own functions. + +## 4. Hello-world notebook + +Every device ships a runnable notebook that takes a user from cabling to first command. Path `docs/user_guide///hello-world.ipynb`; wire it in via the `/index.md` `{toctree}` and add `/index` to the Manufacturers `{toctree}` in `docs/user_guide/index.md` (alphabetical). + +Sections (markdown cell then code cell): (1) title + property table + untested-warning; (2) how it talks — brief, since users care about the machine, not the wire; (3) physical setup; (4) `setup()`; (5) one section per operation. + +Cell rules: +- **One concept per code cell.** If a physical action happens between steps, that's two cells: `move_tray_out()` → place plate → `move_tray_in()`. Every code cell gets a preceding markdown cell. +- **Notebook JSON:** edit with a notebook-aware tool (plain-text replace is blocked on `.ipynb`); code cells `execution_count: null`, empty `outputs`; `nbformat: 4`, `nbformat_minor: 5`; every cell has an `id`; validate it parses. + +## 5. Verify & ship + +- **Lint + type-check:** ruff + mypy, 2-space indent. +- **Show commit + PR text and get confirmation before committing.** +- **Never drive real hardware without explicit per-run approval** — a previous OK doesn't carry to the next run. + +## 6. Working style + +- **Do exactly what's asked, nothing more** — no drive-by edits to comments/constants/logic, no side-quests. If a fix seems to need more, state the minimal fact and let the person decide. +- **Run at full speed;** parallelize independent work. diff --git a/docs/contributor_guide/index.md b/docs/contributor_guide/index.md index 06292a05ccd..4f3f8feb1fb 100644 --- a/docs/contributor_guide/index.md +++ b/docs/contributor_guide/index.md @@ -14,8 +14,7 @@ contributing-to-docs :maxdepth: 2 :caption: Adding Backends/Drivers -new-machine-type -new-concrete-backend +device-driver-guide ```
diff --git a/docs/contributor_guide/new-concrete-backend.md b/docs/contributor_guide/new-concrete-backend.md deleted file mode 100644 index 149d755bed1..00000000000 --- a/docs/contributor_guide/new-concrete-backend.md +++ /dev/null @@ -1,42 +0,0 @@ -# Adding Support for a New Machine of an Existing Type - -This guide explains how to add support for a new machine of an existing type. For example, if you want to add support for a new liquid handler, you should read this guide. If you want to add support for a new type of machine, you should read {doc}`this guide ` first. - -The machine types that are currently can be found [here](/user_guide/machines). - -Two documents that you can read before you start are: - -- [Contributing to PyLabRobot](contributing.md): This document contains general information about contributing to PyLabRobot, and covers things like installation and testing. -- [How to Open Source](how-to-open-source.md): This document contains step-by-step instructions for contributing to an open source project. It is not specific to PyLabRobot, and serves as a reference. - -Thank you for contributing to PyLabRobot! - -## Background - -Backends are minimal classes that are responsible for communicating with a machine and are thus specific to one machine. Frontends are higher level classes that are responsible for orchestrating higher-level state and providing nice interfaces to users, and should work with any machine. For example, the STAR liquid handler backend is responsible for executing the liquid handling operations on a Hamilton STAR, while the LiquidHandler frontend is responsible for making sure a requested operation is valid given the current state of the deck. - -Backends should contain minimal state. We prefer to manage the state in the frontend, because this allows us to share the code across all machines of a type. For example, the liquid handler backend does not contain any information about the deck, because this is managed by the frontend. If a certain machine has a specific state that needs to be managed, like whether the gripper arm is parked on a liquid handling robot, that should be done by the backend because it is specific to the machine. - -## 0. Get in touch - -Please make a post on [the PyLabRobot Development forum](https://discuss.pylabrobot.org) to let us know what you are working on. This will help you avoid duplicating work, and it is also a good place to get support. - -## 1. Creating a new concrete backend class - -It is easiest to start by copying the abstract base class for the machine type to a new file. You will find this in `backend.py` in the module for the machine type. For example, the liquid handling abstract base class is located at `pylabrobot.liquid_handling.backends.backend`. You should copy this file to a new file called `.py` in the same directory. For example, the liquid handling backend for the Hamilton STAR is located at `pylabrobot.liquid_handling.backends.hamilton.STAR`. - -## 2. Implementing the abstract methods - -The abstract base class contains a number of abstract methods. These are the methods that are expected to be implemented by the concrete backend. You should implement these methods in the concrete backend. You can use the abstract base class as a reference for what the methods should do. - -PyLabRobot aims to be OS-agnostic, meaning that it should work on Windows, Mac, and Linux. This maximizes flexibility for users and the reproducibility of experiments. However, this also means that you should not use any OS-specific libraries or dlls in the backend. - -If an operation is not supported by the machine, you should raise a `NotImplementedError`. - -The actual process of implementing the methods varies widely from machine to machine. It is generally useful to search for firmware documents, search for log files generated by a manufacturer's software, or find other open source projects that have implemented the same machine. - -## 3. Adding documentation (recommended) - -Find the relevant module in the `docs` directory. For example, the liquid handling backends module is located at `docs/pylabrobot.liquid_handling.backends.rst`. Then, add the name of the new backend to make sure that the new backend is automatically documented in the API reference. - -If you want, you can also add a new page to the `docs` directory that explains how to use the new backend. This is not required, but it is strongly recommended. Experience has shown that this is the best way to get people to actually use the new backend. diff --git a/docs/contributor_guide/new-machine-type.md b/docs/contributor_guide/new-machine-type.md deleted file mode 100644 index 676419bee63..00000000000 --- a/docs/contributor_guide/new-machine-type.md +++ /dev/null @@ -1,68 +0,0 @@ -# Contributing a New Type of Machine to PLR - -PyLabRobot supports a number of different types of machines. They can be found [here](/user_guide/machines). - -If you want to add support for a new type of machine, this guide will explain the process. If you want to add a new machine for a type that already exists, you should read {doc}`this guide ` instead. - -This guide is not a definitive step-by-step guide (otherwise we would have automated it), but rather a collection of high-level ideas and suggestions. Often, it only becomes clear what the best abstractions are after two or more machines for a type have been implemented, so it is totally valid (and encouraged) to make some assumptions and then refactor later. - -Two documents that you can read before you start are: - -- [Contributing to PyLabRobot](contributing.md): This document contains general information about contributing to PyLabRobot, and covers things like installation and testing. -- [How to Open Source](how-to-open-source.md): This document contains step-by-step instructions for contributing to an open source project. It is not specific to PyLabRobot, and serves as a reference. - -Thank you for contributing to PyLabRobot! - -## 0. Get in touch - -Please make a post on [the PyLabRobot Development forum](https://discuss.pylabrobot.org) to let us know what you are working on. This will help you avoid duplicating work, and it is also a good place to get support. - -## 1. Creating a new module - -Each machine type has its own module in PLR. For example, the liquid handling module is located at `pylabrobot.liquid_handling`. This module contains: - -- the machine front end: the user-facing API for the machine type. Example: `LiquidHandler`. -- the abstract base class for the machine type: the minimal set of atomic commands that the machine type is expected to support. Example: `LiquidHandlerBackend`. -- the concrete backends: the actual implementations of the abstract base class for specific machines. See {doc}`the concrete backends guide ` for more information. Example: `STAR`. - -## 2. Creating a new abstract backend class - -Abstract backends are used to define the interface for a type of machine in terms of the minimal set of atomic commands. For example, all liquid handlers should have an `aspirate` method. - -The commands should be interactive and minimal. Interactive means that the command is expected to be executed immediately when its method is called. Minimal means the commands cannot be broken into sub-commands that a user would reasonably want to use. For example, the abstract liquid handler backend contains commands for `aspirate` and `dispense`, but not `transfer` (a convenience method that exists on the frontend). At the same time, `aspirate` does move the pipetting head to a certain location because this move and the actual aspiration are reasonably expected to always occur together. For new machines, it is fine to make some assumptions and revisit them later. - -The purpose of minimality is to make adding new concrete backends as easy as possible. The purpose of interactivity is to make the iteration cycle when developing new methods as short as possible. - -You must put the abstract base class in `backend.py` in the module you created in step 1. The abstract class {class}`~pylabrobot.machine.MachineBackend` must be used as the base class for all backends. This class defines the `setup` and `stop` methods, which are used to initialize and stop the machine. - -## 3. Creating a new front end class - -Front ends are used to define the user-facing interface for a specific machine type, and shared across all machines of this type in PLR. They expose the atomic backend commands in addition to providing higher level utilities and orchestrating state. For example, `LiquidHandler` has a `transfer` method that is not defined in the abstract backend (it is not minimal), but instead simply calls `aspirate` and `dispense` on the backend. This way, the `transfer` implementation is shared across all supported liquid handling robots. `LiquidHandler` also maintains a reference to the deck, to make sure the requested operations are valid given the current state of the deck. - -The abstract class {class}`~pylabrobot.machine.MachineFrontend` must be used as the base class for all front ends. This class defines the `setup` and `stop` methods, which are used to initialize and stop the machine. It also defines the `backend` attribute, which is used to access the backend. - -You should put the front end in a file called `.py` in the module you created in step 1. For example, the liquid handling front end is located at `pylabrobot.liquid_handling.liquid_handler.py`. - -If your devices updates the resource tree or its state, the front end should handle this. See [the resources guide](/resources/introduction.md) for more information. - -## 4. Creating a new concrete backend for a specific machine - -Refer to the {doc}`the concrete backends guide `. - -## 5. Adding documentation (strongly recommended) - -Each module should have a corresponding documentation page in the `docs` directory. Experience has shown that this is the best way to get people to actually use the new module. - -### API documentation - -API documentation is generated automatically based on docstrings, but has to be linked to from the main API documentation. - -1. Create a new file in the `docs` directory called `pylabrobot..rst`. You can look at the existing files for examples. -2. Add a link to this file to the API documentation in [`docs/api/pylabrobot.rst`](https://github.com/PyLabRobot/pylabrobot/blob/main/docs/api/pylabrobot.rst). - -### Brief introduction - -It is also recommended to add a brief introduction to the module which explains what it is and how to use it. You can write this introduction in Markdown, reStructuredText, or a Jupyter notebook (recommended). You can look at [`basic.ipynb`](https://github.com/PyLabRobot/pylabrobot/blob/main/docs/user_guide/00_liquid-handling/hamilton-star/basic.ipynb) for an example. - -1. Put the introduction in the `docs` folder. -2. Link to the new file from [`docs/index.md`](https://github.com/PyLabRobot/pylabrobot/blob/main/docs/index.md). diff --git a/docs/contributor_guide/visualizer.md b/docs/contributor_guide/visualizer.md index 1735844d0ea..8319bef54aa 100644 --- a/docs/contributor_guide/visualizer.md +++ b/docs/contributor_guide/visualizer.md @@ -59,7 +59,7 @@ Useful entry points are the examples in the user guide or the unit tests in Because communication happens over websockets, you can also drive the visualizer from unit tests or scripts without a physical robot. The -{class}`~pylabrobot.liquid_handling.backends.chatterbox.ChatterboxBackend` works +{class}`~pylabrobot.legacy.liquid_handling.backends.chatterbox.ChatterboxBackend` works well for this purpose. ## Contributing tips diff --git a/docs/index.md b/docs/index.md index 9bf6a74cef1..d376abafdf0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -235,19 +235,3 @@ Wierenga, R., Golas, S., Ho, W., Coley, C., & Esvelt, K. (2023). PyLabRobot: An ``` [Cited by](https://scholar.google.com/scholar?cites=4498189371108132583): - -- Tom, Gary, et al. "Self-driving laboratories for chemistry and materials science." Chemical Reviews (2024). -- Anhel, Ana-Mariya, Lorea Alejaldre, and Ángel Goñi-Moreno. "The Laboratory Automation Protocol (LAP) Format and Repository: a platform for enhancing workflow efficiency in synthetic biology." ACS synthetic biology 12.12 (2023): 3514-3520. -- Bultelle, Matthieu, Alexis Casas, and Richard Kitney. "Engineering biology and automation–Replicability as a design principle." Engineering Biology (2024). -- Pleiss, Jürgen. "FAIR Data and Software: Improving Efficiency and Quality of Biocatalytic Science." ACS Catalysis 14.4 (2024): 2709-2718. -- Gopal, Anjali, et al. "Will releasing the weights of large language models grant widespread access to pandemic agents?." arXiv preprint arXiv:2310.18233 (2023). -- Padhy, Shakti P., and Sergei V. Kalinin. "Domain hyper-languages bring robots together and enable the machine learning community." Device 1.4 (2023). -- Beaucage, Peter A., Duncan R. Sutherland, and Tyler B. Martin. "Automation and Machine Learning for Accelerated Polymer Characterization and Development: Past, Potential, and a Path Forward." Macromolecules (2024). -- Bultelle, Matthieu, Alexis Casas, and Richard Kitney. "Construction of a Calibration Curve for Lycopene on a Liquid-Handling Platform─ Wider Lessons for the Development of Automated Dilution Protocols." ACS Synthetic Biology (2024). -- Hysmith, Holland, et al. "The future of self-driving laboratories: from human in the loop interactive AI to gamification." Digital Discovery 3.4 (2024): 621-636. -- Casas, Alexis, Matthieu Bultelle, and Richard Kitney. "An engineering biology approach to automated workflow and biodesign." (2024). -- Jiang, Shuo, et al. "ProtoCode: Leveraging Large Language Models for Automated Generation of Machine-Readable Protocols from Scientific Publications." arXiv preprint arXiv:2312.06241 (2023). -- Jiang, Shuo, et al. "ProtoCode: Leveraging large language models (LLMs) for automated generation of machine-readable PCR protocols from scientific publications." SLAS technology 29.3 (2024): 100134. -- Thieme, Anton, et al. "Deep integration of low-cost liquid handling robots in an industrial pharmaceutical development environment." SLAS technology (2024): 100180. -- Daniel, Čech. Adaptace algoritmů pro navigaci robota na základě apriorních informací. BS thesis. České vysoké učení technické v Praze. Vypočetní a informační centrum., 2024. -- Tenna Alexiadis Møller, Thom Booth, Simon Shaw, Vilhelm Krarup Møller, Rasmus J.N. Frandsen, Tilmann Weber. ActinoMation: a literate programming approach for medium-throughput robotic conjugation of Streptomyces spp. bioRxiv 2024.12.05.622625; doi: https://doi.org/10.1101/2024.12.05.622625 diff --git a/docs/resources/container/container.rst b/docs/resources/container/container.rst index ce5cdbeb8d5..4d92a816335 100644 --- a/docs/resources/container/container.rst +++ b/docs/resources/container/container.rst @@ -1,7 +1,7 @@ Container ========= -Resources that contain liquid are subclasses of :class:`~pylabrobot.resources.container.Container`. This class provides a :class:`~pylabrobot.resources.volume_tracker.VolumeTracker` that helps :class:`~pylabrobot.liquid_handling.liquid_handler.LiquidHandler` keep track of the liquid in the resource. (For more information on trackers, check out :doc:`/user_guide/machine-agnostic-features/using-trackers`). Examples of subclasses of `Container` are :class:`~pylabrobot.resources.Well` and :class:`~pylabrobot.resources.trough.Trough`. +Resources that contain liquid are subclasses of :class:`~pylabrobot.resources.container.Container`. This class provides a :class:`~pylabrobot.resources.volume_tracker.VolumeTracker` that helps :class:`~pylabrobot.legacy.liquid_handling.liquid_handler.LiquidHandler` keep track of the liquid in the resource. (For more information on trackers, check out :doc:`/user_guide/machine-agnostic-features/using-trackers`). Examples of subclasses of `Container` are :class:`~pylabrobot.resources.Well` and :class:`~pylabrobot.resources.trough.Trough`. It is possible to instantiate a `Container` directly: diff --git a/docs/resources/index.md b/docs/resources/index.md index 4a86bf7ca1c..417279aa0fd 100644 --- a/docs/resources/index.md +++ b/docs/resources/index.md @@ -55,7 +55,7 @@ PLR's `Resource` subclasses in the inheritance tree are: Resource ├── Arm - │ ├── ArticulatedArm + │ ├── ArticulatedGripperArm │ ├── CartesianArm │ └── SCARA @@ -94,14 +94,14 @@ PLR's `Resource` subclasses in the inheritance tree are: ├── ResourceHolder - │ └── PlateHolder + │ └── PlateHolder ├── Lid ├── PlateAdapter ├── ResourceStack - │ └── NestedTipRackStack (to be made) + │ └── NestedTipRackStack (to be made) └── Workcell (to be made) @@ -185,6 +185,7 @@ library/imcs library/nest library/opentrons library/perkin_elmer +library/pioreactor library/porvair library/revvity library/sergi diff --git a/docs/resources/introduction.md b/docs/resources/introduction.md index ae454285282..82cdcca8032 100644 --- a/docs/resources/introduction.md +++ b/docs/resources/introduction.md @@ -8,7 +8,7 @@ While you can instantiate a `Resource` directly, several subclasses of methods e The relation between resources is modelled by a tree, specifically an [_arborescence_]() (a directed, rooted tree). The location of a resource in the tree is a Cartesian coordinate and always relative to the bottom front left corner of its immediate parent. The absolute location, the location of the resource wrt the root of the tree it is in, can be computed using {meth}`~pylabrobot.resources.resource.Resource.get_absolute_location`. The location wrt any resource between a given one and the root can be computed using {meth}`~pylabrobot.resources.resource.Resource.get_location_wrt`. The x-axis is left (smaller) and right (larger); the y-axis is front (small) and back (larger); the z-axis is down (smaller) and up (higher). Each resource has `children` and `parent` attributes that allow you to navigate the tree. -{class}`pylabrobot.machines.machine.Machine` is a special type of resource that represents a physical machine, such as a liquid handling robot ({class}`pylabrobot.liquid_handling.liquid_handler.LiquidHandler`) or a plate reader ({class}`pylabrobot.plate_reading.plate_reader.PlateReader`). Machines have a `backend` attribute linking to the backend that is responsible for converting PyLabRobot commands into commands that a specific machine can understand. Other than that, Machines, including {class}`pylabrobot.liquid_handling.liquid_handler.LiquidHandler`, are just like any other Resource. +{class}`pylabrobot.machines.machine.Machine` is a special type of resource that represents a physical machine, such as a liquid handling robot ({class}`pylabrobot.legacy.liquid_handling.liquid_handler.LiquidHandler`) or a plate reader ({class}`pylabrobot.legacy.plate_reading.plate_reader.PlateReader`). Machines have a `backend` attribute linking to the backend that is responsible for converting PyLabRobot commands into commands that a specific machine can understand. Other than that, Machines, including {class}`pylabrobot.legacy.liquid_handling.liquid_handler.LiquidHandler`, are just like any other Resource. ## Defining a simple resource diff --git a/docs/resources/library/corning.md b/docs/resources/library/corning.md index 1248f83c2ef..30008ffd934 100644 --- a/docs/resources/library/corning.md +++ b/docs/resources/library/corning.md @@ -43,8 +43,8 @@ Company page: [Corning - Axygen® Brand Products](https://www.corning.com/emea/e | Description | Image | PLR definition | |-|-|-| -| 'Cor_Axy_24_wellplate_10mL_Vb'
Part no.: P-DW-10ML-24-C-S
[manufacturer website](https://ecatalog.corning.com/life-sciences/b2b/UK/en/Genomics-&-Molecular-Biology/Automation-Consumables/Deep-Well-Plate/Axygen%C2%AE-Deep-Well-and-Assay-Plates/p/P-DW-10ML-24-C-S) | ![](img/corning_axygen/Cor_Axy_24_wellplate_10mL_Vb.jpg) | `Cor_Axy_24_wellplate_10mL_Vb` | -| 'Cor_Axy_96_wellplate_500uL_Ub'
Part no.: P-96-450V-C-S ("-S" indicates sterile labware)
[manufacturer website](https://ecatalog.corning.com/life-sciences/b2c/US/en/Genomics-&-Molecular-Biology/Automation-Consumables/Deep-Well-Plate/Axygen%C2%AE-Deep-Well-and-Assay-Plates/p/P-96-450V-C-S) | ![](img/corning_axygen/Cor_Axy_96_wellplate_500uL_Ub.png) | `Cor_Axy_96_wellplate_500uL_Ub` | +| 'Cor_Axy_24_wellplate_10mL_Vb'
Part no.: P-DW-10ML-24-C-S, P-DW-10ML-24-C
[manufacturer website](https://ecatalog.corning.com/life-sciences/b2b/UK/en/Genomics-&-Molecular-Biology/Automation-Consumables/Deep-Well-Plate/Axygen%C2%AE-Deep-Well-and-Assay-Plates/p/P-DW-10ML-24-C-S) | ![](img/corning_axygen/Cor_Axy_24_wellplate_10mL_Vb.jpg) | `Cor_Axy_24_wellplate_10mL_Vb` | +| 'Cor_Axy_96_wellplate_500uL_Ub'
Part no.: P-96-450V-C-S, P-96-450V-C ("-S" indicates sterile labware)
[manufacturer website](https://ecatalog.corning.com/life-sciences/b2c/US/en/Genomics-&-Molecular-Biology/Automation-Consumables/Deep-Well-Plate/Axygen%C2%AE-Deep-Well-and-Assay-Plates/p/P-96-450V-C-S) | ![](img/corning_axygen/Cor_Axy_96_wellplate_500uL_Ub.png) | `Cor_Axy_96_wellplate_500uL_Ub` | ## Corning - Costar diff --git a/docs/resources/library/diy/grindbio.md b/docs/resources/library/diy/grindbio.md index 2ab0a5bea0e..0194dffe70b 100644 --- a/docs/resources/library/diy/grindbio.md +++ b/docs/resources/library/diy/grindbio.md @@ -6,3 +6,8 @@ GrindBio created a 3D printed part for one of the Hamilton modules (cat.-no. 188 | Description | Image | PLR definition | | - | - | - | | 'Hamilton_MFX_plateholder_DWP_metal_tapped_10mm_3dprint'
3D printed supports accept Hamilton MFX DWP Module (cat.-no. 188042 / 188042-00)
[OnShape link to part](https://cad.onshape.com/documents/87b79aea22945656e1849b61/w/1d28384d184c23a6551facf8/e/3313021cc0b2fe3c5e005547)
Read more about assembly [here](https://labautomation.io/t/adapters-for-hamilton-carrier-188039/6561)| ![](../img/grindbio/3d-supports-for-Hamilton-module.jpeg) | `Hamilton_MFX_plateholder_DWP_metal_tapped_10mm_3dprint` | + +## Pioreactor Plate Adapter +This ANSI-compatible adapter allows the Pioreactor to be placed on the deck. +https://cad.onshape.com/documents/cae54894e48b6624a361b53a/w/b1eaa767347c60ba70d74e31/e/cd40414a831dd21eb4ae1235 + diff --git a/docs/resources/library/img/pioreactor/pioreactor_20mL.png b/docs/resources/library/img/pioreactor/pioreactor_20mL.png new file mode 100644 index 00000000000..5692ab0e897 Binary files /dev/null and b/docs/resources/library/img/pioreactor/pioreactor_20mL.png differ diff --git a/docs/resources/library/pioreactor.md b/docs/resources/library/pioreactor.md new file mode 100644 index 00000000000..518a4717ddb --- /dev/null +++ b/docs/resources/library/pioreactor.md @@ -0,0 +1,9 @@ +# Pioreactor + +Pioreactor is a modular, open-source benchtop bioreactor system designed for running many small, parallel microbial cultivations with tight control of key parameters (e.g., stirring, aeration, temperature, and dosing) and easy integration into automation workflows. In PyLabRobot, Pioreactor labware definitions let you reference Pioreactor vessels in deck layouts and liquid-handling protocols. + +## Bioreactors + +| Description | Image | PLR definition | +|-|-|-| +| `pioreactor_20ml` | ![](img/pioreactor/pioreactor_20mL.png) | `pioreactor_20ml` | diff --git a/docs/resources/resource-holder/plate-holder.md b/docs/resources/resource-holder/plate-holder.md index dae3089ba62..59108c32be6 100644 --- a/docs/resources/resource-holder/plate-holder.md +++ b/docs/resources/resource-holder/plate-holder.md @@ -24,7 +24,7 @@ To explain what is happening in the image above: you measure the difference betw #### Measurement with z probing -To measure the height of a surface, you might find [z probing](/user_guide/00_liquid-handling/hamilton-star/z-probing) useful. Z-probing is an automated way, using a pipetting channel, to find the z height of an object. You can see a video of automated measurement on our YouTube channel: +To measure the height of a surface, you might find z brobing useful. Z-probing is an automated way, using a pipetting channel, to find the z height of an object. You can see a video of automated measurement on our YouTube channel: diff --git a/docs/user_guide/00_liquid-handling/_liquid-handling.rst b/docs/user_guide/00_liquid-handling/_liquid-handling.rst index 3de4229f65b..98e666e6e98 100644 --- a/docs/user_guide/00_liquid-handling/_liquid-handling.rst +++ b/docs/user_guide/00_liquid-handling/_liquid-handling.rst @@ -1,12 +1,7 @@ Liquid Handling =============== -Everything that touches a liquid directly. -Examples: -- Liquid handlers -- Pumps - - +TODO: remove this page ------------------------------------------ @@ -14,13 +9,6 @@ Examples: :maxdepth: 1 :hidden: - hamilton-star/_hamilton-star - hamilton-vantage/_hamilton-vantage - hamilton-prep/_hamilton-prep - opentrons/ot2/ot2 - tecan-evo/_tecan-evo - plate-washing/plate-washing - pumps/_pumps moving-channels-around tutorial_tip_inventory_consolidation mixing diff --git a/docs/user_guide/00_liquid-handling/hamilton-prep/_hamilton-prep.md b/docs/user_guide/00_liquid-handling/hamilton-prep/_hamilton-prep.md deleted file mode 100644 index 1eceeccc73c..00000000000 --- a/docs/user_guide/00_liquid-handling/hamilton-prep/_hamilton-prep.md +++ /dev/null @@ -1,3 +0,0 @@ -# Hamilton Prep - -Coming soon. See [https://github.com/PyLabRobot/pylabrobot/pull/407](https://github.com/PyLabRobot/pylabrobot/pull/407). diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/96head.ipynb b/docs/user_guide/00_liquid-handling/hamilton-star/96head.ipynb deleted file mode 100644 index fa6809c59b6..00000000000 --- a/docs/user_guide/00_liquid-handling/hamilton-star/96head.ipynb +++ /dev/null @@ -1,254 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Using the 96 head\n", - "\n", - "![star supported](https://img.shields.io/badge/STAR-supported-blue)\n", - "![Vantage supported](https://img.shields.io/badge/Vantage-supported-blue)\n", - "![OT2 not supported](https://img.shields.io/badge/OT-not%20supported-red)\n", - "![EVO not implemented](https://img.shields.io/badge/EVO-not%20implemented-orange)\n", - "\n", - "Some liquid handling robots have a 96 head, which can be used to pipette 96 samples at once. This notebook shows how to use the 96 head in PyLabRobot." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Example: Hamilton STARLet\n", - "\n", - "Here, we'll use a Hamilton STARLet as an example. For other robots, simply change the deck layout, making sure that you have at least a tip rack and a plate to use." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.liquid_handling import LiquidHandler, STARBackend\n", - "from pylabrobot.resources import STARLetDeck\n", - "from pylabrobot.resources import (\n", - " TIP_CAR_480_A00,\n", - " PLT_CAR_L5AC_A00,\n", - " TIP_50ul,\n", - " Cor_96_wellplate_360ul_Fb\n", - ")\n", - "\n", - "lh = LiquidHandler(backend=STARBackend(), deck=STARLetDeck())\n", - "await lh.setup()\n", - "\n", - "# assign a tip rack\n", - "tip_carrier = TIP_CAR_480_A00(name=\"tip_carrier\")\n", - "tip_carrier[1] = tip_rack = TIP_50ul(name=\"tip_rack\")\n", - "lh.deck.assign_child_resource(tip_carrier, rails=1)\n", - "\n", - "# assign a plate\n", - "plt_carrier = PLT_CAR_L5AC_A00(name=\"plt_carrier\")\n", - "plt_carrier[0] = plate = Cor_96_wellplate_360ul_Fb(name=\"plt\")\n", - "lh.deck.assign_child_resource(plt_carrier, rails=7)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Liquid handling with the 96 head\n", - "\n", - "Liquid handling with the 96 head is very similar to what you would do with individual channels. The methods have `96` in their names, and they take `TipRack`s and `Plate`s as arguments, as opposed to `TipSpot`s and `Well`s in case of heads with individual pipetting channels." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.pick_up_tips96(tip_rack)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For aspirations and dispenses, a single volume is passed.\n", - "\n", - "```{note}\n", - "Only single-volume aspirations and dispenses are supported because all robots that are currently implemented only support single-volume operations. When we add support for robots that can do variable-volume, this will be updated.\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.aspirate96(plate, volume=50)" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.dispense96(plate, volume=50)" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.return_tips96()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Quadrants\n", - "\n", - "96 heads can also be used to pipette quadrants of a 384 well plate. Here, we'll show how to do that.\n", - "\n", - "![quadrants](img/96head/quadrants.png)" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import BioRad_384_DWP_50uL_Vb\n", - "plt_carrier[1] = plate384 = BioRad_384_DWP_50uL_Vb(name=\"plt384\")" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.pick_up_tips96(tip_rack)" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.aspirate96(plate384.get_quadrant(1), volume=10)" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.dispense96(plate384.get_quadrant(2), volume=10)" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.aspirate96(plate384.get_quadrant(3), volume=10)" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.dispense96(plate384.get_quadrant(4), volume=10)" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.return_tips96()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Manually moving the 96 head around\n", - "\n", - "![star supported](https://img.shields.io/badge/STAR-supported-blue)\n", - "![Vantage supported](https://img.shields.io/badge/Vantage-not%20supported-red)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.backend.request_position_of_core_96_head()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.backend.move_core_96_head_x(100)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.backend.move_core_96_head_y(120)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.backend.move_core_96_head_z(300)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.2" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/_hamilton-star.rst b/docs/user_guide/00_liquid-handling/hamilton-star/_hamilton-star.rst deleted file mode 100644 index 1b62e1e8e3f..00000000000 --- a/docs/user_guide/00_liquid-handling/hamilton-star/_hamilton-star.rst +++ /dev/null @@ -1,30 +0,0 @@ -Hamilton STAR -============= - -Installation ------------- - -.. code-block:: bash - - pip install pylabrobot[usb] - -See :ref:`using-the-usb-interface` for platform-specific driver setup (libusb on Mac, Zadig on Windows). - -Tools for working with Hamilton-STAR specific functions. - -.. toctree:: - :maxdepth: 1 - - basic - 96head - autoload_and_1d_barcode_reader - iswap-module - star_lld - y-probing - z-probing - foil - debug - hardware/index - hamilton-liquid-classes - core-grippers - surface-following diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/autoload_and_1d_barcode_reader.ipynb b/docs/user_guide/00_liquid-handling/hamilton-star/autoload_and_1d_barcode_reader.ipynb deleted file mode 100644 index 6de99b35d9e..00000000000 --- a/docs/user_guide/00_liquid-handling/hamilton-star/autoload_and_1d_barcode_reader.ipynb +++ /dev/null @@ -1,907 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Using the autoload & 1D barcode reader\n", - "\n", - "| Summary | Image |\n", - "|--------|--------|\n", - "|
  • Feature: Autoload with integrated 1D barcode reader
  • Operation: Automatically loads carriers and plates from the front-loading tray onto the STAR deck.
  • Barcode Recognition: Scans 1D barcodes on carriers, plates, tube racks, and tip racks to confirm ID and placement.
  • Purpose: Reduces manual deck verification by ensuring that physically loaded carriers match the expected layout.
  • Workflow Benefit: Verification + Speeds up run setup through hands-free loading and automatic identification.
  • Best For: High-throughput or frequently changing deck configurations.
|
![real_autoload](img/autoload/hamilton_star_autoload.png)
Figure: Hamilton STAR Autoload system
|\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Background: Architecture of Hamilton STAR(let) Autoload\n", - "\n", - "![hamilton_star_overview](img/autoload/hamilton_autoload_overview.png)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Setup" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.liquid_handling import LiquidHandler, STARBackend\n", - "from pylabrobot.resources import STARDeck\n", - "from pylabrobot.resources import (\n", - " TIP_CAR_480_A00,\n", - " PLT_CAR_L5AC_A00,\n", - " hamilton_96_tiprack_50uL,\n", - " Cor_96_wellplate_360ul_Fb\n", - ")\n", - "\n", - "star = STARBackend()\n", - "lh = LiquidHandler(backend=star, deck=STARDeck())\n", - "await lh.setup()\n", - "\n", - "# assign a tip rack carrier\n", - "tip_carrier = TIP_CAR_480_A00(name=\"tip_carrier\")\n", - "tip_carrier[1] = tip_rack = hamilton_96_tiprack_50uL(name=\"tip_rack\")\n", - "lh.deck.assign_child_resource(tip_carrier, rails=10)\n", - "\n", - "# assign a plate carrier\n", - "plt_carrier = PLT_CAR_L5AC_A00(name=\"plt_carrier\")\n", - "plt_carrier[0] = plate = Cor_96_wellplate_360ul_Fb(name=\"plt\")\n", - "lh.deck.assign_child_resource(plt_carrier, rails=30)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "```{note}\n", - "Two starting points are possible:\n", - "1. Carriers have been moved directly on the deck.\n", - "2. Carriers have been left on the loading tray.\n", - "\n", - "Here we assume we're starting with (1) Carriers have been moved directly on the deck.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Querying autoload state" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "star.autoload_installed" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "54" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.request_autoload_track()\n" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'ML-STAR with 1D Barcode Scanner'" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.request_autoload_type()\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Sensing carriers\n", - "\n", - "The autoload sled has a front-facing proximity sensor.\n", - "This sensor can be used to scan the entire loading tray to identify whether there are any carriers currently on the loading tray:" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[]" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.request_presence_of_carriers_on_loading_tray()\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "```{note}\n", - "The autoload detects *only* the right-most track occupied by a carrier!\n", - "```\n", - "\n", - "Similarly, if you only want to investigate a single position this can be done too. " - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "False" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.request_presence_of_single_carrier_on_loading_tray(\n", - " track=44\n", - " )\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The counterpart to checking for carriers on the loading tray is checking for presence of carriers on the liquid handler's deck:" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[15, 35]" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.request_presence_of_carriers_on_deck()\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Note that we have assigned the carriers based on the left-most track they are occupying but the `request_presence_of_carriers_on_deck()` detects the right-most track of carriers:\n", - "- tip_carrier: left-most track = 10, 6 tracks wide, right-most track 15\n", - "- plt_carrier: left-most track = 30, 6 tracks wide, right-most track 35\n", - "\n", - "```{note}\n", - "`.request_presence_of_carriers_on_deck()` is technically not an 'autoload' command.\n", - "It uses the presence sensors at the back of the STAR(let) deck to identify whether a carrier is present.\n", - "i.e. there is no autoload movement involved, and it therefore works for STAR(let)s without an integrated barcode sensor too.\n", - "```\n", - "\n", - "Together these `STAR` methods enable capturing a full picture of the state of carriers on your liquid handler." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Moving autoload" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'C0IVid0018er00/00'" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Always ensure the carrier handler is safely tucked away during movement.\n", - "await star.move_autoload_to_save_z_position()\n" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'I0XPid0020er00'" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.move_autoload_to_track(30)\n" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'I0XPid0022er00'" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.park_autoload()\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Basic Load & Unload" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'C0CRid0023er00/00'" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.unload_carrier(tip_carrier, park_autoload_after=False)\n" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'carrier_barcode': Barcode(data='08T0241707', symbology='Code 128 (Subset B and C)', position_on_resource='right'),\n", - " 'container_barcodes': None}" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.load_carrier(tip_carrier, park_autoload_after=False)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Command Architecture for (Un)Loading & Barcode Reading\n", - "\n", - "From a firmware-perspective a carrier can be in 3 \"states\":\n", - "1. On the deck\n", - "2. On the autoload belt\n", - "3. On the loading tray\n", - "\n", - "PyLabRobot enables easy transfer between these 3 states with these `STARBackend` methods:\n", - "\n", - "![hamilton_star_overview](img/autoload/hamilton_autoload_state_transfer.png)\n", - "\n", - "```{note}\n", - "We could not find a command that enables the movement sequence: deck > autoload_belt > loading_tray yet.\n", - "`.unload_carrier_after_carrier_barcode_scanning()` requires `.load_carrier_from_tray_and_scan_carrier_barcode()` to precede it.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 1D Barcode reading\n", - "\n", - "There are 2 main types of 1D barcodes one a classic STAR(let) deck:\n", - "1. Carrier barcodes (orientation=vertical; located at the right-back of the carrier)\n", - "2. Container barcodes:\n", - " - TipRack barcodes (orientation=horizontal)\n", - " - Plate barcodes (orientation=horizontal)\n", - " - Tube barcodes (orientation=vertical)\n", - "\n", - "### 1D Barcode Symbologies\n", - "\n", - "All barcodes must follow a standard encoding scheme, i.e. a \"symbology\".\n", - "Before reading barcodes it is important to know what barcode symbology you are expecting to read!\n", - "\n", - "The following barcode symbologies can be detected by the system:" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "['ISBT Standard',\n", - " 'Code 128 (Subset B and C)',\n", - " 'Code 39',\n", - " 'Codebar',\n", - " 'Code 2of5 Interleaved',\n", - " 'UPC A/E',\n", - " 'YESN/EAN 8',\n", - " 'ANY 1D']" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "list(star.barcode_1d_symbology_dict.keys())\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For the highest reading safety Hamilton recommends to use barcode type `Code128 (subset B and C)`.\n", - "\n", - "This is the default symbology chosen in PyLabRobot commands:" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Code 128 (Subset B and C)'" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "star._default_1d_symbology\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "However, you can directly set or change the expected barcode symbology:\n", - "\n", - "\n", - "The fastest way to read your barcode *when* your carriers are already on the deck is to move the carrier out to the identification position:" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'ISBT Standard'" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.set_1d_barcode_type(\"ISBT Standard\")\n", - "\n", - "star._default_1d_symbology\n" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Code 128 (Subset B and C)'" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.set_1d_barcode_type(\"Code 128 (Subset B and C)\")\n", - "\n", - "star._default_1d_symbology\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Loading with with Barcode Reading\n", - "\n", - "1D barcode reading via the autoload can only occur during carrier loading actions.\n", - "So let's first unload the carrier:" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'C0CRid0029er00/00'" - ] - }, - "execution_count": 17, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.unload_carrier(tip_carrier, park_autoload_after=False)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "...and this time activate reading both (1) the carrier barcord and (2) the container barcode:" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "metadata": {}, - "outputs": [], - "source": [ - "barcode_readings = await star.load_carrier(\n", - " carrier=tip_carrier,\n", - " carrier_barcode_reading=True,\n", - " barcode_reading=True,\n", - " # barcode_symbology=\"Code 39\",\n", - " # barcode_reading_direction=\"horizontal\",\n", - " # no_container_per_carrier=5,\n", - " park_autoload_after=False,\n", - ")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "This returns a dictionary:" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'carrier_barcode': Barcode(data='08T0241707', symbology='Code 128 (Subset B and C)', position_on_resource='right'),\n", - " 'container_barcodes': {0: Barcode(data='18235938752776512151', symbology='Code 128 (Subset B and C)', position_on_resource='right'),\n", - " 1: Barcode(data='18235938752776527151', symbology='Code 128 (Subset B and C)', position_on_resource='right'),\n", - " 2: Barcode(data='18235938752776549151', symbology='Code 128 (Subset B and C)', position_on_resource='right'),\n", - " 3: None,\n", - " 4: Barcode(data='18235938752776513151', symbology='Code 128 (Subset B and C)', position_on_resource='right')}}" - ] - }, - "execution_count": 19, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "barcode_readings" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "```{warning}\n", - "The default behaviour of the 1D barcode scanner is set to read the standard 5x tip- and plate-carrier positions.\n", - "If you are reading barcodes on a different carrier (e.g. tube or trough carrier) you will have to modify the default parameters.\n", - "```\n", - "\n", - "![hamilton_star_overview](img/autoload/hamilton_autoload_correct_1d_barcode_height.png)\n", - "\n", - "```{warning}\n", - "The 1D barcode scanner uses a (class 2) laser targeted to a fixed height.\n", - "This is especially important when reading horizontal barcodes.\n", - "The z-height of the laser (during horizonatal) barcode reading is `z=219` or 119 mm above the deck surface.\n", - "If your 1D barcode is not precisely positioned at this height, the 1D barcode reader cannot read your barcode.\n", - "To facilitate this height, use \"DWP\" carriers/MFX plate_holders for plates >40 mm in `size_z`, and \"MP\" carriers/MFX plate_holders for plates ~15 mm in `size_z`.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Advanced 1D Barcode Reading" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'C0CRid0035er00/00'" - ] - }, - "execution_count": 20, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.unload_carrier(tip_carrier, park_autoload_after=False)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Loading tray -> Autoload Belt (Carrier Barcode Reading) -> Loading Tray**" - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Barcode(data='08T0241707', symbology='Code 128 (Subset B and C)', position_on_resource='right')" - ] - }, - "execution_count": 21, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.load_carrier_from_tray_and_scan_carrier_barcode(\n", - " tip_carrier,\n", - " # barcode_position = 4.3, # mm\n", - " # barcode_reading_window_width = 38.0, # mm\n", - " # reading_speed = 128.1, # mm/sec\n", - " )\n" - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'C0CAid0037er00/00'" - ] - }, - "execution_count": 22, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.unload_carrier_after_carrier_barcode_scanning()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Loading tray -> Autoload Belt (Carrier Barcode Reading) -> Deck (Container Barcode Reading)**\n", - "\n", - "-> same as simply `.load_carrier()` but split into separate components" - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Barcode(data='08T0241707', symbology='Code 128 (Subset B and C)', position_on_resource='right')" - ] - }, - "execution_count": 23, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.load_carrier_from_tray_and_scan_carrier_barcode(\n", - " tip_carrier\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: Barcode(data='18235938752776512151', symbology='Code 128 (Subset B and C)', position_on_resource='right'),\n", - " 1: Barcode(data='18235938752776527151', symbology='Code 128 (Subset B and C)', position_on_resource='right'),\n", - " 2: Barcode(data='18235938752776549151', symbology='Code 128 (Subset B and C)', position_on_resource='right'),\n", - " 3: None,\n", - " 4: Barcode(data='18235938752776513151', symbology='Code 128 (Subset B and C)', position_on_resource='right')}" - ] - }, - "execution_count": 24, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "reading = await star.load_carrier_from_autoload_belt(\n", - " barcode_reading=True,\n", - " park_autoload_after=False\n", - " # barcode_reading_direction = \"horizontal\",\n", - " # barcode_symbology = \"Code 128 (Subset B and C)\"\n", - " # reading_position_of_first_barcode = 63.0, # mm\n", - " # no_container_per_carrier = 5,\n", - " # distance_between_containers = 96.0, # mm\n", - " # width_of_reading_window = 38.0, # mm\n", - " # reading_speed = 128.1, # mm/secs\n", - " )\n", - "reading" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Deck -> Autoload Belt -> Deck (with container barcode reading only)**" - ] - }, - { - "cell_type": "code", - "execution_count": 25, - "metadata": {}, - "outputs": [], - "source": [ - "await star.take_carrier_out_to_autoload_belt(tip_carrier)\n" - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: Barcode(data='18235938752776512151', symbology='Code 128 (Subset B and C)', position_on_resource='right'),\n", - " 1: Barcode(data='18235938752776527151', symbology='Code 128 (Subset B and C)', position_on_resource='right'),\n", - " 2: Barcode(data='18235938752776549151', symbology='Code 128 (Subset B and C)', position_on_resource='right'),\n", - " 3: None,\n", - " 4: Barcode(data='18235938752776513151', symbology='Code 128 (Subset B and C)', position_on_resource='right')}" - ] - }, - "execution_count": 26, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "reading = await star.load_carrier_from_autoload_belt(\n", - " barcode_reading = True,\n", - " park_autoload_after=False,\n", - " )\n", - "reading" - ] - }, - { - "cell_type": "code", - "execution_count": 27, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'C0IVid0045er00/00'" - ] - }, - "execution_count": 27, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.move_autoload_to_save_z_position()" - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'I0XPid0047er00'" - ] - }, - "execution_count": 28, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await star.park_autoload()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Close Connection" - ] - }, - { - "cell_type": "code", - "execution_count": 29, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.stop()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "```{note}\n", - "Someone did not set up the deck according to the definition in the `Setup` section above.\n", - "What is different between sensed physical reality and the deck model? ;)\n", - "```" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "plr", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.11" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/basic.ipynb b/docs/user_guide/00_liquid-handling/hamilton-star/basic.ipynb deleted file mode 100644 index 7fb3bda2d2d..00000000000 --- a/docs/user_guide/00_liquid-handling/hamilton-star/basic.ipynb +++ /dev/null @@ -1,426 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Getting started with liquid handling on a Hamilton STAR(let)\n", - "\n", - "In this notebook, you will learn how to use PyLabRobot to move water from one range of wells to another.\n", - "\n", - "**Note: before running this notebook, you should have**:\n", - "\n", - "- Installed PyLabRobot and the USB driver as described in [the installation guide](../../_getting-started/installation).\n", - "- Connected the Hamilton to your computer using the USB cable.\n", - "\n", - "Video of what this code does:\n", - "\n", - "" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Setting up a connection with the robot\n", - "\n", - "Start by importing the {class}`~pylabrobot.liquid_handling.liquid_handler.LiquidHandler` class, which will serve as a front end for all liquid handling operations.\n", - "\n", - "Backends serve as communicators between `LiquidHandler`s and the actual hardware. Since we are using a Hamilton STAR, we also import the {class}`~pylabrobot.liquid_handling.backends.STAR_backend.STARBackend` backend." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.liquid_handling import LiquidHandler\n", - "from pylabrobot.liquid_handling.backends import STARBackend" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "In addition, import the {class}`~pylabrobot.resources.hamilton.STARLetDeck`, which represents the deck of the Hamilton STAR." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources.hamilton import STARLetDeck" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Create a new liquid handler using `STARBackend` as its backend." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [], - "source": [ - "backend = STARBackend()\n", - "lh = LiquidHandler(backend=backend, deck=STARLetDeck())" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The final step is to open communication with the robot. This is done using the {func}`~pylabrobot.liquid_handling.LiquidHandler.setup` method." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": { - "scrolled": false - }, - "outputs": [], - "source": [ - "await lh.setup()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Creating the deck layout\n", - "\n", - "Now that we have a `LiquidHandler` instance, we can define the deck layout.\n", - "\n", - "The layout in this tutorial will contain five sets of standard volume tips with filter, 1 set of 96 1mL wells, and tip and plate carriers on which these resources are positioned.\n", - "\n", - "Start by importing the relevant objects and variables from the PyLabRobot package. This notebook uses the following resources:\n", - "\n", - "- {class}`~pylabrobot.resources.hamilton.tip_carriers.TIP_CAR_480_A00` tip carrier\n", - "- {class}`~pylabrobot.resources.hamilton.plate_carriers.PLT_CAR_L5AC_A00` plate carrier\n", - "- {class}`~pylabrobot.resources.corning_costar.plates.Cor_96_wellplate_360ul_Fb` wells\n", - "- {class}`~pylabrobot.resources.hamilton.tip_racks.hamilton_96_tiprack_1000uL_filter` tips" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import (\n", - " TIP_CAR_480_A00,\n", - " PLT_CAR_L5AC_A00,\n", - " Cor_96_wellplate_360ul_Fb,\n", - " hamilton_96_tiprack_1000uL_filter,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Then create a tip carrier named `tip carrier`, which will contain tip rack at all 5 positions. These positions can be accessed using `tip_car[x]`, and are 0 indexed." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [], - "source": [ - "tip_car = TIP_CAR_480_A00(name=\"tip carrier\")\n", - "tip_car[0] = hamilton_96_tiprack_1000uL_filter(name=\"tips_01\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Use {func}`~pylabrobot.resources.abstract.assign_child_resources` to assign the tip carrier to the deck of the liquid handler. All resources contained by this carrier will be assigned automatically.\n", - "\n", - "In the `rails` parameter, we can pass the location of the tip carrier. The locations of the tips will automatically be calculated." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [], - "source": [ - "lh.deck.assign_child_resource(tip_car, rails=3)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Repeat this for the plates." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [], - "source": [ - "plt_car = PLT_CAR_L5AC_A00(name=\"plate carrier\")\n", - "plt_car[0] = Cor_96_wellplate_360ul_Fb(name=\"plate_01\")" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [], - "source": [ - "lh.deck.assign_child_resource(plt_car, rails=15)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Let's look at a summary of the deck layout using {func}`~pylabrobot.liquid_handling.LiquidHandler.summary`." - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Rail Resource Type Coordinates (mm)\n", - "===============================================================================================\n", - "(3) ├── tip carrier TipCarrier (145.000, 063.000, 100.000)\n", - " │ ├── tips_01 TipRack (162.900, 145.800, 131.450)\n", - " │ ├── \n", - " │ ├── \n", - " │ ├── \n", - " │ ├── \n", - " │\n", - "(15) ├── plate carrier PlateCarrier (415.000, 063.000, 100.000)\n", - " │ ├── plate_01 Plate (433.000, 146.000, 187.150)\n", - " │ ├── \n", - " │ ├── \n", - " │ ├── \n", - " │ ├── \n", - " │\n", - "(32) ├── trash Trash (800.000, 190.600, 137.100)\n", - "\n" - ] - } - ], - "source": [ - "lh.summary()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Picking up tips\n", - "\n", - "Picking up tips is as easy as querying the tips from the tiprack." - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:pylabrobot.liquid_handling.backends.hamilton.STARBackend:Sent command: C0TTid0004tt01tf1tl0871tv12500tg3tu0\n", - "INFO:pylabrobot.liquid_handling.backends.hamilton.STARBackend:Received response: C0TTid0004er00/00\n", - "INFO:pylabrobot.liquid_handling.backends.hamilton.STARBackend:Sent command: C0TPid0005xp01629 01629 01629 00000&yp1458 1368 1278 0000&tm1 1 1 0&tt01tp2244tz2164th2450td0\n", - "INFO:pylabrobot.liquid_handling.backends.hamilton.STARBackend:Received response: C0TPid0005er00/00\n" - ] - } - ], - "source": [ - "tiprack = lh.deck.get_resource(\"tips_01\")\n", - "await lh.pick_up_tips(tiprack[\"A1:C1\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Aspirating and dispensing" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Aspirating and dispensing work similarly to picking up tips: where you use booleans to specify which tips to pick up, with aspiration and dispensing you use floats to specify the volume to aspirate or dispense in $\\mu L$.\n", - "\n", - "The cells below move liquid from wells `'A1:C1'` to `'D1:F1'` using channels 1, 2, and 3 using the {func}`~pylabrobot.liquid_handling.LiquidHandler.aspirate` and {func}`~pylabrobot.liquid_handling.LiquidHandler.dispense` methods." - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:pylabrobot.liquid_handling.backends.hamilton.STARBackend:Sent command: C0ASid0006at0&tm1 1 1 0&xp04330 04330 04330 00000&yp1460 1370 1280 0000&th2450te2450lp1931 1931 1931&ch000 000 000&zl1881 1881 1881&po0100 0100 0100&zu0032 0032 0032&zr06180 06180 06180&zx1831 1831 1831&ip0000 0000 0000&it0 0 0&fp0000 0000 0000&av01072 00551 02110&as1000 1000 1000&ta000 000 000&ba0000 0000 0000&oa000 000 000&lm0 0 0&ll1 1 1&lv1 1 1&zo000 000 000&ld00 00 00&de0020 0020 0020&wt10 10 10&mv00000 00000 00000&mc00 00 00&mp000 000 000&ms1000 1000 1000&mh0000 0000 0000&gi000 000 000&gj0gk0lk0 0 0&ik0000 0000 0000&sd0500 0500 0500&se0500 0500 0500&sz0300 0300 0300&io0000 0000 0000&il00000 00000 00000&in0000 0000 0000&\n", - "INFO:pylabrobot.liquid_handling.backends.hamilton.STARBackend:Received response: C0ASid0006er00/00\n" - ] - } - ], - "source": [ - "plate = lh.deck.get_resource(\"plate_01\")\n", - "await lh.aspirate(plate[\"A1:C1\"], vols=[100.0, 50.0, 200.0])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "After the liquid has been aspirated, dispense it in the wells below. Note that while we specify different wells, we are still using the same channels. This is needed because only these channels contain liquid, of course." - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:pylabrobot.liquid_handling.backends.hamilton.STARBackend:Sent command: C0DSid0007dm2 2 2&tm1 1 1 0&xp04330 04330 04330 00000&yp1190 1100 1010 0000&zx1871 1871 1871&lp2321 2321 2321&zl1881 1881 1881&po0100 0100 0100&ip0000 0000 0000&it0 0 0&fp0000 0000 0000&zu0032 0032 0032&zr06180 06180 06180&th2450te2450dv01072 00551 02110&ds1200 1200 1200&ss0050 0050 0050&rv000 000 000&ta000 000 000&ba0000 0000 0000&lm0 0 0&dj00zo000 000 000&ll1 1 1&lv1 1 1&de0020 0020 0020&wt00 00 00&mv00000 00000 00000&mc00 00 00&mp000 000 000&ms0010 0010 0010&mh0000 0000 0000&gi000 000 000&gj0gk0\n", - "INFO:pylabrobot.liquid_handling.backends.hamilton.STARBackend:Received response: C0DSid0007er00/00\n" - ] - } - ], - "source": [ - "await lh.dispense(plate[\"D1:F1\"], vols=[100.0, 50.0, 200.0])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Let's move the liquid back to the original wells." - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:pylabrobot.liquid_handling.backends.hamilton.STARBackend:Sent command: C0ASid0008at0&tm1 1 1 0&xp04330 04330 04330 00000&yp1190 1100 1010 0000&th2450te2450lp1931 1931 1931&ch000 000 000&zl1881 1881 1881&po0100 0100 0100&zu0032 0032 0032&zr06180 06180 06180&zx1831 1831 1831&ip0000 0000 0000&it0 0 0&fp0000 0000 0000&av01072 00551 02110&as1000 1000 1000&ta000 000 000&ba0000 0000 0000&oa000 000 000&lm0 0 0&ll1 1 1&lv1 1 1&zo000 000 000&ld00 00 00&de0020 0020 0020&wt10 10 10&mv00000 00000 00000&mc00 00 00&mp000 000 000&ms1000 1000 1000&mh0000 0000 0000&gi000 000 000&gj0gk0lk0 0 0&ik0000 0000 0000&sd0500 0500 0500&se0500 0500 0500&sz0300 0300 0300&io0000 0000 0000&il00000 00000 00000&in0000 0000 0000&\n", - "INFO:pylabrobot.liquid_handling.backends.hamilton.STARBackend:Received response: C0ASid0008er00/00\n", - "INFO:pylabrobot.liquid_handling.backends.hamilton.STARBackend:Sent command: C0DSid0009dm2 2 2&tm1 1 1 0&xp04330 04330 04330 00000&yp1460 1370 1280 0000&zx1871 1871 1871&lp2321 2321 2321&zl1881 1881 1881&po0100 0100 0100&ip0000 0000 0000&it0 0 0&fp0000 0000 0000&zu0032 0032 0032&zr06180 06180 06180&th2450te2450dv01072 00551 02110&ds1200 1200 1200&ss0050 0050 0050&rv000 000 000&ta000 000 000&ba0000 0000 0000&lm0 0 0&dj00zo000 000 000&ll1 1 1&lv1 1 1&de0020 0020 0020&wt00 00 00&mv00000 00000 00000&mc00 00 00&mp000 000 000&ms0010 0010 0010&mh0000 0000 0000&gi000 000 000&gj0gk0\n", - "INFO:pylabrobot.liquid_handling.backends.hamilton.STARBackend:Received response: C0DSid0009er00/00\n" - ] - } - ], - "source": [ - "await lh.aspirate(plate[\"D1:F1\"], vols=[100.0, 50.0, 200.0])\n", - "await lh.dispense(plate[\"A1:C1\"], vols=[100.0, 50.0, 200.0])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Dropping tips\n", - "\n", - "Finally, you can drop tips anywhere on the deck by using the {func}`~pylabrobot.liquid_handling.LiquidHandler.drop_tips` method." - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:pylabrobot.liquid_handling.backends.hamilton.STARBackend:Sent command: C0TRid0010xp01629 01629 01629 00000&yp1458 1368 1278 0000&tm1 1 1 0&tt01tp1314tz1414th2450ti0\n", - "INFO:pylabrobot.liquid_handling.backends.hamilton.STARBackend:Received response: C0TRid0010er00/00kz381 356 365 000 000 000 000 000vz303 360 368 000 000 000 000 000\n" - ] - } - ], - "source": [ - "await lh.drop_tips(tiprack[\"A1:C1\"])" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "WARNING:root:Closing connection to USB device.\n" - ] - } - ], - "source": [ - "await lh.stop()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3.9.13 ('env': venv)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.8" - }, - "vscode": { - "interpreter": { - "hash": "bf274dfc1b974177267b6b8fba8543eeb0bb4c5d64c637dde420829b05625268" - } - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/core-grippers.ipynb b/docs/user_guide/00_liquid-handling/hamilton-star/core-grippers.ipynb deleted file mode 100644 index 71a555b38a1..00000000000 --- a/docs/user_guide/00_liquid-handling/hamilton-star/core-grippers.ipynb +++ /dev/null @@ -1,226 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0fe80c88", - "metadata": {}, - "source": [ - "# Co-Re Grippers\n", - "\n", - "This tutorial demonstrates how to use the Co-Re grippers on Hamilton STAR liquid handling robots with PyLabRobot.\n", - "\n", - "The Co-Re grippers mount on pipetting channels and allow for moving labware around the deck." - ] - }, - { - "cell_type": "markdown", - "id": "55622e0d", - "metadata": {}, - "source": [ - "## Deck setup\n", - "\n", - "There are two different types of core grippers:\n", - "\n", - "![Co-Re gripper types](./img/core-grippers/core-gripper-types.jpg)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "aeb84f69", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.liquid_handling import LiquidHandler, STARBackend\n", - "from pylabrobot.liquid_handling.backends.hamilton.STAR_chatterbox import STARChatterboxBackend\n", - "from pylabrobot.resources import STARDeck # STARLetDeck\n", - "deck = STARDeck(\n", - " core_grippers=\"1000uL-at-waste\" # or \"1000uL-5mL-on-waste\"\n", - ") # STARLetDeck()\n", - "# star_backend = STARChatterboxBackend()\n", - "star_backend = STARBackend()\n", - "lh = LiquidHandler(backend=star_backend, deck=deck) # STARLetDeck())" - ] - }, - { - "cell_type": "markdown", - "id": "c158df6a", - "metadata": {}, - "source": [ - "Let's also set up a dummy plate carrier and plate." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7d024e1b", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import PLT_CAR_L5AC_A00, CellTreat_96_wellplate_350ul_Ub\n", - "plate_carrier = PLT_CAR_L5AC_A00(name=\"plate_carrier\")\n", - "plate_carrier[0] = plate = CellTreat_96_wellplate_350ul_Ub(name=\"plate\")\n", - "deck.assign_child_resource(plate_carrier, rails=14)" - ] - }, - { - "cell_type": "markdown", - "id": "30168d2b", - "metadata": {}, - "source": [ - "Finally call `setup` to initialize the robot:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "90b60293", - "metadata": {}, - "outputs": [], - "source": [ - "await lh.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "0a4a6ae0", - "metadata": {}, - "source": [ - "## Moving resources\n", - "\n", - "There are two apis:\n", - "\n", - "- `move_resource`: a single call, simple to read. This function calls `pick_up_resource` and `drop_resource` internally.\n", - "- `pick_up_resource` and `drop_resource`: two calls, more control over the timing of operations." - ] - }, - { - "cell_type": "markdown", - "id": "831e8798", - "metadata": {}, - "source": [ - "## `move_resource` api\n", - "\n", - "This API is the simplest to read:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9c6cc552", - "metadata": {}, - "outputs": [], - "source": [ - "await lh.move_resource(\n", - " plate,\n", - " plate_carrier[1],\n", - " pickup_distance_from_top=10,\n", - "\n", - " # Specify to use the core arm.\n", - " use_arm=\"core\",\n", - "\n", - " # use two channels to pick up the core gripper tools. Channels are 0-indexed.\n", - " # specify only the front channel. In this case the back channel will be 6.\n", - " core_front_channel=7,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "dcd28a93", - "metadata": {}, - "source": [ - "## `pick_up_resource` and `drop_resource` apis\n", - "\n", - "These APIs give more control over the pick up and drop actions:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "02b73541", - "metadata": {}, - "outputs": [], - "source": [ - "await lh.pick_up_resource(\n", - " plate,\n", - " pickup_distance_from_top=10,\n", - "\n", - " # Backend kwargs:\n", - "\n", - " # Specify to use the core arm.\n", - " use_arm=\"core\",\n", - "\n", - " # use two channels to pick up the core gripper tools. Channels are 0-indexed.\n", - " # specify only the front channel. In this case the back channel will be 6.\n", - " core_front_channel=7,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d84fd1e9", - "metadata": {}, - "outputs": [], - "source": [ - "await lh.drop_resource(\n", - " plate_carrier[1],\n", - "\n", - " # Backend kwargs:\n", - " use_arm=\"core\",\n", - " return_core_gripper=True,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "726cd298", - "metadata": {}, - "source": [ - "## Manual control over grippers" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f89a7b0e", - "metadata": {}, - "outputs": [], - "source": [ - "await star_backend.pick_up_core_gripper_tools(front_channel=7)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1400f0b4", - "metadata": {}, - "outputs": [], - "source": [ - "await star_backend.return_core_gripper_tools()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/foil.ipynb b/docs/user_guide/00_liquid-handling/hamilton-star/foil.ipynb deleted file mode 100644 index 75d0568dc30..00000000000 --- a/docs/user_guide/00_liquid-handling/hamilton-star/foil.ipynb +++ /dev/null @@ -1,202 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Foil\n", - "\n", - "The :class:`~pylabrobot.liquid_handling.backends.hamilton.STAR_backend.STAR` backend includes special utilities for working with foil-sealed plates, specifically:\n", - "\n", - "1. a function to pierce foil before aspirating from the plate, and\n", - "2. a function to keep the plate down while moving the channels up to avoid lifting the plate." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Example setup\n", - "\n", - "```{note}\n", - "While this example uses high volume tips, it _might_ be possible to use other tip types to pierce the foil. However, 50uL tips are very soft and probably can't be used.\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.liquid_handling import LiquidHandler, STARBackend\n", - "from pylabrobot.resources import STARLetDeck\n", - "from pylabrobot.resources import (\n", - " TIP_CAR_480_A00,\n", - " PLT_CAR_L5AC_A00,\n", - " hamilton_96_tiprack_1000ul,\n", - " AGenBio_4_wellplate_Vb\n", - ")\n", - "\n", - "star = STARBackend()\n", - "deck = STARLetDeck()\n", - "lh = LiquidHandler(backend=star, deck=deck)\n", - "await lh.setup()\n", - "\n", - "# assign a tip rack\n", - "tip_carrier = TIP_CAR_480_A00(name=\"tip_carrier\")\n", - "tip_carrier[1] = tip_rack = hamilton_96_tiprack_1000ul(name=\"tip_rack\")\n", - "lh.deck.assign_child_resource(tip_carrier, rails=1)\n", - "\n", - "# assign a plate\n", - "plt_carrier = PLT_CAR_L5AC_A00(name=\"plt_carrier\")\n", - "plt_carrier[0] = plate = AGenBio_4_wellplate_Vb(name=\"plate\")\n", - "lh.deck.assign_child_resource(plt_carrier, rails=10)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Breaking the foil before using a plate\n", - "\n", - "It is important to break the foil before aspirating because tiny foil pieces can stuck in the tip, drastically changing the liquid handling characteristics.\n", - "\n", - "In this example, we will use an 8 channel workcell and use the inner 6 channels for breaking the foil and then aspirating. We will use the outer 2 channels to keep the plate down while the inner channels are moving up." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "well = plate.get_well(\"A1\")\n", - "await lh.pick_up_tips(tip_rack[\"A1:H1\"])" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [], - "source": [ - "aspiration_channels = [1, 2, 3, 4, 5, 6]\n", - "hold_down_channels = [0, 7]\n", - "await star.pierce_foil(\n", - " wells=[well],\n", - " piercing_channels=aspiration_channels,\n", - " hold_down_channels=hold_down_channels,\n", - " move_inwards=4,\n", - " one_by_one=False,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.return_tips()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "![gif of piercing foil](./img/pierce_foil.gif)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Holding the plate down\n", - "\n", - "Holding the plate down while moving channels up after aspiration consists of two parts:\n", - "1. Making the channels stay down after a liquid handling operation has finished. By default, STAR will move channels up to traversal height.\n", - "2. Putting two channels on the edges of the plate to hold it down, while moving the other channels up." - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.pick_up_tips(tip_rack[\"A2:H2\"])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "num_channels = len(aspiration_channels)\n", - "await lh.aspirate(\n", - " [well]*num_channels, vols=[100]*num_channels, use_channels=aspiration_channels,\n", - "\n", - " # aspiration parameters (backend_kwargs)\n", - " min_z_endpos=well.get_location_wrt(deck, z=\"cavity_bottom\").z, # z end position: where channels go after aspiration\n", - " surface_following_distance=0, # no moving in z dimension during aspiration\n", - " pull_out_distance_transport_air=[0] * num_channels # no moving up to aspirate transport air after aspiration\n", - ")\n", - "\n", - "await star.step_off_foil(\n", - " well,\n", - " front_channel=7,\n", - " back_channel=0,\n", - " move_inwards=5,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.return_tips()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "![gif of holding down foil](./img/step_off_foil.gif)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hamilton-liquid-classes.ipynb b/docs/user_guide/00_liquid-handling/hamilton-star/hamilton-liquid-classes.ipynb deleted file mode 100644 index f2e8c6a2d0f..00000000000 --- a/docs/user_guide/00_liquid-handling/hamilton-star/hamilton-liquid-classes.ipynb +++ /dev/null @@ -1,289 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "302c3f6d", - "metadata": {}, - "source": [ - "# Using \"Hamilton Liquid Classes\" with Pylabrobot\n", - "\n", - "This notebook demonstrates how to use the Hamilton liquid classes with Pylabrobot's liquid handling system. \"Liquid classes\" are essentially a essentially sets of predefined parameters that describe a specific liquid transfer operation (aspirate + dispense). While it is possible to control all parameters explicitly/manually, or to store those in dictionaries of kwargs, using \"Hamilton liquid classes\" is the historical way many people are used to doing this in Venus. \"Hamilton liquid class\" refers to the concept of the 'set of parameters' as it is used in VENUS.\n", - "\n", - "PyLabRobot has imported many Hamilton liquid classes from VENUS. In this notebook we will show how to use these classes in PylabRobot." - ] - }, - { - "cell_type": "markdown", - "id": "244c67c9", - "metadata": {}, - "source": [ - "## Simple example setup" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "e4a91d71", - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "markdown", - "id": "314c913e", - "metadata": {}, - "source": [ - "Use the `STARChatterboxBackend` to test out the liquid classes without connecting to a real Hamilton STAR robot. Switch out the backend to `STARBackend` to run on a real robot." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "8a28fa0e", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.liquid_handling import LiquidHandler\n", - "from pylabrobot.liquid_handling.backends.hamilton.STAR_chatterbox import STARChatterboxBackend\n", - "# from pylabrobot.liquid_handling.backends.hamilton.STAR_backend import STARBackend\n", - "\n", - "from pylabrobot.resources.hamilton import STARLetDeck\n", - "\n", - "backend = STARChatterboxBackend()\n", - "# backend = STARBackend()\n", - "lh = LiquidHandler(backend=backend, deck=STARLetDeck())\n", - "\n", - "await lh.setup()" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "91b6df80", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import (\n", - " TIP_CAR_480_A00,\n", - " PLT_CAR_L5AC_A00,\n", - " Cor_96_wellplate_360ul_Fb,\n", - " hamilton_96_tiprack_1000uL_filter,\n", - ")\n", - "\n", - "tip_car = TIP_CAR_480_A00(name=\"tip carrier\")\n", - "tip_car[0] = tr = hamilton_96_tiprack_1000uL_filter(name=\"tips_01\")\n", - "lh.deck.assign_child_resource(tip_car, rails=3)\n", - "\n", - "plt_car = PLT_CAR_L5AC_A00(name=\"plate carrier\")\n", - "plt_car[0] = plate = Cor_96_wellplate_360ul_Fb(name=\"plate_01\")\n", - "lh.deck.assign_child_resource(plt_car, rails=15)" - ] - }, - { - "cell_type": "markdown", - "id": "034980b1", - "metadata": {}, - "source": [ - "### Picking up tips for the rest of the notebook" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "d8dda5e8", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "C0TTid0001tt01tf1tl0871tv10650tg3tu0\n", - "C0TPid0002xp01629 01629 01629 00000&yp1458 1368 1278 0000&tm1 1 1 0&tt01tp2266tz2166th2450td0\n" - ] - } - ], - "source": [ - "await lh.pick_up_tips(tr[\"A1:C1\"])" - ] - }, - { - "cell_type": "markdown", - "id": "33988b4c", - "metadata": {}, - "source": [ - "## Using a predefined Hamilton liquid class" - ] - }, - { - "cell_type": "markdown", - "id": "4e4a3d55", - "metadata": {}, - "source": [ - "Pass a predefined Hamilton liquid class to the `lh.aspirate` method using the `hamilton_liquid_classes` parameter. This parameter is a backend kwarg defined by the STARBackend. This will use the parameters defined in the liquid class for the aspirate operation." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "4cc2e7d7", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "C0ASid0003at0 0 0 0&tm1 1 1 0&xp04333 04333 04333 00000&yp1457 1367 1277 0000&th2450te2450lp2000 2000 2000 2000&ch000 000 000 000&zl1866 1866 1866 1866&po0100 0100 0100 0100&zu0032 0032 0032 0032&zr06180 06180 06180 06180&zx1866 1866 1866 1866&ip0000 0000 0000 0000&it0 0 0 0&fp0000 0000 0000 0000&av01083 00563 02110 01083&as2500 2500 2500 2500&ta000 000 000 000&ba0000 0000 0000 0000&oa000 000 000 000&lm0 0 0 0&ll1 1 1 1&lv1 1 1 1&zo000 000 000 000&ld00 00 00 00&de0020 0020 0020 0020&wt10 10 10 10&mv00000 00000 00000 00000&mc00 00 00 00&mp000 000 000 000&ms1200 1200 1200 1200&mh0000 0000 0000 0000&gi000 000 000 000&gj0gk0lk0 0 0 0&ik0000 0000 0000 0000&sd0500 0500 0500 0500&se0500 0500 0500 0500&sz0300 0300 0300 0300&io0000 0000 0000 0000&il00000 00000 00000 00000&in0000 0000 0000 0000&\n" - ] - } - ], - "source": [ - "from pylabrobot.liquid_handling.liquid_classes.hamilton.star import HighVolumeFilter_Water_DispenseSurface_Part\n", - "await lh.aspirate(\n", - " plate[\"A1:C1\"],\n", - " vols=[100.0, 50.0, 200.0],\n", - " hamilton_liquid_classes=[HighVolumeFilter_Water_DispenseSurface_Part]*3,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "e83d3f2d", - "metadata": {}, - "source": [ - "## Using a different Hamilton liquid class for each channel" - ] - }, - { - "cell_type": "markdown", - "id": "07993140", - "metadata": {}, - "source": [ - "You can easily pass a list of different Hamilton liquid classes. They will correspond to the channels in the order they are specified." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "099e7ad1", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "C0ASid0004at0 0 0 0&tm1 1 1 0&xp04333 04333 04333 00000&yp1457 1367 1277 0000&th2450te2450lp2000 2000 2000 2000&ch000 000 000 000&zl1866 1866 1866 1866&po0100 0100 0100 0100&zu0032 0032 0032 0032&zr06180 06180 06180 06180&zx1866 1866 1866 1866&ip0000 0000 0000 0000&it0 0 0 0&fp0000 0000 0000 0000&av01083 00629 02000 01083&as2500 2500 2500 2500&ta000 050 000 000&ba0000 0000 0500 0000&oa000 000 000 000&lm0 0 0 0&ll1 1 1 1&lv1 1 1 1&zo000 000 000 000&ld00 00 00 00&de0020 0020 0020 0020&wt10 10 10 10&mv00000 00000 00000 00000&mc00 00 00 00&mp000 000 000 000&ms1200 2500 2500 1200&mh0000 0000 0000 0000&gi000 000 000 000&gj0gk0lk0 0 0 0&ik0000 0000 0000 0000&sd0500 0500 0500 0500&se0500 0500 0500 0500&sz0300 0300 0300 0300&io0000 0000 0000 0000&il00000 00000 00000 00000&in0000 0000 0000 0000&\n" - ] - } - ], - "source": [ - "from pylabrobot.liquid_handling.liquid_classes.hamilton.star import HighVolumeFilter_Water_DispenseSurface_Part, HighVolumeFilter_EtOH_DispenseJet, HighVolumeFilter_DMSO_AliquotDispenseJet_Part\n", - "\n", - "await lh.aspirate(\n", - " plate[\"A1:C1\"],\n", - " vols=[100.0, 50.0, 200.0],\n", - " hamilton_liquid_classes=[\n", - " HighVolumeFilter_Water_DispenseSurface_Part,\n", - " HighVolumeFilter_EtOH_DispenseJet,\n", - " HighVolumeFilter_DMSO_AliquotDispenseJet_Part,\n", - " ], \n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "9b640c6b", - "metadata": {}, - "source": [ - "## Using custom Hamilton liquid classes" - ] - }, - { - "cell_type": "markdown", - "id": "69e68119", - "metadata": {}, - "source": [ - "It is also possible to define your own Hamilton liquid classes. This is useful if you want to use a specific set of parameters that are not available in the predefined classes.\n", - "\n", - "The example below is based on the `HighVolumeFilter_Water_DispenseSurface_Part`, but you can easily modify the parameters to suit your needs." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d237bda2", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "C0ASid0005at0 0 0 0&tm1 1 1 0&xp04333 04333 04333 00000&yp1457 1367 1277 0000&th2450te2450lp2000 2000 2000 2000&ch000 000 000 000&zl1866 1866 1866 1866&po0100 0100 0100 0100&zu0032 0032 0032 0032&zr06180 06180 06180 06180&zx1866 1866 1866 1866&ip0000 0000 0000 0000&it0 0 0 0&fp0000 0000 0000 0000&av01083 00563 02110 01083&as2500 2500 2500 2500&ta000 000 000 000&ba0000 0000 0000 0000&oa000 000 000 000&lm0 0 0 0&ll1 1 1 1&lv1 1 1 1&zo000 000 000 000&ld00 00 00 00&de0020 0020 0020 0020&wt10 10 10 10&mv00000 00000 00000 00000&mc00 00 00 00&mp000 000 000 000&ms1200 1200 1200 1200&mh0000 0000 0000 0000&gi000 000 000 000&gj0gk0lk0 0 0 0&ik0000 0000 0000 0000&sd0500 0500 0500 0500&se0500 0500 0500 0500&sz0300 0300 0300 0300&io0000 0000 0000 0000&il00000 00000 00000 00000&in0000 0000 0000 0000&\n" - ] - } - ], - "source": [ - "from pylabrobot.liquid_handling.liquid_classes.hamilton import HamiltonLiquidClass\n", - "\n", - "my_custom_hamilton_liquid_class = HamiltonLiquidClass(\n", - " curve={\n", - " 500.0: 518.3,\n", - " 50.0: 56.3,\n", - " 0.0: 0.0,\n", - " 100.0: 108.3,\n", - " 20.0: 23.9,\n", - " 1000.0: 1028.5,\n", - " 200.0: 211.0,\n", - " 10.0: 12.7,\n", - " },\n", - " aspiration_flow_rate=250.0,\n", - " aspiration_mix_flow_rate=120.0,\n", - " aspiration_air_transport_volume=0.0,\n", - " aspiration_blow_out_volume=0.0,\n", - " aspiration_swap_speed=2.0,\n", - " aspiration_settling_time=1.0,\n", - " aspiration_over_aspirate_volume=5.0,\n", - " aspiration_clot_retract_height=0.0,\n", - " dispense_flow_rate=120.0,\n", - " dispense_mode=4.0,\n", - " dispense_mix_flow_rate=1.0,\n", - " dispense_air_transport_volume=30.0,\n", - " dispense_blow_out_volume=0.0,\n", - " dispense_swap_speed=2.0,\n", - " dispense_settling_time=1.0,\n", - " dispense_stop_flow_rate=5.0,\n", - " dispense_stop_back_volume=0.0,\n", - ")\n", - "\n", - "await lh.aspirate(\n", - " plate[\"A1:C1\"],\n", - " vols=[100.0, 50.0, 200.0],\n", - " hamilton_liquid_classes=[my_custom_hamilton_liquid_class]*3, \n", - ")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env (3.10.15)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/iswap-module.md b/docs/user_guide/00_liquid-handling/hamilton-star/iswap-module.md deleted file mode 100644 index 9562610409d..00000000000 --- a/docs/user_guide/00_liquid-handling/hamilton-star/iswap-module.md +++ /dev/null @@ -1,127 +0,0 @@ -# iSWAP Module - -The `R0` module allows fine grained control of the iSWAP gripper. - -## Common tasks - -- Parking - -You can park the iSWAP using {meth}`~pylabrobot.liquid_handling.backends.hamilton.STAR_backend.STARBackend.park_iswap`. - -```python -await star_backend.park_iswap() -``` - -- Opening gripper: - -You can open the iSWAP gripper using {meth}`~pylabrobot.liquid_handling.backends.hamilton.STAR_backend.STARBackend.iswap_open_gripper`. Warning: this will release any object that is gripped. Used for error recovery. - -```python -# opening all the way -await star_backend.iswap_open_gripper() -``` - -```python -# opening partially to 90mm -await star_backend.iswap_open_gripper(open_position=90) -``` - -- Closing gripper: note: this will throw an error if there is no object to grip. - -```python -await star_backend.iswap_close_gripper() -``` - -## Rotations - -You can rotate the iSWAP to 12 predefined positions using {meth}`~pylabrobot.liquid_handling.backends.hamilton.STAR_backend.STARBackend.iswap_rotate`. - -the positions and their corresponding integer specifications are shown visually here. - -![alt text](iswap_positions.png) - -The `iswap_rotate` method can be used to move the wrist drive and the rotation drive simultaneously in one smooth motion. It takes a parameter for the rotation drive, and the final `grip_direction` of the iswap. The `grip_direction` is the same parameter used by {meth}`~pylabrobot.liquid_handling.liquid_handler.LiquidHandler.pick_up_resource` and {meth}`~pylabrobot.liquid_handling.liquid_handler.LiquidHandler.drop_resource`, and is with respect to the deck. This is easier than controlling the rotation drive, which is with respect to the wrist drive. - -To extend the iSWAP fully to the left, call `iswap_rotate(rotation_drive=STARBackend.RotationDriveOrientation.LEFT, grip_direction=GripDirection.RIGHT)`. `GripDirection.RIGHT` means the gripper will be gripping the object from the right hand side meaning the gripper fingers will be pointing left wrt the deck. Compared this to `iswap_rotate(rotation_drive=STARBackend.RotationDriveOrientation.RIGHT, grip_direction=GripDirection.RIGHT)`, where the gripper fingers will still be pointing left wrt the deck, but the rotation drive is pointing right. This means the wrist drive is in "REVERSE" orientation (folded up). - -Moving the iswap between two positions with the same `grip_direction` while changing the rotation drive will keep the plate pointing in one direction. The internal motion planner on the STAR will automatically adjust the wrist drive to keep the plate in the same orientation. - -### Controlling the wrist and rotation drive individually - -You can also control the wrist (T-drive) and rotation drive (W-drive) individually using {meth}`~pylabrobot.liquid_handling.backends.hamilton.STAR_backend.STARBackend.rotate_iswap_wrist` and {meth}`~pylabrobot.liquid_handling.backends.hamilton.STAR_backend.STARBackend.rotate_iswap_rotation_drive` respectively. Make sure you have enough space (you can use {meth}`~pylabrobot.liquid_handling.backends.hamilton.STAR_backend.STARBackend.move_iswap_y_relative`) - -```python -rotation_drive = random.choice([STARBackend.RotationDriveOrientation.LEFT, STARBackend.RotationDriveOrientation.RIGHT, STARBackend.RotationDriveOrientation.FRONT]) -wrist_drive = random.choice([STARBackend.WristDriveOrientation.LEFT, STARBackend.WristDriveOrientation.RIGHT, STARBackend.WristDriveOrientation.STRAIGHT, STARBackend.WristDriveOrientation.REVERSE]) -await star_backend.rotate_iswap_rotation_drive(rotation_drive) -await star_backend.rotate_iswap_wrist(wrist_drive) -``` - -## Slow movement - -You can make the iswap move more slowly during sensitive operations using {meth}`~pylabrobot.liquid_handling.backends.hamilton.STAR_backend.STARBackend.slow_iswap`. This is useful when you want to avoid splashing or other disturbances. - -```python -async with star_backend.slow_iswap(): - await lh.move_plate(plate, plt_car[1]) -``` - -## Manual movement (teaching / calibration) - -1. For safety, move the other components as far away as possible before teaching. This is easily done using the firmware command `C0FY`, implemented in PLR as `position_components_for_free_iswap_y_range`: - -```python -await star_backend.position_components_for_free_iswap_y_range() -``` - -2. Move the iSWAP wrist and rotation drive to the correct orientation as [explained above](#rotations). Repeated: be careful to move the iSWAP to a position where it does not hit any other components. See commands below for how to do this. - -3. You can then use the following three commands to move the iSWAP in the X, Y and Z directions. All units are in mm. - -```python -await star_backend.move_iswap_x(x) -``` - -```python -await star_backend.move_iswap_y(y) -``` - -```python -await star_backend.move_iswap_z(z) -``` - -4. Note that the x, y and z here refer to the **center** of the iSWAP gripper. This is to make it agnostic to plate size. But in PLR all locations are with respect to LFB (left front bottom) of the plate. To get the LFB after calibrating to the center, subtract the distance from the plate LFB to CCB: - -```python -from pylabrobot.resources import Coordinate - -calibrated_position = Coordinate(x, y, z) -plate_lfb_absolute = calibrated_position - plate.get_anchor("c", "c", "b") -``` - -Then you get the plate's LFB position in absolute coordinates. The location of the plate will probably be defined wrt some other resource. To get the relative location of the plate wrt that parent resource, you have to subtract the absolute location of the parent from the absolute location of the plate: - -```python -parent_absolute = parent.get_location_wrt(deck) -plate_relative = plate_lfb_absolute - parent_absolute -``` - -This will be the location of the plate wrt the parent. You can use this with `parent.assign_child_resource(plate, location=plate_relative)` to assign the plate to the parent resource. - -### Relative movements - -You can also move the iSWAP relative to its current position using the following commands. All units are in mm. - -```python -await star_backend.move_iswap_x_relative(x) -``` - -```python -await star_backend.move_iswap_y_relative(y) -``` - -```python -await star_backend.move_iswap_z_relative(z) -``` - -This is the center of the iSWAP gripper. See the note above. diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/star_lld.md b/docs/user_guide/00_liquid-handling/hamilton-star/star_lld.md deleted file mode 100644 index 7aa38799ff9..00000000000 --- a/docs/user_guide/00_liquid-handling/hamilton-star/star_lld.md +++ /dev/null @@ -1,79 +0,0 @@ -# Liquid level detection on Hamilton STAR(let) - -Liquid level detection (LLD) is a feature that allows the Hamilton STAR(let) to move the pipetting tip down slowly until a liquid is found using either a) the pressure sensor, or b) a change in capacitance, or c) both. This feature is useful if you want to aspirate or dispense at a distance relative to the liquid surface, but you don't know the exact height of the liquid in the container. - -To use LLD, you need to specify the LLD mode when calling the `aspirate` or `dispense` methods. Here is how you can use pressure or capacative LLD with the `aspirate` : - -```python -await lh.aspirate([tube], vols=[300], lld_mode=[STARBackend.LLDMode.GAMMA]) -``` - -The `lld_mode` parameter can be one of the following: - -- `STARBackend.LLDMode.OFF`: default, no LLD -- `STARBackend.LLDMode.GAMMA`: capacative LLD (cLLD) -- `STARBackend.LLDMode.PRESSURE`: pressure LLD (pLLD) -- `STARBackend.LLDMode.DUAL`: both capacative and pressure LLD -- `STARBackend.LLDMode.Z_TOUCH_OFF`: find the bottom of the container - -The `lld_mode` parameter is a list, so you can specify a different LLD mode for each channel. - -```{note} -The `lld_mode` parameter is only available when using the `STAR` backend. -``` - -## Going into or out of the liquid - -You can use the `immersion_depth` backend kwarg to move the tip with respect to the found liquid surface. A positive value means to go deeper into the liquid, a negative value means to go above the liquid. - -Going 1mm below the liquid for aspiration: - -```python -await lh.aspirate( - [tube], - vols=[300], - lld_mode=[STARBackend.LLDMode.GAMMA], - immersion_depth=[1]) -``` - -Going 1mm above the liquid for dispens: - -```python -await lh.dispense( - [tube], - vols=[300], - lld_mode=[STARBackend.LLDMode.GAMMA], - immersion_depth=[-1]) -``` - -## Moving with liquid surface (liquid following) - -Through another backend kwarg, `surface_following_distance`, you can move with the liquid: - -```python -await lh.aspirate( - [tube], - vols=[300], - lld_mode=[STARBackend.LLDMode.GAMMA], - surface_following_distance=[10], # 10mm -) -``` - -## Catching errors - -All channelized pipetting operations raise a `ChannelizedError` exception when an error occurs, so that we can have specific error handling for each channel. - -When no liquid is found in the container, the channel will have a `TooLittleLiquidError` error. This is useful for detecting that your container is empty. - -You can catch the error like this: - -```python -from pylabrobot.liquid_handling.errors import ChannelizedError -from pylabrobot.resources.errors import TooLittleLiquidError -channel = 0 -try: - await lh.aspirate([tube], vols=[300], lld_mode=[STARBackend.LLDMode.GAMMA], use_channels=[channel]) -except ChannelizedError as e: - if isinstance(e.errors[channel], TooLittleLiquidError): - print("Too little liquid in tube") -``` diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/surface-following.ipynb b/docs/user_guide/00_liquid-handling/hamilton-star/surface-following.ipynb deleted file mode 100644 index a0c741f228b..00000000000 --- a/docs/user_guide/00_liquid-handling/hamilton-star/surface-following.ipynb +++ /dev/null @@ -1,203 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "c9fc088b", - "metadata": {}, - "source": [ - "# Surface following\n", - "\n", - "Surface following is a feature on Hamilton liquid handling robots that makes the pipette tip follow the surface of a liquid when aspirating (going down) or dispensing (going up).\n", - "\n", - "When using surface following, the robot will automatically move the Z position of the pipette tip the user-specified distance. The amount of surface following required can be computed by comparing the liquid level before and after each aspiration or dispense. PyLabRobot can do this automatically when the height<>volume functions for the given containers are defined. You can also specify the liquid surface following distance manually.\n", - "\n", - "It is useful to start the surface following only at the liquid level, so it is recommended to use [liquid level detection](./star_lld) with the surface following feature. (See below for syntax, which differs from the LLD tutorial). VENUS also supports surface following while doing LLD.\n", - "\n", - "In PLR, when we have LLD + automatic surface following, we can go beyond VENUS by computing the surface following amount based on the precise location of liquid inside the container. This is necessary because the surface following amount is not _just_ a function of the volume of liquid aspirated or dispensed, _but also_ of the location of liquid inside the container (see below). By doing liquid level detection first to get the precise liquid level, we can then use that liquid level height to compute the surface following amount based on the requested volume _and_ location of liquid inside the container.\n", - "\n", - "![](./img/surface_following/surface_following_distance.svg)" - ] - }, - { - "cell_type": "markdown", - "id": "c2ab15af", - "metadata": {}, - "source": [ - "## Dummy setup" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "d3f86a7d", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.liquid_handling import LiquidHandler\n", - "from pylabrobot.liquid_handling.backends.hamilton.STAR_backend import STARBackend\n", - "from pylabrobot.resources.hamilton import STARDeck\n", - "\n", - "backend = STARBackend()\n", - "lh = LiquidHandler(backend=backend, deck=STARDeck())\n", - "\n", - "await lh.setup()\n", - "\n", - "from pylabrobot.resources import TIP_CAR_480_A00, hamilton_96_tiprack_1000uL_filter\n", - "tip_car = TIP_CAR_480_A00(\"tip_car\")\n", - "tip_car[0] = tr0 = hamilton_96_tiprack_1000uL_filter(\"tr0\")\n", - "lh.deck.assign_child_resource(tip_car, rails=2)\n", - "\n", - "from pylabrobot.resources import PLT_CAR_L5AC_A00, CellTreat_96_wellplate_350ul_Ub\n", - "plt_car = PLT_CAR_L5AC_A00(\"plt_car\")\n", - "plt_car[0] = plate = CellTreat_96_wellplate_350ul_Ub(\"plate\")\n", - "lh.deck.assign_child_resource(plt_car, rails=14)" - ] - }, - { - "cell_type": "markdown", - "id": "4645e4f2", - "metadata": {}, - "source": [ - "## Automatic surface following\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "5e9d4e1a", - "metadata": {}, - "outputs": [], - "source": [ - "wells = plate[\"A1:H1\"]\n", - "vols = [50] * len(wells)" - ] - }, - { - "cell_type": "markdown", - "id": "aebdc554", - "metadata": {}, - "source": [ - "You can probe the liquid height first using liquid level detection (capacitive), and then use automatic surface following for subsequent aspirations and dispenses as follows:" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "d4858585", - "metadata": {}, - "outputs": [], - "source": [ - "async with lh.use_tips(tr0[\"A1:H1\"], discard=False):\n", - " await lh.aspirate(\n", - " wells,\n", - " vols,\n", - "\n", - " # Probe the liquid height before aspirating.\n", - " probe_liquid_height=True,\n", - "\n", - " # Automatically adjust the following distance based on the probed liquid height.\n", - " auto_surface_following_distance=True,\n", - " )\n", - "\n", - " await lh.dispense(\n", - " wells,\n", - " vols,\n", - " probe_liquid_height=True,\n", - " auto_surface_following_distance=True,\n", - " )" - ] - }, - { - "cell_type": "markdown", - "id": "0b3ac98c", - "metadata": {}, - "source": [ - "You can also pass the liquid height directly to the aspiration and dispense methods, and still use automatic surface following. This can be useful when you cannot use LLD." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "ffde0c4c", - "metadata": {}, - "outputs": [], - "source": [ - "async with lh.use_tips(tr0[\"A1:H1\"], discard=False):\n", - " await lh.aspirate(\n", - " wells,\n", - " vols,\n", - " liquid_height=[10] * len(wells), # in mm above the bottom of the well\n", - " auto_surface_following_distance=True,\n", - " )\n", - "\n", - " await lh.dispense(\n", - " wells,\n", - " vols,\n", - " liquid_height=[10] * len(wells), # in mm above the bottom of the well\n", - " auto_surface_following_distance=True,\n", - " )" - ] - }, - { - "cell_type": "markdown", - "id": "3f58b88c", - "metadata": {}, - "source": [ - "## Manual surface following" - ] - }, - { - "cell_type": "markdown", - "id": "aee70228", - "metadata": {}, - "source": [ - "To manually specify the surface following amount, you can use the `surface_following_distance` backend kwarg of the aspiration and dispense methods. For example, to aspirate 100 µL with a surface following amount of 2 mm starting at the detected liquid level:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e3e205b7", - "metadata": {}, - "outputs": [], - "source": [ - "async with lh.use_tips(tr0[\"A1:H1\"], discard=False):\n", - " await lh.aspirate(\n", - " wells,\n", - " vols,\n", - " probe_liquid_height=True,\n", - " surface_following_distance=[2] * len(wells), # mm down after finding liquid\n", - " )\n", - "\n", - " await lh.dispense(\n", - " wells,\n", - " vols,\n", - " probe_liquid_height=True,\n", - " surface_following_distance=[2] * len(wells), # mm up after finding liquid\n", - " )" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/y-probing.ipynb b/docs/user_guide/00_liquid-handling/hamilton-star/y-probing.ipynb deleted file mode 100644 index ec24c0aa731..00000000000 --- a/docs/user_guide/00_liquid-handling/hamilton-star/y-probing.ipynb +++ /dev/null @@ -1,150 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Y-probing\n", - "\n", - "With PyLabRobot, you can probe the y position of any object on a STAR(let) deck. See also [z probing](./z-probing) for doing the same in the z direction.\n", - "\n", - "```{warning}\n", - "This example uses the teaching tips. These are metal tips that are not forgiving. Be particularly careful when moving the channels around to avoid collisions.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Example setup" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.liquid_handling import LiquidHandler, STARBackend\n", - "from pylabrobot.resources import STARDeck # or STARletDeck\n", - "\n", - "star = STARBackend()\n", - "lh = LiquidHandler(backend=star, deck=STARDeck())\n", - "await lh.setup()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Capacitive y-probing using cLLD\n", - "\n", - "If you are mapping a capacitive surface, you can use the cLLD sensor to detect the surface.\n", - "\n", - "```{warning}\n", - "This example uses the teaching tips. These are metal tips that are not forgiving. Be particularly careful when moving the channels around to avoid collisions.\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "teaching_tip = lh.deck.get_resource(\"teaching_tip_rack\")[\"A1\"]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.pick_up_tips(teaching_tip)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await star.prepare_for_manual_channel_operation(0)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# TODO: change this to a position that works for you\n", - "await star.move_channel_x(0, 500)\n", - "await star.move_channel_y(0, 300)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Use `STARBackend.clld_probe_y_position_using_channel` to probe the y-position of a single point at the current xz plane. This function will slowly move the channel until the liquid level sensor detects a change in capacitance. The y-point of the point of the tip is then returned." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await star.clld_probe_y_position_using_channel(\n", - " channel_idx=0,\n", - " probing_direction=\"forward\",\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await star.clld_probe_y_position_using_channel(\n", - " channel_idx=0,\n", - " probing_direction=\"backward\",\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.return_tips()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/z-probing.ipynb b/docs/user_guide/00_liquid-handling/hamilton-star/z-probing.ipynb deleted file mode 100644 index cc4dc2244dd..00000000000 --- a/docs/user_guide/00_liquid-handling/hamilton-star/z-probing.ipynb +++ /dev/null @@ -1,422 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Z-probing\n", - "\n", - "With PyLabRobot, one can probe the surface of any object on a STAR(let) deck. This effectively makes the STAR act as a [Coordinate-Measurement Machine (CMM)](https://en.wikipedia.org/wiki/Coordinate-measuring_machine). See also [y probing](./y-probing) for doing the same in the y direction.\n", - "\n", - "There are two ways to probe the surface of an object:\n", - "\n", - "- Using capacitive liquid level sensors (cLLD) to map capacitive objects.\n", - "- Moving the tip down onto an object until resistance is detected (a \"controlled crash\"), which works with both capacitive and non-capacitive objects." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Example setup" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.liquid_handling import LiquidHandler, STARBackend\n", - "from pylabrobot.resources import STARLetDeck\n", - "from pylabrobot.resources import (\n", - " TIP_CAR_480_A00,\n", - " PLT_CAR_L5AC_A00,\n", - " hamilton_96_tiprack_50uL,\n", - " Cor_96_wellplate_360ul_Fb\n", - ")\n", - "\n", - "star = STARBackend()\n", - "lh = LiquidHandler(backend=star, deck=STARLetDeck())\n", - "await lh.setup()\n", - "\n", - "# assign a tip rack\n", - "tip_carrier = TIP_CAR_480_A00(name=\"tip_carrier\")\n", - "tip_carrier[1] = tip_rack = hamilton_96_tiprack_50uL(name=\"tip_rack\")\n", - "lh.deck.assign_child_resource(tip_carrier, rails=1)\n", - "\n", - "# assign a plate\n", - "plt_carrier = PLT_CAR_L5AC_A00(name=\"plt_carrier\")\n", - "plt_carrier[0] = plate = Cor_96_wellplate_360ul_Fb(name=\"plt\")\n", - "lh.deck.assign_child_resource(plt_carrier, rails=7)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Capacitive probing using cLLD\n", - "\n", - "If you are mapping a capacitive surface, you can use the cLLD sensor to detect the surface. This is safer and more accurate than the controlled crash method.\n", - "\n", - "```{warning}\n", - "For safety purposes, we recommend using Hamilton 50ul tips for mapping surfaces. These are relatively long and soft, acting as 'cushions' in case you try out faster detection speeds (not recommended). Small bends are tolerated well by the 50ul tips.\n", - "```\n", - "\n", - "Introduced in [PR #69](https://github.com/PyLabRobot/pylabrobot/pull/69)." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Mapping a single point" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.pick_up_tips(tip_rack[\"A1\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For more information on manually moving channels, see [Manually moving channels around](../moving-channels-around.ipynb)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await star.prepare_for_manual_channel_operation(0)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# TODO: change this to a position that works for you\n", - "await star.move_channel_x(0, 260)\n", - "await star.move_channel_y(0, 190)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Use `STARBackend.probe_z_height_using_channel` to probe the z-height of a single point at the current location. This function will slowly lower the channel until the liquid level sensor detects a change in capacitance. The z-height of the point of the tip is then returned." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await star.clld_probe_z_height_using_channel(0, move_channels_to_safe_pos_after=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.return_tips()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "(mapping-a-3d-surface)=\n", - "### Mapping a 3D surface" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.pick_up_tips(tip_rack[\"A1\"])\n", - "await star.prepare_for_manual_channel_operation(0)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "xs = [260 + i * 3 for i in range(13)] # in mm, absolute coordinates\n", - "ys = [190 + i * 3 for i in range(10)] # in mm, absolute coordinates\n", - "\n", - "data = []\n", - "\n", - "for x in xs:\n", - " await star.move_channel_x(0, x)\n", - " for y in ys:\n", - " await star.move_channel_y(0, y)\n", - " height = await star.clld_probe_z_height_using_channel(0, start_pos_search=25000)\n", - " data.append((x, y, height))\n", - " await lh.move_channel_z(0, 230) # move up slightly for traversal" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.return_tips()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Plotting requires `matplotlib` and `numpy`. If you don't have them installed, you can install them with `pip install matplotlib numpy`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "\n", - "data = np.array(data)\n", - "fig = plt.figure()\n", - "ax = fig.add_subplot(111, projection='3d')\n", - "ax.scatter(data[:, 0], data[:, 1], data[:, 2])\n", - "ax.set_xlabel('X')\n", - "ax.set_ylabel('Y')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Check out the following video demo of mapping a 3D surface:\n", - "\n", - "" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Non-capacitive probing\n", - "\n", - "There are two ways to probe non-capacitive surfaces:\n", - "\n", - "- using the pLLD sensor\n", - "- using the force sensor (a \"controlled crash\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Using the force sensor\n", - "\n", - "This uses moves a tip down slowly until resistance is detected (a \"controlled crash\"), to measure the surface z-height. This technique is similar to what is routinely used for discarding tips into the trash in both VENUS and PLR.\n", - "\n", - "Sensor accuracy for z-height readings needs to be further tested but in initial tests has been at least 0.2 mm." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Picking up teaching needles\n", - "\n", - "Most STAR(let)s come with a teaching block that includes 8 teaching needles. These needles are equivalent to standard volume (300uL) pipette tips but are made from metal instead of plastic. This leads to more accurate results when probing surfaces.\n", - "\n", - "```{warning}\n", - "When using the teaching needles, be careful not to damage the STAR(let) deck or channels. The needles are made from metal and can bend the pipetting channels more easily than soft plastic tips.\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "teaching_tip_rack = lh.deck.get_resource(\"teaching_tip_rack\")\n", - "await lh.pick_up_tips(teaching_tip_rack[\"A2\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Alternatively, you can use plastic tips for probing surfaces. However, these are softer and therefore less accurate than the metal teaching needles. 50uL tips are the softest and cannot be used with force z-probing." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Moving the channel and mapping a point\n", - "\n", - "See above for more information on moving channels.\n", - "\n", - "```{warning}\n", - "Make sure the tip is at a safe height above the labware before moving the channel. Use `STARBackend.move_channel_z` to move the channel to a safe height.\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await star.prepare_for_manual_channel_operation(0)\n", - "await star.move_channel_x(0, 260)\n", - "await star.move_channel_y(0, 190)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await star.ztouch_probe_z_height_using_channel(\n", - " channel_idx=0,\n", - " move_channels_to_save_pos_after=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.return_tips()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Check out [mapping a 3d surface](#mapping-a-3d-surface) for more information on mapping a surface." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Using pLLD\n", - "\n", - "The pLLD sensor can also be used to probe non-capacitive surfaces. This is done by slowly lowering the tip until a change in pressure is detected by the pLLD sensor.\n", - "\n", - "Do not use teaching needles for pLLD probing!" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.pick_up_tips(tip_rack[\"A1\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Moving the channel and mapping a point\n", - "\n", - "See above for more information on moving channels.\n", - "\n", - "```{warning}\n", - "Make sure the tip is at a safe height above the labware before moving the channel. Use `STARBackend.move_channel_z` to move the channel to a safe height.\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await star.prepare_for_manual_channel_operation(0)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# TODO: change this to a position that works for you\n", - "await star.move_channel_x(0, 260)\n", - "await star.move_channel_y(0, 190)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Probing:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await star.plld_probe_z_height_using_channel(0, move_channels_to_safe_pos_after=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.return_tips()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/user_guide/00_liquid-handling/hamilton-vantage/_hamilton-vantage.rst b/docs/user_guide/00_liquid-handling/hamilton-vantage/_hamilton-vantage.rst deleted file mode 100644 index 2f1f1464c26..00000000000 --- a/docs/user_guide/00_liquid-handling/hamilton-vantage/_hamilton-vantage.rst +++ /dev/null @@ -1,4 +0,0 @@ -Hamilton Vantage -================ - -Documentation coming soon. diff --git a/docs/user_guide/00_liquid-handling/opentrons/ot2/hello-world.ipynb b/docs/user_guide/00_liquid-handling/opentrons/ot2/hello-world.ipynb deleted file mode 100644 index 638dcd7b325..00000000000 --- a/docs/user_guide/00_liquid-handling/opentrons/ot2/hello-world.ipynb +++ /dev/null @@ -1,286 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "d05c1147", - "metadata": {}, - "source": "# OT-2 Hello World\n\nThis notebook shows how to use the Opentrons OT-2 with PyLabRobot.\n\n```{note}\nThe 8 channel pipette is not yet supported.\n```\n\n```{tip}\nDon't have an OT-2? Use the [OT-2 Simulator](ot2-simulator.ipynb) to test protocols offline with the visualizer.\n```" - }, - { - "cell_type": "markdown", - "id": "69a595f9", - "metadata": {}, - "source": [ - "## Connecting" - ] - }, - { - "cell_type": "markdown", - "id": "95c0f738", - "metadata": {}, - "source": [ - "There are different ways to find the robot IP address.\n", - "- the OT vendor app.\n", - "- using its hostname, e.g. `ot2.local`.\n", - "- `arp -a` command in terminal to list all devices in the network.\n", - "- checking the connected devices in your router admin panel.\n", - "- ..." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "236c2618", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.liquid_handling import LiquidHandler, OpentronsOT2Backend\n", - "from pylabrobot.resources import OTDeck\n", - "deck = OTDeck()\n", - "robot_ip = \"192.168.0.61\"\n", - "ot2_backend = OpentronsOT2Backend(host=robot_ip)\n", - "lh = LiquidHandler(backend=ot2_backend, deck=deck)" - ] - }, - { - "cell_type": "markdown", - "id": "a84ddd36", - "metadata": {}, - "source": [ - "You can set the traversal height for the robot as follows. This is the height above the deck at which tips will move." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "aed97f4b", - "metadata": {}, - "outputs": [], - "source": [ - "ot2_backend.traversal_height = 120" - ] - }, - { - "cell_type": "markdown", - "id": "3bdd7aee", - "metadata": {}, - "source": [ - "The robot will automatically home on `setup`, unless `skip_home=True` is passed." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "d76a35bc", - "metadata": {}, - "outputs": [], - "source": [ - "await lh.setup(skip_home=False)" - ] - }, - { - "cell_type": "markdown", - "id": "3788cd77", - "metadata": {}, - "source": [ - "Homing manually:" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "e15e0897", - "metadata": {}, - "outputs": [], - "source": [ - "await ot2_backend.home()" - ] - }, - { - "cell_type": "markdown", - "id": "04354871", - "metadata": {}, - "source": [ - "## Deck setup" - ] - }, - { - "cell_type": "markdown", - "id": "92a83aa4", - "metadata": {}, - "source": [ - "Loading a tip rack from the OT reosurce library. This requires a network connection the first time to download the labware definition. After that it is cached locally." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "de328307", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources.opentrons.load import load_ot_tip_rack\n", - "tr20uL = load_ot_tip_rack(\n", - " ot_name=\"opentrons_96_filtertiprack_20ul\", # https://labware.opentrons.com\n", - " plr_resource_name=\"20uL Tip Rack 1\" # name used in Pylabrobot, arbitrary\n", - ")\n", - "deck.assign_child_at_slot(tr20uL, slot=9)" - ] - }, - { - "cell_type": "markdown", - "id": "352955c5", - "metadata": {}, - "source": [ - "Plates are loaded from [the PLR resource library](../../../../resources/index.md) as usual." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "13beeec5", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import agilent_96_wellplate_150uL_Ub\n", - "plate = agilent_96_wellplate_150uL_Ub(name=\"premixplate\")\n", - "deck.assign_child_at_slot(plate, slot=6)" - ] - }, - { - "cell_type": "markdown", - "id": "e44fbcb8", - "metadata": {}, - "source": [ - "You can also import tube racks from the OT resource library and use them with normal PLR tubes:" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "bdacf9e1", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources.opentrons.load import load_ot_tube_rack\n", - "from pylabrobot.resources import Eppendorf_DNA_LoBind_1_5ml_Vb\n", - "tube_rack = load_ot_tube_rack(\n", - " ot_name=\"opentrons_24_aluminumblock_nest_1.5ml_snapcap\",\n", - " plr_resource_name=\"tube_rack\"\n", - ")\n", - "tube_rack[0] = tube = Eppendorf_DNA_LoBind_1_5ml_Vb(name=\"tube_01\")\n", - "deck.assign_child_at_slot(tube_rack, slot=4)" - ] - }, - { - "cell_type": "markdown", - "id": "2f2d968c", - "metadata": {}, - "source": [ - "## Liquid handling" - ] - }, - { - "cell_type": "markdown", - "id": "8044bf5c", - "metadata": {}, - "source": [ - "OT2 robots often get out of calibration or do not calibrate to the deck correctly.\n", - "\n", - "You can deal with this by passing offsets to specific operations. The offsets are in mm. Below shows 0,0,0 but you might need a mm to make operations work on your robot. Be careful that the offsets will drift over time." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "a09f7071", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources.coordinate import Coordinate\n", - "tip_offset = Coordinate(x=0, y=0, z=0)\n", - "await lh.pick_up_tips(tr20uL[95], offsets=[tip_offset])" - ] - }, - { - "cell_type": "markdown", - "id": "933d2326", - "metadata": {}, - "source": [ - "Moving liquid from a plate to a tube:" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "4e5726a1-5dd3-4d51-ad24-6d6a255ec37c", - "metadata": {}, - "outputs": [], - "source": [ - "plate_offset = Coordinate(x=0, y=0, z=0)\n", - "await lh.aspirate(plate[0], vols=[20], offsets=[plate_offset])\n", - "tube_offset = Coordinate(x=0, y=0, z=0)\n", - "await lh.dispense([tube], vols=[20], offsets=[tube_offset])" - ] - }, - { - "cell_type": "markdown", - "id": "c0f1057b", - "metadata": {}, - "source": [ - "You can put the tips in the trash after use using `await lh.discard_tips()`." - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "e299ba23", - "metadata": {}, - "outputs": [], - "source": [ - "trash_tip = False\n", - "if trash_tip:\n", - " await lh.discard_tips()" - ] - }, - { - "cell_type": "markdown", - "id": "8bb37049", - "metadata": {}, - "source": [ - "Or return them to the pickup location using `await lh.return_tips()`." - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "21ec44a0", - "metadata": {}, - "outputs": [], - "source": [ - "await lh.return_tips(offsets=[tip_offset])" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.24" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/00_liquid-handling/opentrons/ot2/ot2-simulator.ipynb b/docs/user_guide/00_liquid-handling/opentrons/ot2/ot2-simulator.ipynb deleted file mode 100644 index 5f85036db56..00000000000 --- a/docs/user_guide/00_liquid-handling/opentrons/ot2/ot2-simulator.ipynb +++ /dev/null @@ -1,314 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "8yvxm6cktvk", - "metadata": {}, - "source": "# OT-2 Simulator with Visualizer\n\nThis notebook shows how to use the `OpentronsOT2Simulator` backend to test OT-2 protocols offline, with the visualizer for real-time feedback.\n\nSee [the OT-2 hello world notebook](hello-world.ipynb) for using the real hardware." - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "qml6wch61b", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.liquid_handling import LiquidHandler\n", - "from pylabrobot.liquid_handling.backends import OpentronsOT2Simulator\n", - "from pylabrobot.resources.opentrons import (\n", - " OTDeck,\n", - " opentrons_96_filtertiprack_20ul,\n", - " opentrons_96_tiprack_300ul,\n", - ")\n", - "from pylabrobot.resources.celltreat import CellTreat_96_wellplate_350ul_Fb\n", - "from pylabrobot.resources import set_tip_tracking, set_volume_tracking\n", - "from pylabrobot.visualizer import Visualizer" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "08rlkxblsvgs", - "metadata": {}, - "outputs": [], - "source": [ - "set_tip_tracking(True)\n", - "set_volume_tracking(True)" - ] - }, - { - "cell_type": "markdown", - "id": "09gvm1i32tfj", - "metadata": {}, - "source": [ - "## Set up the simulator and visualizer\n", - "\n", - "The simulator takes optional pipette names. Defaults are `p300_single_gen2` (left) and `p20_single_gen2` (right)." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "ij4yhnx4zqq", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-03-12 18:32:17,220 - pylabrobot.liquid_handling.backends.opentrons_simulator - INFO - OpentronsOT2Simulator setup: left=p300_single_gen2, right=p20_single_gen2\n", - "2026-03-12 18:32:17,220 - pylabrobot.liquid_handling.backends.opentrons_simulator - INFO - Homing (simulated).\n" - ] - } - ], - "source": [ - "backend = OpentronsOT2Simulator()\n", - "deck = OTDeck()\n", - "lh = LiquidHandler(backend=backend, deck=deck)\n", - "await lh.setup()" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "h25lkq3p6m4", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Websocket server started at http://127.0.0.1:2121\n", - "File server started at http://127.0.0.1:1337 . Open this URL in your browser.\n" - ] - } - ], - "source": [ - "vis = Visualizer(resource=lh)\n", - "await vis.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "iwvn83iixz", - "metadata": {}, - "source": [ - "## Add labware to the deck\n", - "\n", - "Assign tip racks and a plate. These will appear in the visualizer automatically." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "2jblokus51v", - "metadata": {}, - "outputs": [], - "source": [ - "tip_rack_20 = opentrons_96_filtertiprack_20ul(name=\"tip_rack_20\")\n", - "deck.assign_child_at_slot(tip_rack_20, slot=1)\n", - "\n", - "tip_rack_300 = opentrons_96_tiprack_300ul(name=\"tip_rack_300\")\n", - "deck.assign_child_at_slot(tip_rack_300, slot=2)\n", - "\n", - "plate = CellTreat_96_wellplate_350ul_Fb(name=\"plate\")\n", - "deck.assign_child_at_slot(plate, slot=6)" - ] - }, - { - "cell_type": "markdown", - "id": "k3wy4vevi4", - "metadata": {}, - "source": [ - "## Run a simple protocol\n", - "\n", - "Pick up a 20 uL tip (right pipette = channel 1), aspirate from well A1, dispense into well B1, and return the tip." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "vetyk6lxwv", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-03-12 18:32:17,371 - pylabrobot.liquid_handling.backends.opentrons_simulator - INFO - Picked up tip from tip_rack_20_A1 with pipette sim-right\n" - ] - } - ], - "source": [ - "await lh.pick_up_tips(tip_rack_20[\"A1\"], use_channels=[1])" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "t8p5sechijb", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-03-12 18:32:17,377 - pylabrobot.liquid_handling.backends.opentrons_simulator - INFO - Aspirated 15.00 µL from plate_well_A1\n" - ] - } - ], - "source": [ - "plate.get_well(\"A1\").tracker.set_volume(200)\n", - "await lh.aspirate(plate[\"A1\"], vols=[15], use_channels=[1])" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "gsplfidciro", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-03-12 18:32:17,384 - pylabrobot.liquid_handling.backends.opentrons_simulator - INFO - Dispensed 15.00 µL to plate_well_B1\n" - ] - } - ], - "source": [ - "await lh.dispense(plate[\"B1\"], vols=[15], use_channels=[1])" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "yhq53lilekm", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-03-12 18:32:17,390 - pylabrobot.liquid_handling.backends.opentrons_simulator - INFO - Dropped tip to tip_rack_20_A1 with pipette sim-right\n" - ] - } - ], - "source": [ - "await lh.return_tips()" - ] - }, - { - "cell_type": "markdown", - "id": "motyoooioj", - "metadata": {}, - "source": [ - "Now try the left pipette (p300) with the 300 uL tip rack." - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "87wafam374a", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-03-12 18:32:17,395 - pylabrobot.liquid_handling.backends.opentrons_simulator - INFO - Picked up tip from tip_rack_300_A1 with pipette sim-left\n" - ] - } - ], - "source": [ - "await lh.pick_up_tips(tip_rack_300[\"A1\"])" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "ddprpyr2o8q", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-03-12 18:32:17,402 - pylabrobot.liquid_handling.backends.opentrons_simulator - INFO - Aspirated 100.00 µL from plate_well_A1\n", - "2026-03-12 18:32:17,404 - pylabrobot.liquid_handling.backends.opentrons_simulator - INFO - Dispensed 100.00 µL to plate_well_C1\n" - ] - } - ], - "source": [ - "await lh.aspirate(plate[\"A1\"], vols=[100])\n", - "await lh.dispense(plate[\"C1\"], vols=[100])" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "lgz8dc8jv3n", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-03-12 18:32:17,417 - pylabrobot.liquid_handling.backends.opentrons_simulator - INFO - Dropped tip to tip_rack_300_A1 with pipette sim-left\n" - ] - } - ], - "source": [ - "await lh.return_tips()" - ] - }, - { - "cell_type": "markdown", - "id": "px3gkxjx3kr", - "metadata": {}, - "source": [ - "## Clean up" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "qt1yijht0z", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-03-12 18:32:17,442 - pylabrobot.liquid_handling.backends.opentrons_simulator - INFO - OpentronsOT2Simulator stopped.\n" - ] - } - ], - "source": [ - "await vis.stop()\n", - "await lh.stop()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file diff --git a/docs/user_guide/00_liquid-handling/opentrons/ot2/ot2.rst b/docs/user_guide/00_liquid-handling/opentrons/ot2/ot2.rst deleted file mode 100644 index 2054901effc..00000000000 --- a/docs/user_guide/00_liquid-handling/opentrons/ot2/ot2.rst +++ /dev/null @@ -1,12 +0,0 @@ -Opentrons OT-2 -============== - -PyLabRobot supports the `Opentrons OT-2 `_ liquid handler. Single-channel pipettes (GEN1 and GEN2) on left and right mounts are supported. - -Supported pipettes: ``p10_single``, ``p20_single_gen2``, ``p50_single``, ``p300_single``, ``p300_single_gen2``, ``p1000_single``, ``p1000_single_gen2``. - -.. toctree:: - :maxdepth: 1 - - hello-world - ot2-simulator diff --git a/docs/user_guide/00_liquid-handling/plate-washing/biotek-el406.ipynb b/docs/user_guide/00_liquid-handling/plate-washing/biotek-el406.ipynb deleted file mode 100644 index f9b377006fd..00000000000 --- a/docs/user_guide/00_liquid-handling/plate-washing/biotek-el406.ipynb +++ /dev/null @@ -1,298 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# BioTek EL406\n", - "\n", - "The BioTek EL406 plate washer is controlled by the {class}`~pylabrobot.plate_washing.biotek.el406.BioTekEL406Backend` class. It communicates via FTDI USB serial.\n", - "\n", - "The EL406 has three fluid delivery subsystems — manifold, syringe pump, and peristaltic pump — plus an integrated plate shaker. All operations require a {class}`~pylabrobot.resources.Plate` object to configure plate-specific parameters automatically." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Setup" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend\n", - "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", - "\n", - "backend = ExperimentalBioTekEL406Backend() # ExperimentalBioTekEL406Backend(device_id=\"YOUR_FTDI_ID_HERE\")\n", - "await backend.setup()\n", - "\n", - "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Manifold\n", - "\n", - "The wash manifold is the primary fluid system. Prime the lines before use to fill tubing with buffer." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.manifold_prime(plate, volume=10000, buffer=\"A\") # 10 mL" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Dispense and aspirate individually, or use `manifold_wash` for repeated dispense-aspirate cycles." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.manifold_dispense(plate, volume=200, buffer=\"A\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.manifold_aspirate(plate)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.manifold_wash(plate, cycles=3, buffer=\"A\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "`manifold_wash` supports many options: shake/soak between cycles, secondary aspirate, bottom wash, and per-cycle pre-dispense." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.manifold_wash(\n", - " plate,\n", - " cycles=3,\n", - " buffer=\"A\",\n", - " dispense_volume=300,\n", - " soak_duration=10,\n", - " shake_duration=5,\n", - " shake_intensity=\"Medium\",\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Run an auto-clean cycle to flush the manifold lines." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.manifold_auto_clean(plate, buffer=\"A\", duration=60)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Syringe pump\n", - "\n", - "The syringe pump provides precise low-volume dispensing." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.syringe_prime(plate, syringe=\"A\", volume=5000, flow_rate=5, refills=2)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.syringe_dispense(plate, volume=50, syringe=\"A\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Peristaltic pump" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.peristaltic_prime(plate, volume=300, flow_rate=\"High\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.peristaltic_dispense(plate, volume=100, flow_rate=\"High\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.peristaltic_purge(plate, volume=300, flow_rate=\"High\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Shaking" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.shake(plate, duration=30, intensity=\"Medium\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Batching\n", - "\n", - "Each step command automatically starts and cleans up a batch. To group multiple steps into a single batch (avoiding repeated start/cleanup overhead), use the `batch()` context manager." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "async with backend.batch(plate):\n", - " await backend.manifold_prime(plate, volume=10000, buffer=\"A\")\n", - " await backend.manifold_wash(plate, cycles=3, buffer=\"A\")\n", - " await backend.manifold_aspirate(plate)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Queries" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.request_serial_number()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.request_washer_manifold()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.request_instrument_settings()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Teardown" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.stop()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.25" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/00_liquid-handling/plate-washing/plate-washing.md b/docs/user_guide/00_liquid-handling/plate-washing/plate-washing.md deleted file mode 100644 index 08b07f905ff..00000000000 --- a/docs/user_guide/00_liquid-handling/plate-washing/plate-washing.md +++ /dev/null @@ -1,14 +0,0 @@ -# Plate Washing - -Plate washers automate the process of dispensing and aspirating wash buffers from microplates. They are commonly used in ELISA, cell-based assays, and other workflows that require repeated wash cycles. - -## Supported Plate Washers - -- BioTek EL406 - -```{toctree} -:maxdepth: 1 -:hidden: - -BioTek EL406 -``` diff --git a/docs/user_guide/00_liquid-handling/pumps/_pumps.rst b/docs/user_guide/00_liquid-handling/pumps/_pumps.rst deleted file mode 100644 index e41f6ed4136..00000000000 --- a/docs/user_guide/00_liquid-handling/pumps/_pumps.rst +++ /dev/null @@ -1,9 +0,0 @@ -Pumps -===== - -PLR supports the following pumps: - -.. toctree:: - :maxdepth: 1 - - cole-parmer-masterflex diff --git a/docs/user_guide/00_liquid-handling/pumps/cole-parmer-masterflex.md b/docs/user_guide/00_liquid-handling/pumps/cole-parmer-masterflex.md deleted file mode 100644 index 00f0f4c6621..00000000000 --- a/docs/user_guide/00_liquid-handling/pumps/cole-parmer-masterflex.md +++ /dev/null @@ -1,55 +0,0 @@ -# Cole Parmer Masterflex - -## Installation - -```bash -pip install pylabrobot[serial] -``` - -PyLabRobot supports the following pumps: - -- {ref}`Cole Parmer Masterflex ` - -## Introduction - -Pumps are controlled by the {class}`~pylabrobot.pumps.pump.Pump` class. These take a backend as an argument. The backend is responsible for communicating with the pump and is specific to the hardware being used. - -```python -from pylabrobot.pumps import Pump -backend = SomePumpBackend() -p = Pump(backend=backend) -await p.setup() -``` - -The {meth}`~pylabrobot.pumps.pump.Pump.setup` method is used to initialize the pump. This is where the backend will connect to the pump and perform any necessary initialization. - -The {class}`~pylabrobot.pumps.pump.Pump` class has a number of methods for controlling the pump. These are: - -- {meth}`~pylabrobot.pumps.pump.Pump.run_continuously`: Run the pump continuously at a given speed. - -- {meth}`~pylabrobot.pumps.pump.Pump.run_revolutions`: Run the pump for a given number of revolutions. - -- {meth}`~pylabrobot.pumps.pump.Pump.halt`: Stop the pump immediately. - -Run the pump for 5 seconds at 100 RPM: - -```python -await p.run_continuously(speed=100) -await asyncio.sleep(5) -await p.halt() -``` - -(masterflex)= - -## Cole Parmer Masterflex - -The Masterflex pump is controlled by the {class}`~pylabrobot.pumps.cole_parmer.masterflex_backend.MasterflexBackend` class. This takes a serial port as an argument. The serial port is used to communicate with the pump. - -```python -from pylabrobot.pumps.cole_parmer.masterflex import MasterflexBackend -m = MasterflexBackend(com_port='/dev/cu.usbmodemDEMO000000001') -``` - -(I have tried on the L/S 07551-20, but it should work on other models as well.) - -Documentation available at: [https://web.archive.org/web/20210924061132/https://pim-resources.coleparmer.com/instruction-manual/a-1299-1127b-en.pdf](https://web.archive.org/web/20210924061132/https://pim-resources.coleparmer.com/instruction-manual/a-1299-1127b-en.pdf) diff --git a/docs/user_guide/00_liquid-handling/tecan-evo/_tecan-evo.rst b/docs/user_guide/00_liquid-handling/tecan-evo/_tecan-evo.rst deleted file mode 100644 index cf75c197515..00000000000 --- a/docs/user_guide/00_liquid-handling/tecan-evo/_tecan-evo.rst +++ /dev/null @@ -1,4 +0,0 @@ -Tecan EVO -========= - -Documentation coming soon. diff --git a/docs/user_guide/01_material-handling/_material-handling.rst b/docs/user_guide/01_material-handling/_material-handling.rst deleted file mode 100644 index 68091a72dfe..00000000000 --- a/docs/user_guide/01_material-handling/_material-handling.rst +++ /dev/null @@ -1,23 +0,0 @@ -Material Handling -================= - -Everything that controls the movement of materials and the condition/state of the materials. -This includes machines for temperature and motion control (as neither machines physically touch a liquid). - - - ------------------------------------------- - -.. toctree:: - :maxdepth: 1 - - arms/_arm - centrifuge/_centrifuge - heating_shaking/heating_shaking - fans/fans - sealers/sealers - temperature-controllers/temperature-controllers - storage/storage - tilting - thermocycling/thermocycling - diff --git a/docs/user_guide/01_material-handling/arms/_arm.rst b/docs/user_guide/01_material-handling/arms/_arm.rst deleted file mode 100644 index cf00979e9fc..00000000000 --- a/docs/user_guide/01_material-handling/arms/_arm.rst +++ /dev/null @@ -1,12 +0,0 @@ -Arm -=== - -Robotic arms are a WIP in PLR. - ------------------------------------------- - -.. toctree:: - :maxdepth: 1 - :hidden: - - c_scara/_scara diff --git a/docs/user_guide/01_material-handling/arms/c_scara/_scara.rst b/docs/user_guide/01_material-handling/arms/c_scara/_scara.rst deleted file mode 100644 index 945ff394215..00000000000 --- a/docs/user_guide/01_material-handling/arms/c_scara/_scara.rst +++ /dev/null @@ -1,12 +0,0 @@ -SCARA -===== - -Selective Compliance Assembly Robot Arm (SCARA). - ------------------------------------------- - -.. toctree:: - :maxdepth: 1 - :hidden: - - precise-flex-pf400/hello-world diff --git a/docs/user_guide/01_material-handling/arms/c_scara/precise-flex-pf400/hello-world.ipynb b/docs/user_guide/01_material-handling/arms/c_scara/precise-flex-pf400/hello-world.ipynb deleted file mode 100644 index 1baf5819b13..00000000000 --- a/docs/user_guide/01_material-handling/arms/c_scara/precise-flex-pf400/hello-world.ipynb +++ /dev/null @@ -1,394 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "5f3c5bbc", - "metadata": {}, - "source": [ - "# PreciseFlex PF400 and PF3400 robots\n", - "\n", - "Connection: ethernet" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "594d1a91", - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ba84cba7", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.arms.scara import ExperimentalSCARA\n", - "from pylabrobot.arms.precise_flex.pf_400 import PreciseFlex400Backend\n", - "\n", - "from pylabrobot.arms.precise_flex.coords import PreciseFlexCartesianCoords\n", - "from pylabrobot.arms.precise_flex.joints import PFAxis\n", - "from pylabrobot.resources import Coordinate, Rotation" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e83c35da", - "metadata": {}, - "outputs": [], - "source": [ - "backend = PreciseFlex400Backend(host=\"192.168.0.1\", port=10100, has_rail=False)\n", - "arm = ExperimentalSCARA(backend=backend)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1a9eb932", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.setup(skip_home=False)" - ] - }, - { - "cell_type": "markdown", - "id": "eb3a5aae", - "metadata": {}, - "source": [ - "## Granular Robot control" - ] - }, - { - "cell_type": "markdown", - "id": "7e0cc273", - "metadata": {}, - "source": [ - "### Gripper Control" - ] - }, - { - "cell_type": "markdown", - "id": "3741f8e2", - "metadata": {}, - "source": [ - "The gripper can be controlled manually as follows:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "451d51c0", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.close_gripper(gripper_width=80)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ec9cd5f2", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.open_gripper(gripper_width=120)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "89517ae8", - "metadata": {}, - "outputs": [], - "source": [ - "await backend.is_gripper_closed()" - ] - }, - { - "cell_type": "markdown", - "id": "6518bc78", - "metadata": {}, - "source": [ - "### Movement" - ] - }, - { - "cell_type": "markdown", - "id": "60b8cede", - "metadata": {}, - "source": [ - "You can also arbitrarily move the arm to cartesian coordinates as well as joint coordinates:" - ] - }, - { - "cell_type": "markdown", - "id": "51ea5969", - "metadata": {}, - "source": [ - "```{warning}\n", - "Depending on the current position, moving to a joint position might actually cause the arm to collide with its base! Be careful when using joint coordinates.\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0ebba776", - "metadata": {}, - "outputs": [], - "source": [ - "location = {\n", - " PFAxis.BASE: 99.981,\n", - " PFAxis.SHOULDER: -36.206,\n", - " PFAxis.ELBOW: 83.063,\n", - " PFAxis.WRIST: -331.7,\n", - " PFAxis.GRIPPER: 126.084,\n", - " PFAxis.RAIL: 0.0,\n", - "}\n", - "await arm.move_to(location)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3e81b5a4", - "metadata": {}, - "outputs": [], - "source": [ - "location = PreciseFlexCartesianCoords(\n", - " location=Coordinate(x=290, y=659, z=100),\n", - " rotation=Rotation(x=-180.0, y=90.0, z=84.804)\n", - ")\n", - "await arm.move_to(location)" - ] - }, - { - "cell_type": "markdown", - "id": "1addb341", - "metadata": {}, - "source": [ - "## Getting current location" - ] - }, - { - "cell_type": "markdown", - "id": "022458da", - "metadata": {}, - "source": [ - "Get the joint angles of the robot's arm, including the rails if applicable and the gripper width:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b515597a", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.get_joint_position()" - ] - }, - { - "cell_type": "markdown", - "id": "f254c14c", - "metadata": {}, - "source": [ - "Get the cartesian coordinates of the robot's end effector:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9d72277a", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.get_cartesian_position()" - ] - }, - { - "cell_type": "markdown", - "id": "6f8c1b6c", - "metadata": {}, - "source": [ - "## Teaching positions using free mode" - ] - }, - { - "cell_type": "markdown", - "id": "e534f0b0", - "metadata": {}, - "source": [ - "Use \"free mode\" to manually move the robot's arm to a desired position, and then read the current cartesian coordinates. You can use the cartesian coordinates to programmatically move the arm to that position later." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "65e03f11", - "metadata": {}, - "outputs": [], - "source": "await arm.freedrive_mode(free_axes=[0])" - }, - { - "cell_type": "code", - "execution_count": null, - "id": "64fd60cf", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.get_cartesian_position()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1b64e546", - "metadata": {}, - "outputs": [], - "source": "await arm.end_freedrive_mode()" - }, - { - "cell_type": "markdown", - "id": "6ef53233", - "metadata": {}, - "source": [ - "## Plate Movement" - ] - }, - { - "cell_type": "markdown", - "id": "af6424a0", - "metadata": {}, - "source": [ - "Below is an example of picking up and placing a plate using cartesian coordinates. You can call `move_to` in between to move to other locations as needed." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "88ae00e3", - "metadata": {}, - "outputs": [], - "source": [ - "location = PreciseFlexCartesianCoords(\n", - " location=Coordinate(x=650.74, y=-345.922, z=5.05),\n", - " rotation=Rotation(x=180.0, y=90.0, z=-9.921)\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "18c1a704", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.pick_up_resource(\n", - " location,\n", - " plate_width=125,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bfeefd6b", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.drop_resource(location)" - ] - }, - { - "cell_type": "markdown", - "id": "62036558", - "metadata": {}, - "source": [ - "## Miscellaneous commands" - ] - }, - { - "cell_type": "markdown", - "id": "80c96216", - "metadata": {}, - "source": [ - "Move the arm to its predefined home/safe position:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c8e1e7c7", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.move_to_safe()" - ] - }, - { - "cell_type": "markdown", - "id": "3d07da5b", - "metadata": {}, - "source": [ - "Home the arm:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "78ab2382", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.home()" - ] - }, - { - "cell_type": "markdown", - "id": "076b738d", - "metadata": {}, - "source": [ - "Stop any ongoing movement of the arm:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "13b4e525", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.halt()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.24" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file diff --git a/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md b/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md deleted file mode 100644 index dca419d6586..00000000000 --- a/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md +++ /dev/null @@ -1,30 +0,0 @@ -# Centrifuges - -Centrifuges are controlled by the {class}`~pylabrobot.centrifuge.centrifuge.Centrifuge` class. This class takes a backend as an argument. The backend is responsible for communicating with the centrifuge and is specific to the hardware being used. - -The {class}`~pylabrobot.centrifuge.centrifuge.Centrifuge` class has a number of methods for controlling the centrifuge. These are: - -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.open_door`: Open the centrifuge door. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.close_door`: Close the centrifuge door. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.lock_door`: Lock the centrifuge door. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.unlock_door`: Unlock the centrifuge door. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.lock_bucket`: Lock centrifuge buckets. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.unlock_bucket`: Unlock centrifuge buckets. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.go_to_bucket1`: Rotate/present Bucket 1. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.go_to_bucket2`: Rotate/present Bucket 2. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.spin`: Start a centrifuge spin cycle. - -Some standalone door/lock and low-level rotation primitives are hardware-dependent. For example, -the Agilent VSpin backend exposes {meth}`~pylabrobot.centrifuge.vspin_backend.VSpinBackend.go_to_position` -for arbitrary bucket positioning during calibration (8000 ticks = 360 degrees), while the HighRes -MicroSpin only exposes bucket presentation through `go_to_bucket1()` / `go_to_bucket2()` and manages -door/lock operations automatically. See the hardware-specific guides below for details. - -PLR supports the following centrifuges: - -```{toctree} -:maxdepth: 1 - -agilent_vspin -highres_microspin -``` diff --git a/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb b/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb deleted file mode 100644 index e0ab2cc16b8..00000000000 --- a/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb +++ /dev/null @@ -1,352 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "678edf8f", - "metadata": {}, - "source": [ - "# Agilent VSpin\n", - "\n", - "The VSpin centrifuge is controlled by the {class}`~pylabrobot.centrifuge.vspin_backend.VSpinBackend` class." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e937791a", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.centrifuge import Centrifuge, VSpinBackend\n", - "vspin_backend = VSpinBackend() # VSpinBackend(device_id=\"YOUR_FTDI_ID_HERE\")\n", - "cf = Centrifuge(name = \"centrifuge\", backend = vspin_backend, size_x= 1, size_y=1, size_z=1)\n", - "await cf.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "e5ee122c", - "metadata": {}, - "source": [ - "Before you can use the \"go to bucket\" commands, you need to calibrate the bucket positions. See [below](#calibrating-bucket-1-position) for instructions." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9f9365f0", - "metadata": {}, - "outputs": [], - "source": [ - "await cf.go_to_bucket1()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c8b872e9", - "metadata": {}, - "outputs": [], - "source": [ - "await cf.go_to_bucket2()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ed867ca2", - "metadata": {}, - "outputs": [], - "source": [ - "await cf.spin(\n", - " g=800,\n", - " duration=60, # seconds\n", - " acceleration=1.0, # 0-1\n", - " deceleration=1.0, # 0-1\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "a3587acc", - "metadata": {}, - "source": [ - "Status commands" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2d8d3047", - "metadata": {}, - "outputs": [], - "source": [ - "await vspin_backend.get_door_locked()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "57bdc72d", - "metadata": {}, - "outputs": [], - "source": [ - "await vspin_backend.get_door_open()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d3f66052", - "metadata": {}, - "outputs": [], - "source": [ - "await vspin_backend.get_bucket_locked()" - ] - }, - { - "cell_type": "markdown", - "id": "47c4cfc9", - "metadata": {}, - "source": [ - "## Calibrating bucket 1 position\n", - "\n", - "You need to calibrate the bucket 1 position for every vspin once. There are two ways to do this:\n", - "1. Manually: Move bucket 1 to the correct position using the physical controls on the centrifuge.\n", - "2. Programmatically: Use the `go_to_position` command to move bucket 1 to the correct position.\n", - "\n", - "With bucket 1 in the correct position, save it with `cf.backend.set_bucket_1_position_to_current()`. This will save the calibration for the current centrifuge to disk at `~/.pylabrobot/vspin_bucket_calibrations.json` (based on the usb serial number)." - ] - }, - { - "cell_type": "markdown", - "id": "8086eab1", - "metadata": {}, - "source": [ - "### Moving using code\n", - "\n", - "Use `vspin_backend.go_to_position` to move the buckets to the correct position. A full rotation is 8000 ticks, so 4.5 degrees per 100 ticks." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "50fc77a3", - "metadata": {}, - "outputs": [], - "source": [ - "await vspin_backend.go_to_position(100)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7bb22121", - "metadata": {}, - "outputs": [], - "source": [ - "await vspin_backend.set_bucket_1_position_to_current()" - ] - }, - { - "cell_type": "markdown", - "id": "3cbdc48f", - "metadata": {}, - "source": [ - "### Manually rotating\n", - "\n", - "You can open the door unlock the bucket and manually rotate the buckets to the desired position.\n", - "\n", - "```{warning}\n", - "High risk / high reward! The vspin has a safety mechanism that will close the door when it detects movement.\n", - "This means the door will close when you rotate the buckets manually too fast.\n", - "Be careful or it will eat your fingers!\n", - "It will save time compared to using code though.\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "031329fc", - "metadata": {}, - "outputs": [], - "source": [ - "await cf.open_door()\n", - "await vspin_backend.unlock_bucket()" - ] - }, - { - "cell_type": "markdown", - "id": "f0eae259", - "metadata": {}, - "source": [ - "Manually rotate buckets to align bucket 1 with door" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "18a25ed9", - "metadata": {}, - "outputs": [], - "source": [ - "await vspin_backend.set_bucket_1_position_to_current()" - ] - }, - { - "cell_type": "markdown", - "id": "c973648a", - "metadata": {}, - "source": [ - "## Loader\n", - "\n", - "The VSpin can optionally be used with a loader (called Access2). The loader is optional because you can also use a robotic arm like an iSWAP to move a plate directly into the centrifuge.\n", - "\n", - "When using the loader, you must specify the FTDI device ids for both devices because both use FTDI and are otherwise indistinguishable. See [below](#2-finding-the-ftdi-id) for how to find the device ids.\n", - "\n", - "Here's how to use the loader:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f3e17e8f", - "metadata": {}, - "outputs": [], - "source": [ - "import asyncio\n", - "\n", - "from pylabrobot.centrifuge import Access2, VSpinBackend\n", - "vspin_backend = VSpinBackend(device_id=\"YOUR_VSPIN_FTDI_ID_HERE\")\n", - "centrifuge, loader = Access2(name=\"name\", vspin=vspin_backend, device_id=\"YOUR_LOADER_FTDI_ID_HERE\")\n", - "\n", - "# initialize the centrifuge and loader in parallel\n", - "await asyncio.gather(\n", - " centrifuge.setup(),\n", - " loader.setup()\n", - ")\n", - "\n", - "# go to a bucket and open the door before loading\n", - "await centrifuge.go_to_bucket1()\n", - "await centrifuge.open_door()\n", - "\n", - "# assign a plate to the loader before loading. This can also be done implicitly by for example\n", - "# lh.move_plate(plate, loader)\n", - "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", - "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", - "loader.assign_child_resource(plate)\n", - "\n", - "# load and unload the plate\n", - "await loader.load()\n", - "await loader.unload()" - ] - }, - { - "cell_type": "markdown", - "id": "e3de872f", - "metadata": {}, - "source": [ - "## Installation\n", - "\n", - "The VSpin centrifuge connects to your system via a COM port. Integrating it with `pylabrobot` library requires some setup. Follow this guide to get started.\n", - "\n", - "### 1. Installing libftdi\n", - "\n", - "#### macOS\n", - "\n", - "Install libftdi using [Homebrew](https://brew.sh/):\n", - "\n", - "```bash\n", - "brew install libftdi\n", - "```\n", - "\n", - "#### Linux\n", - "\n", - "Debian (rpi) / Ubuntu etc:\n", - "\n", - "```bash\n", - "sudo apt-get install libftdi-dev\n", - "```\n", - "\n", - "Other distros have similar packages.\n", - "\n", - "#### Windows\n", - "\n", - "**Find Your Python Directory**\n", - "\n", - "To use the necessary FTDI `.dll` files, you need to locate your Python environment:\n", - "\n", - "1. Open Python in your terminal:\n", - " ```python\n", - " python\n", - " >>> import sys\n", - " >>> sys.executable\n", - " ```\n", - "2. This will print a path, e.g., `C:\\Python39\\python.exe`.\n", - "3. Navigate to the `Scripts` folder in the same directory as `python.exe`.\n", - "\n", - "**Download FTDI DLLs**\n", - "\n", - "Download the required `.dll` files from the following link:\n", - "[FTDI Development Kit](https://sourceforge.net/projects/picusb/files/libftdi1-1.5_devkit_x86_x64_19July2020.zip/download) (link will start download).\n", - "\n", - "1. Extract the downloaded zip file.\n", - "2. Locate the `bin64` folder.\n", - "3. Copy the files named:\n", - " - `libftdi1.dll`\n", - " - `libusb-1.0.dll`\n", - "\n", - "**Place DLLs in Python Scripts Folder**\n", - "\n", - "Paste the copied `.dll` files into the `Scripts` folder of your Python environment. This enables Python to communicate with FTDI devices.\n", - "\n", - "**Configuring the Driver with Zadig**\n", - "\n", - "Use Zadig to replace the default driver of the VSpin device with `libusbk`:\n", - "\n", - "1. **Identify the VSpin Device**\n", - "\n", - " - Open Zadig.\n", - " - To confirm the VSpin device, disconnect the RS232 port from the centrifuge while monitoring the Zadig device list.\n", - " - The device that disappears is your VSpin, likely titled \"USB Serial Converter.\"\n", - "\n", - "2. **Replace the Driver**\n", - " - Select the identified VSpin device in Zadig.\n", - " - Replace its driver with `libusbk`.\n", - " - Optionally, rename the device to \"VSpin\" for easy identification.\n", - "\n", - "> **Note:** If you need to revert to the original driver for tools like the Agilent Centrifuge Config Tool, go to **Device Manager** and uninstall the `libusbk` driver. The default driver will reinstall automatically.\n", - "\n", - "### 2. Finding the FTDI ID\n", - "\n", - "To interact with the centrifuge programmatically, you need its FTDI device ID. Use the following steps to find it:\n", - "\n", - "1. Open a terminal and run:\n", - " ```bash\n", - " python -m pylibftdi.examples.list_devices\n", - " ```\n", - "2. This will output something like:\n", - " ```\n", - " FTDI:USB Serial Converter:FTE0RJ5T\n", - " ```\n", - "3. Copy the ID (`FTE0RJ5T` or your equivalent).\n", - "\n", - "You’re now ready to use your VSpin centrifuge with `pylabrobot`!" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.9.23" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb b/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb deleted file mode 100644 index 299ce5f8e4b..00000000000 --- a/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb +++ /dev/null @@ -1,388 +0,0 @@ -{ - "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.request_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.request_status()\n# {'Spindle Position': '1958', 'Door Position': '-457'}\n" - }, - { - "cell_type": "markdown", - "id": "7fb071d2", - "metadata": {}, - "source": [ - "## 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()`. 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" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "935d0e90", - "metadata": {}, - "outputs": [], - "source": [ - "# Open the door and present bucket 1 in one step\n", - "await cf.go_to_bucket1()\n", - "\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" - ] - }, - { - "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", - "- 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", - "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", - "**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", - "```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\nawait cf.backend.clear_button_abort() # latch cleared; rotor may still be spinning\nstatus = await cf.backend.request_status() # NOW we know we're really stopped\n" - }, - { - "cell_type": "markdown", - "id": "b76bed47", - "metadata": {}, - "source": "## 8. Error stack\n\nThe MicroSpin maintains an internal error stack. `request_errors(n)` returns the top `n` lines (default 10):\n" - }, - { - "cell_type": "code", - "execution_count": null, - "id": "94eac76d", - "metadata": {}, - "outputs": [], - "source": "await cf.backend.request_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\nfrom pylabrobot.centrifuge import MicroSpin\nfrom pylabrobot.centrifuge.highres.mock_server import MicroSpinMockServer\n\nasync 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.request_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 -} \ No newline at end of file diff --git a/docs/user_guide/01_material-handling/fans/fans.md b/docs/user_guide/01_material-handling/fans/fans.md deleted file mode 100644 index 1aa83239005..00000000000 --- a/docs/user_guide/01_material-handling/fans/fans.md +++ /dev/null @@ -1,23 +0,0 @@ -# Fans (Air Filtration Systems) - -In PyLabRobot, fans refer to air filtration units that condition air within or around the deck to protect the sample from contamination. - -Their main purpose is to maintain a clean environment for experiments by ensuring consistent airflow and particle removal, reducing risks from dust, aerosols, and microorganisms. -These systems are not primarily designed for operator safety; separate equipment like fume extractors or biosafety cabinets serves that role. - -Common filter technologies include: - -- **HEPA filters**: Capture ≥99.97% of airborne particles ≥0.3 µm, widely used to keep samples clean. - -- **ULPA filters**: Capture even smaller particles for higher-level cleanroom requirements. - -- **Activated carbon filters**: Remove volatile organic compounds (VOCs) and chemical fumes. - -- **Prefilters**: Trap larger particles to extend the lifespan of HEPA/ULPA filters. - - -```{toctree} -:maxdepth: 1 -:hidden: -hamilton_hepa_scap -``` diff --git a/docs/user_guide/01_material-handling/fans/hamilton_hepa_scap.ipynb b/docs/user_guide/01_material-handling/fans/hamilton_hepa_scap.ipynb deleted file mode 100644 index 6dc8df7b25a..00000000000 --- a/docs/user_guide/01_material-handling/fans/hamilton_hepa_scap.ipynb +++ /dev/null @@ -1,121 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Hamilton HEPA Fan\n", - "\n", - "| Summary | Photo |\n", - "|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------|\n", - "| - OEM Link (none exists?)
- **Communication Protocol / Hardware**: Serial (FTDI)/ USB-A
- **Communication Level**: Firmware
- Old HEPA CAP discontinued in 2020, replaced with Clean Air Protection (CAP) Fan (Hamilton cat. no.: 92173-22)
- Old HEPA CAP VID:PID 0856:ac11 \"USOPTL4\"
- Adds 321 mm to height of STAR(let)
- Takes in ambient air, filters it and supplies it to the inside of the STAR(let) | ![quadrants](img/hamilton-old-hepa-cap.png) |" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Physical)\n", - "\n", - "The Hamilton HEPA Fan for a STAR or STARlet liquid handling workstation has to be placed on top of the machines chassis.\n", - "\n", - "It requires two cable connections to be operational:\n", - "1. Power cord (standard IEC C13); if you are using an older HEPA Fan model ensure that the voltage switch is set to your country's mains voltage!\n", - "2. USB cable (USB-B with at fan end; USB-A at control PC end)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Programmatic)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.only_fans import Fan\n", - "from pylabrobot.only_fans import HamiltonHepaFanBackend" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fan = Fan(backend=HamiltonHepaFanBackend()) \n", - "# NB.: fans are only machines, they are not modelled as resources -> require no str name\n", - "\n", - "await fan.setup()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Usage / Machine Features" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Running for 60 seconds:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await fan.turn_on(intensity=100, duration=30)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Running until stop:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await fan.turn_on(intensity=100)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await fan.turn_off()" - ] - } - ], - "metadata": { - "language_info": { - "name": "python" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/user_guide/01_material-handling/fans/img/hamilton-old-hepa-cap.png b/docs/user_guide/01_material-handling/fans/img/hamilton-old-hepa-cap.png deleted file mode 100644 index 854c88fb881..00000000000 Binary files a/docs/user_guide/01_material-handling/fans/img/hamilton-old-hepa-cap.png and /dev/null differ diff --git a/docs/user_guide/01_material-handling/fans/img/star-scap.png b/docs/user_guide/01_material-handling/fans/img/star-scap.png deleted file mode 100644 index 8be7889bfb9..00000000000 Binary files a/docs/user_guide/01_material-handling/fans/img/star-scap.png and /dev/null differ diff --git a/docs/user_guide/01_material-handling/fans/img/starlet_old_hepa_fan.jpeg b/docs/user_guide/01_material-handling/fans/img/starlet_old_hepa_fan.jpeg deleted file mode 100644 index 40fb982c402..00000000000 Binary files a/docs/user_guide/01_material-handling/fans/img/starlet_old_hepa_fan.jpeg and /dev/null differ diff --git a/docs/user_guide/01_material-handling/heating_shaking/hamilton.ipynb b/docs/user_guide/01_material-handling/heating_shaking/hamilton.ipynb deleted file mode 100644 index 8ba4ebe5348..00000000000 --- a/docs/user_guide/01_material-handling/heating_shaking/hamilton.ipynb +++ /dev/null @@ -1,546 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "866f6f49", - "metadata": {}, - "source": [ - "# Hamilton Heater Shaker\n", - "\n", - "The Hamilton Heater Shaker is a `HeaterShaker` machine that enables:\n", - "- heating, \n", - "- locking & unlocking, and\n", - "- (orbital) shaking\n", - "\n", - "...of plates (active cooling is not possible with this machine)." - ] - }, - { - "cell_type": "markdown", - "id": "b235295c-ed0f-4bcc-9542-3ebb13b47181", - "metadata": {}, - "source": [ - "- [manufacturer_link](https://www.hamiltoncompany.com/automated-liquid-handling/small-devices/hamilton-heater-shaker?srsltid=AfmBOooBVzRaBrPj4UYumvbcbECIxj4lk_0jpJDjMrLksnFJPOgNURm6)\n", - "- Temperature control = RT+5°C to 105°C (all variants)\n", - "- Variants:\n", - " - Cat. no.: 199027 \n", - " - shaking orbit = 1.5 mm \n", - " - shaking speed = 100 - 1800 rpm\n", - " - Cat. no.: 199033 \n", - " - shaking orbit = 2.0 mm \n", - " - shaking speed = 100 - 2500 rpm\n", - " - Cat. no.: 199034 \n", - " - shaking orbit = 3.0 mm \n", - " - shaking speed = 100 - 2400 rpm \n", - " - max. loading = 500 grams \n", - "\n", - "- Footprint: size_x = 146.2, size_y = 103.8, size_z=74.11" - ] - }, - { - "cell_type": "markdown", - "id": "1e836e11-90eb-4c4a-888e-1f7d0ac54798", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Physical)" - ] - }, - { - "cell_type": "markdown", - "id": "d2087a07", - "metadata": {}, - "source": [ - "A Hamilton Heater Shaker (hhs) can be used through two different **control interfaces**:\n", - "- a **control box**, called the `HamiltonHeaterShakerBox`: this supports connection of up to **8 heater shakers** per control box, OR\n", - "- **directly plugging** the hhs **into a Hamilton STAR liquid handler**: STAR liquid handlers have 2 RS232 ports on their left side, and can therefore support up to **2 heater shakers** simultaneously.\n", - "\n", - "When using the **heater shaker box control interface** a USB-B cable is plugged into one of the heater shakers and connected to the host computer.\n", - "This heater shaker is connected via a serial port to the control box. Other heater shakers are also plugged into the control box using serial cables, but not plugged into the computer.\n", - "The first heater shakers serves as a gateway.\n", - "\n", - "When using the **Hamilton STAR interface**, the Heater Shaker is connected via a serial interface:\n", - "- Connection: STAR (RS232) ⇄ Host computer (USB-A)\n", - "\n", - "The heater shaker is then controlled through the STAR Liquid Handler." - ] - }, - { - "cell_type": "markdown", - "id": "eb905396-ef6d-4f45-b0f0-933e6b689418", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Programmatic)\n", - "\n", - "In either case, `HamiltonHeaterShakerBackend` will be the backend and `HeaterShaker` will be the frontend.\n", - "Depending on the interface you use, pass a different argument to `HamiltonHeaterShakerBackend`.\n", - "\n", - "**hs_box_control**:\n", - "As multiple heater shakers can be controlled through one USB connection to the computer (a cable to HHS 0 when using the control box), the `index` of a specific heater shaker needs to be specified. It needs to be set to 0 for the HHS that is connected via a USB cable to the computer.\n", - "Note that this also requires turing a DIP switch on the bottom of the HHS module.\n", - "\n", - "**star_control**:\n", - "Each heater shaker is connected via a separate cable to the STAR liquid handler.\n", - "The back RS232 port corresponds to `index=1` and the front RS232 port corresponds to `index=2`." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "12fa20e6-5708-4581-93e2-f342cf74c062", - "metadata": {}, - "outputs": [], - "source": [ - "interface_choice = \"star_control\" # hs_box_control VS star_control" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "000202e0", - "metadata": {}, - "outputs": [], - "source": [ - "if interface_choice == \"hs_box_control\":\n", - " \n", - " # Setting up a backend with the HamiltonHeaterShakerBox\n", - " from pylabrobot.heating_shaking import HamiltonHeaterShakerBackend, HamiltonHeaterShakerBox\n", - " \n", - " control_interface = hhs_box = HamiltonHeaterShakerBox()\n", - "\n", - "elif interface_choice == \"star_control\":\n", - " \n", - " # Alternative: setting up a backend with a STAR\n", - " from pylabrobot.liquid_handling import LiquidHandler, STARBackend\n", - " from pylabrobot.resources import STARDeck\n", - " from pylabrobot.heating_shaking import HamiltonHeaterShakerBackend\n", - " \n", - " control_interface = star_backend = STARBackend()\n", - "\n", - " # Control via a STAR requires instantiation of the STAR liquid handler\n", - " lh = LiquidHandler(backend=star_backend, deck=STARDeck())\n", - "\n", - "else:\n", - " raise ValueError(f\"Interface choice invalid: {interface_choice}\")\n", - "\n", - "backend = HamiltonHeaterShakerBackend(\n", - " index=0,\n", - " interface=control_interface\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "819863d9", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.heating_shaking import HeaterShaker\n", - "from pylabrobot.resources.coordinate import Coordinate\n", - "\n", - "hs = HeaterShaker(\n", - " name=\"Hamilton HeaterShaker\",\n", - " backend=backend,\n", - " size_x=146.2,\n", - " size_y=103.8,\n", - " size_z=74.11,\n", - " child_location=Coordinate(x=9.66, y=9.22, z=74.11),\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "29806703", - "metadata": {}, - "source": [ - "Note that you will need to call `hhs_box.setup()` before calling `HeaterShaker.setup()`.\n", - "When using a `STAR`, just use `star.setup()` or, more likely, `lh.setup()`.\n", - "This is opening the USB connection to the device you are using as an interface.\n", - "\n", - "Note that setup should only be called ONCE:\n", - "- when using a STAR as a liquid handler, just call `lh.setup()`.\n", - "(Do not call it again when using the heater shaker.)\n", - "- when using multiple heater shakers with the control box, call `.setup()` once for the control box, and then call `HeaterShaker.setup()` for each heater shaker.\n", - "(Do not call `setup` again for the control box.)" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "76173726", - "metadata": {}, - "outputs": [], - "source": [ - "if interface_choice == \"hs_box_control\":\n", - " \n", - " # When using the HamiltonHeaterShakerBox, you need to call setup() on the box\n", - " await hhs_box.setup()\n", - "\n", - "elif interface_choice == \"star_control\":\n", - "\n", - " # Alternative: when using the STAR, you need to call setup() on lh\n", - " await lh.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "70115dff", - "metadata": {}, - "source": [ - "After calling `setup` on your interface, call `HeaterShaker.setup()` for each heater shaker machine.\n", - "This will initialize the `HeaterShaker` machine itself." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "c6d38f82", - "metadata": {}, - "outputs": [], - "source": [ - "await hs.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "78c9349a", - "metadata": {}, - "source": [ - "### Assigning a Hamilton Heater Shaker to the deck\n", - "\n", - "Before you can use the Hamilton Heater Shaker in combination with a Hamilton STAR liquid handler, you need to assign it to the deck. This is needed when, for example, you want to use the iSWAP or CoRe grippers to move a plate to or from the heater shaker. This is also required to get the heater shaker to show up in the Visualizer.\n", - "\n", - "Here's one example of assigning a Hamilton Heater Shaker to the deck using a `MFX_CAR_P3_SHAKER`. Note that you can use any carrier, or even directly place heater shakers on the deck if you like. See the [Hamilton STAR resources page](/resources/library/hamilton) for carriers." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "dd2a8309", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources.hamilton.mfx_carriers import MFX_CAR_P3_SHAKER\n", - "shaker_carrier = MFX_CAR_P3_SHAKER(name=\"shaker_carrier\", modules={0: hs2, 1: hs1, 2: hs0})\n", - "lh.deck.assign_child_resource(shaker_carrier, rails=5)" - ] - }, - { - "cell_type": "markdown", - "id": "599b7d31-27b1-44f9-a720-cd2ef55e122f", - "metadata": {}, - "source": [ - "---\n", - "## Usage" - ] - }, - { - "cell_type": "markdown", - "id": "5e201ecb", - "metadata": {}, - "source": [ - "### Heating Control" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "2f544e4a", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "25.6" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await hs.get_temperature() # Temperature of sensor in the middle of the heater shaker in C" - ] - }, - { - "cell_type": "markdown", - "id": "6cadda38", - "metadata": {}, - "source": [ - "The HHS also supports reading the temperature at the edge of the heater shaker:" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "81e0743c", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "25.7" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await hs.backend.get_edge_temperature()" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "f076c7bd", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'T1TAid0004er00'" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await hs.set_temperature(37) # Temperature in degrees C" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "cf85de20", - "metadata": {}, - "outputs": [], - "source": [ - "await hs.wait_for_temperature() # Wait for the temperature to stabilize" - ] - }, - { - "cell_type": "markdown", - "id": "d1b72d26", - "metadata": {}, - "source": [ - "### Shaking Control" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "17646f3d", - "metadata": {}, - "outputs": [], - "source": [ - "await hs.lock_plate()" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "49b330b7", - "metadata": {}, - "outputs": [], - "source": [ - "await hs.shake(\n", - " speed=100, # rpm\n", - " direction=0, # seconds\n", - " acceleration=1000, # rpm/sec\n", - ") " - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "71d8a964", - "metadata": {}, - "outputs": [], - "source": [ - "await hs.stop_shaking()" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "a0d8ab2d", - "metadata": {}, - "outputs": [], - "source": [ - "await hs.unlock_plate()" - ] - }, - { - "cell_type": "markdown", - "id": "c9176b41-c939-45f0-aa6a-a5599188f38c", - "metadata": {}, - "source": [ - "### Closing Connection to Machine" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "94577ff3-7add-4f3c-815c-c0301ca58adf", - "metadata": {}, - "outputs": [], - "source": [ - "await hs.stop()" - ] - }, - { - "cell_type": "markdown", - "id": "a6ee0951", - "metadata": {}, - "source": [ - "---\n", - "## Using Multiple Hamilton Heater Shakers\n", - "\n", - "### 1x hs_box - Multiple HHS\n", - "\n", - "When using multiple heater shakers, you can use the `HamiltonHeaterShakerBackend` class to control them. This class will automatically handle the communication with the control box and the individual heater shakers.\n", - "\n", - "As above, initialize the `HamiltonHeaterShakerBox` class. Then, initialize as many `HamiltonHeaterShakerBackend` classes as you want to control, specifying the index for each. Note that each `HamiltonHeaterShakerBackend` gets the same instance of the `HamiltonHeaterShakerBox`: this is because there is a single USB connection, managed by that instance." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9745da8f", - "metadata": {}, - "outputs": [], - "source": [ - "control_interface = hhs_box = HamiltonHeaterShakerBox()\n", - "\n", - "# HS1\n", - "backend1 = HamiltonHeaterShakerBackend(index=1, interface=control_interface)\n", - "hs1 = HeaterShaker(\n", - " name=\"Hamilton HeaterShaker\",\n", - " backend=backend1,\n", - " size_x=146.2,\n", - " size_y=103.8,\n", - " size_z=74.11,\n", - " child_location=Coordinate(x=9.66, y=9.22, z=74.11),\n", - ")\n", - "\n", - "# HS2\n", - "backend2 = HamiltonHeaterShakerBackend(index=2, interface=control_interface)\n", - "hs2 = HeaterShaker(\n", - " name=\"Hamilton HeaterShaker\",\n", - " backend=backend2,\n", - " size_x=146.2,\n", - " size_y=103.8,\n", - " size_z=74.11,\n", - " child_location=Coordinate(x=9.66, y=9.22, z=74.11),\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "e26cc8dc", - "metadata": {}, - "source": [ - "For setup, call `setup` on the `HamiltonHeaterShakerBox` instance. This will setup the USB connection to the control box. Then, call `setup` on each `HamiltonHeaterShakerBackend` instance. This will setup the individual heater shakers." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7ff193b0", - "metadata": {}, - "outputs": [], - "source": [ - "await hhs_box.setup()\n", - "\n", - "for hs in [hs1, hs2]:\n", - " await hs.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "d955b4d4-edf8-46b9-a70b-9e75ea7aa4a2", - "metadata": {}, - "source": [ - "### 1x STAR - 2x hhs\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4d753a1a-97ce-4625-adce-e59c37851207", - "metadata": {}, - "outputs": [], - "source": [ - "control_interface = star_backend = STARBackend()\n", - "\n", - "# Control via a STAR requires instantiation of the STAR liquid handler\n", - "lh = LiquidHandler(backend=star_backend, deck=STARDeck())\n", - "\n", - "# HS1\n", - "backend1 = HamiltonHeaterShakerBackend(index=1, interface=control_interface)\n", - "\n", - "hs1 = HeaterShaker(\n", - " name=\"Hamilton HeaterShaker\",\n", - " backend=backend1,\n", - " size_x=146.2,\n", - " size_y=103.8,\n", - " size_z=74.11,\n", - " child_location=Coordinate(x=9.66, y=9.22, z=74.11),\n", - ")\n", - "\n", - "# HS2\n", - "backend2 = HamiltonHeaterShakerBackend(index=2, interface=control_interface)\n", - "\n", - "hs2 = HeaterShaker(\n", - " name=\"Hamilton HeaterShaker\",\n", - " backend=backend2,\n", - " size_x=146.2,\n", - " size_y=103.8,\n", - " size_z=74.11,\n", - " child_location=Coordinate(x=9.66, y=9.22, z=74.11),\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f359e8f2-60c6-45cc-aa01-bc59bb77599d", - "metadata": {}, - "outputs": [], - "source": [ - "await lh.setup()\n", - "\n", - "for hs in [hs1, hs2]:\n", - " await hs.setup()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.7" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/01_material-handling/heating_shaking/heating_shaking.md b/docs/user_guide/01_material-handling/heating_shaking/heating_shaking.md deleted file mode 100644 index 5f863f563ea..00000000000 --- a/docs/user_guide/01_material-handling/heating_shaking/heating_shaking.md +++ /dev/null @@ -1,29 +0,0 @@ -# Heater Shakers - -## Installation - -Most heater-shakers use a serial connection: - -```bash -pip install pylabrobot[serial] -``` - -For Inheco devices in HID mode, install `pip install pylabrobot[hid]` instead. - -Heater-shakers are a hybrid of {class}`~pylabrobot.temperature_controllers.temperature_controller.TemperatureController` and {class}`~pylabrobot.shakers.shaker.Shaker`. They are used to control the temperature of a sample while shaking it. - -PyLabRobot supports the following heater shakers: - -- Inheco ThermoShake RM (tested) -- Inheco ThermoShake (should have the same API as RM) -- Inheco ThermoShake AC (should have the same API as RM) -- Hamilton Heater Shaker (tested) -- QInstruments BioShake (3000 elmm, 5000 elm, and D30-T elm tested) - -```{toctree} -:maxdepth: 1 - -Inheco ThermoShake -Hamilton Heater Shaker -QInstruments BioShake -``` diff --git a/docs/user_guide/01_material-handling/heating_shaking/inheco.ipynb b/docs/user_guide/01_material-handling/heating_shaking/inheco.ipynb deleted file mode 100644 index 46bbeb2793c..00000000000 --- a/docs/user_guide/01_material-handling/heating_shaking/inheco.ipynb +++ /dev/null @@ -1,229 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Hello World, Inheco ThermoShake!\n", - "\n", - "The Inheco Thermoshake is a heater-cooler-shaker machine that enables:\n", - "- heating & cooling,\n", - "- locking & unlocking, and\n", - "- (orbital) shaking\n", - "\n", - "...of plates.\n", - "\n", - "- Temperature control = 4°C to 105°C (all variants, max. 25°C difference to RT in cooling mode)\n", - "- Variants:\n", - " - **Inheco ThermoShake RM** ([manufacturer link](https://www.inheco.com/thermoshake-classic.html))\n", - " - PLR name: `inheco_thermoshake_rm`\n", - " - Cat. no.: 7100144\n", - " - status: PLR-tested\n", - " - shaking orbit = 2.0 mm \n", - " - shaking speed = 100 - 2000 rpm\n", - " - footprint: size_x=147 mm, size_y=104 mm, size_z=116 mm\n", - " - **Inheco ThermoShake** ([manufacturer link](https://www.inheco.com/thermoshake-classic.html))\n", - " - PLR name: `inheco_thermoshake`\n", - " - Cat. no.: 7100146\n", - " - status: PLR-untested (should have the same API as RM)\n", - " - shaking orbit = 2.0 mm \n", - " - shaking speed = 100 - 2000 rpm\n", - " - footprint: size_x=147 mm, size_y=104 mm, size_z=118 mm\n", - " - **Inheco ThermoShake AC** ([manufacturer link](https://www.inheco.com/thermoshake-ac.html))\n", - " - PLR name: `inheco_thermoshake_ac`\n", - " - Cat. no.: 7100160 & 7100161\n", - " - status: PLR-untested (should have the same API as RM)\n", - " - shaking orbit = 2.0 mm \n", - " - shaking speed = 300 - 3000 rpm\n", - " - footprint: size_x=147 mm, size_y=104 mm, size_z=115.9 mm\n", - "\n", - "Check out the [Thermoshake User and installation manual](https://www.inheco.com/data/pdf/thermoshake-manual-1013-1049-33.pdf) for more information." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Setup Instructions (Physical)\n", - "\n", - "See the [Inheco TemperatureController user guide](../temperature-controllers/inheco.ipynb#setup-instructions-physical) for instructions on using multiple ThermoShakes with one control box. The instructions are the same as for the Inheco CPAC." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Usage" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.heating_shaking import HeaterShaker\n", - "from pylabrobot.temperature_controlling import InhecoTECControlBox\n", - "\n", - "control_box = InhecoTECControlBox()\n", - "await control_box.setup()" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "pylabrobot.heating_shaking.heater_shaker.HeaterShaker" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from pylabrobot.heating_shaking import inheco_thermoshake\n", - "hs = inheco_thermoshake(\n", - " name=\"Inheco Thermoshake\",\n", - " control_box=control_box,\n", - " index=1,\n", - ")\n", - "await hs.setup()\n", - "type(hs) # pylabrobot.heating_shaking.HeaterShaker" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Temperature Control\n", - "\n", - "See the [Inheco TemperatureController user guide](../temperature-controllers/inheco.ipynb#temperature-control) for temperature control instructions. They are the same for ThermoShake as they are for the Inheco CPAC." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Shaking Control" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The {meth}`~pylabrobot.heating_shaking.heater_shaker.HeaterShaker.setup` method is used to initialize the machine. This is where the backend will connect to the scale and perform any necessary initialization.\n", - "\n", - "The {class}`~pylabrobot.heating_shaking.heater_shaker.HeaterShaker` class has a number of methods for controlling the temperature and shaking of the sample. These are inherited from the {class}`~pylabrobot.temperature_controllers.temperature_controller.TemperatureController` and {class}`~pylabrobot.shakers.shaker.Shaker` classes.\n", - "\n", - "- {meth}`~pylabrobot.heating_shaking.heater_shaker.HeaterShaker.set_temperature`: Set the temperature of the module.\n", - "- {meth}`~pylabrobot.heating_shaking.heater_shaker.HeaterShaker.get_temperature`: Get the current temperature of the module.\n", - "- {meth}`~pylabrobot.heating_shaking.heater_shaker.HeaterShaker.shake`: Set the shaking speed of the module.\n", - "- {meth}`~pylabrobot.heating_shaking.heater_shaker.HeaterShaker.stop_shaking`: Stop the shaking of the module." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Shake indefinitely:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.shake(speed=100) # speed in rpm" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.stop_shaking() # Stop shaking" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Shake for 10 seconds:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.shake(\n", - " speed=100,\n", - " duration=10\n", - ") # speed in rpm, duration in seconds" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Closing Connection to Machine" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.stop()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Using Multiple Inheco Devices" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "See the [Inheco TemperatureController user guide](../temperature-controllers/inheco.ipynb#using-multiple-inheco-devices) for instructions on using multiple ThermoShakes with one control box. The instructions are the same as for the Inheco CPAC." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/01_material-handling/heating_shaking/qinstruments.ipynb b/docs/user_guide/01_material-handling/heating_shaking/qinstruments.ipynb deleted file mode 100644 index 854bca2164e..00000000000 --- a/docs/user_guide/01_material-handling/heating_shaking/qinstruments.ipynb +++ /dev/null @@ -1,260 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# QInstruments BioShake\n", - "\n", - "The QInstruments BioShake is a series of heater-cooler-shaker machines that enables:\n", - "- heating & cooling,\n", - "- locking & unlocking, and\n", - "- (orbital) shaking\n", - "\n", - "...of plates, depending on the model.\n", - "\n", - "Because all models share the same firmware, the table below lists every model supported by this backend, as well as what features they support. If a feature is called on a model that doesn't support it (e.g. shaking on a Heatplate), then a 'not supported' error will be rasied.\n", - "\n", - "| Model Name | Shaking (rpm) | Plate Lock | Heating | Active Cooling |\n", - "|----------------------|---------------|------------|---------|----------------|\n", - "| BioShake Q1 | 200-3000 | ✔️ | ✔️ | ✔️ |\n", - "| BioShake Q2 | 200-3000 | ✔️ | ✔️ | ✔️ |\n", - "| BioShake 3000 | 200-3000 | ❌ | ❌ | ❌ |\n", - "| BioShake 3000 elm | 200-3000 | ✔️ | ❌ | ❌ |\n", - "| BioShake 3000 elm DWP| 200-3000 | ✔️ | ❌ | ❌ |\n", - "| BioShake D30 elm | 200-2000 | ✔️ | ❌ | ❌ |\n", - "| BioShake 5000 elm | 200-5000 | ✔️ | ❌ | ❌ |\n", - "| BioShake 3000-T | 200-3000 | ❌ | ✔️ | ❌ |\n", - "| BioShake 3000-T elm | 200-3000 | ✔️ | ✔️ | ❌ |\n", - "| BioShake D30-T elm | 200-2000 | ✔️ | ✔️ | ❌ |\n", - "| Heatplate | none | ❌ | ✔️ | ❌ |\n", - "| ColdPlate | none | ❌ | ✔️ | ✔️ |\n", - "\n", - "\n", - "Check out the [BioShake integration manual](https://www.qinstruments.com/fileadmin/Article/All/integration-manual-en-1-8-0.pdf) for more information (or this [manual](https://www.qinstruments.com/fileadmin/Article/MANUALS/Integration-manual-en.pdf) for the Q1 and Q2 models, specifically.)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Setup Instructions (Physical)\n", - "\n", - "Please refer to the above manuals for instructions on connecting the BioShake devices to the computer. They can be connected via RS232 or USB-A port and must be plugged into a 24 VDC power supply." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Usage" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.heating_shaking import HeaterShaker\n", - "from pylabrobot.heating_shaking import BioShake\n", - "from pylabrobot.resources.coordinate import Coordinate\n", - "\n", - "str_port = \"COM1\" # Replace with the actual port connected to your computer\n", - "backend = BioShake(port=str_port)\n", - "hs = HeaterShaker(\n", - " name=\"BioShake\",\n", - " size_x=0, # TODO: physical size\n", - " size_y=0, # TODO: physical size\n", - " size_z=0, # TODO: physical size\n", - " child_location=Coordinate(0, 0, 0), # TODO: physical size\n", - " backend=backend)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "When calling `setup`, the user has the option to home the device or not. By default, the device will reset (clearing any error it's stuck in) before moving to the home zero position and locks in place. This process should take no longer than 30 seconds." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.setup(skip_home=False) # Or 'True' if you wish to skip the process" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Temperature Control\n", - "\n", - "For models that support temperature control, please use the following call functions:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.set_temperature(temperature=37) # Temperature in degrees C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.get_temperature() # Returns temperature in degrees C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.deactivate() # Stop temperature control" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Shaking Control" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For models that support shaking and/or plate locking, please use the following call functions:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.lock_plate()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.unlock_plate()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "BioShake supports acceleration for shaking and deceleration for stopping. Acceleration and deceleration correspond to the seconds it takes till it reaches full speed. The default value is 0, where it starts and stops at normal velocity. Any value higher will result in a slow acceleration/deceleration." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.shake(speed=500, acceleration=0) # speed in rpm" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.stop_shaking(deceleration=5) # Stop shaking" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Closing Connection to Machine" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.stop()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Using Multiple BioShake Devices" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "When using multiple BioShake devices, you may call them in batches." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import asyncio\n", - "\n", - "\n", - "async def setup_and_shake(hs):\n", - " await hs.setup(skip_home=False)\n", - " await hs.shake(speed=500)\n", - "\n", - "await asyncio.gather(\n", - " setup_and_shake(hs1),\n", - " setup_and_shake(hs2),\n", - " setup_and_shake(hs3),\n", - " setup_and_shake(hs4),\n", - ")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/01_material-handling/sealers/a4s.ipynb b/docs/user_guide/01_material-handling/sealers/a4s.ipynb deleted file mode 100644 index df38ffcc760..00000000000 --- a/docs/user_guide/01_material-handling/sealers/a4s.ipynb +++ /dev/null @@ -1,285 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "18708b66", - "metadata": {}, - "source": [ - "# Azenta a4S\n", - "\n", - "| Summary | Photo |\n", - "|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------|\n", - "| - [OEM Link](https://www.azenta.com/products/automated-roll-heat-sealer-formerly-a4s)
- **Communication Protocol / Hardware**: Serial / USB-A
- **Communication Level**: Firmware (documentation shared by OEM)
- **Sealing Method**: Thermal (heat + pressure)
- **Compressed Air Required?**: No
- **Typical Seal Time**: ~7 seconds

The a4S has only 2 programmatically-accessible action parameters for sealing:
- temperature
- sealing duration | ![quadrants](img/azenta_a4s.png) |\n" - ] - }, - { - "cell_type": "markdown", - "id": "adb29364", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Programmatic)\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "34531f2c", - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "markdown", - "id": "0e8abc45", - "metadata": {}, - "source": [ - "Identify your control PC's port to your a4S sealer and instantiate the `Sealer` frontend called `a4s`:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "363b8144", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.sealing import a4s\n", - "\n", - "s = a4s(port=\"/dev/tty.usbserial-0001\") # This is a predifned Sealer object with the A4SBackend\n", - "\n", - "# You can also use the Sealer class directly, e.g.:\n", - "# from pylabrobot.sealing.sealer import Sealer\n", - "# from pylabrobot.sealing.a4s_backend import A4SBackend\n", - "# s = Sealer(backend=A4SBackend(port=\"/dev/tty.usbserial-0001\"))\n", - "\n", - "type(s)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "30720acb", - "metadata": {}, - "outputs": [], - "source": [ - "await s.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "65555028", - "metadata": {}, - "source": [ - "```{note}\n", - "When the a4S is first powered on, it will open its loading tray - this means the **machine default state is open**!\n", - "\n", - "If this is the first time you are using the a4S, follow the OEM’s instructions to load a foil/film roll using the required metal film loading tool.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "7d2e9ed2", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Usage\n", - "\n", - "### Sealing\n", - "\n", - "The a4S firmware enables sealing with just one simple command:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c0834a6e", - "metadata": {}, - "outputs": [], - "source": [ - "await s.seal(\n", - " temperature=180, # degrees Celsius\n", - " duration=5, # sec\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "ce638f41", - "metadata": {}, - "source": [ - "This command will...\n", - "1. set the `temperature`\n", - "2. wait until temperature is reached (!)\n", - "3. move the plate into the machine / close the loading tray\n", - "4. cut the film off its roll (!!)\n", - "5. perform sealing of film onto the plate for the specified `duration`\n", - "6. move the plate out of the machine / open the loading tray" - ] - }, - { - "cell_type": "markdown", - "id": "9c4d21b7", - "metadata": {}, - "source": [ - "### Pre-set Temperature\n", - "\n", - "To accelerate the sealing step you can pre-set the temperature of the sealer by using the `set_temperature` method.\n", - "The temperature is set in degrees Celsius." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "97dbe69e", - "metadata": {}, - "outputs": [], - "source": [ - "await s.set_temperature(170)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bb2de16b", - "metadata": {}, - "outputs": [], - "source": [ - "await s.get_temperature()" - ] - }, - { - "cell_type": "markdown", - "id": "c4be6218", - "metadata": {}, - "source": [ - "---\n", - "### Close and Open of the Loading Tray\n", - "\n", - "The a4S does empower standalone closing and opening of the loading tray.\n", - "However, there is no conceivable reason to do so when one considers the issues this creates:\n", - "\n", - "The default position of the machine's loading tray is open.\n", - "If one executes..." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e23acc3d", - "metadata": {}, - "outputs": [], - "source": [ - "await s.close()" - ] - }, - { - "cell_type": "markdown", - "id": "c7a70d7e", - "metadata": {}, - "source": [ - "...this not only closes the loading tray but **also cuts the film/foil that is currently loaded - without performing a sealing action!**\n", - "\n", - "```{warning}\n", - "This means a **single leaf of film will fall onto the loading tray** (or on the top of a plate located on the loading tray).\n", - "```\n", - "\n", - "(This is a mechanical constraint of the a4S' design:\n", - "\n", - "Without active motors turning the film roll into the opposite direction during an `await s.close()` command the film inside the machine would be pushed inwards and buckle.\n", - "This could lead to multiple problems, including potential sticking of the film to hot internals.\n", - "As a result, the cutting of the film during close is an inbuilt, mechanical safety feature [to our knowledge])\n", - "\n", - "When executing..." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c8656ef6", - "metadata": {}, - "outputs": [], - "source": [ - "await s.open()" - ] - }, - { - "cell_type": "markdown", - "id": "8554a66c", - "metadata": {}, - "source": [ - "...the single leaf of film will then require manual removal.\n", - "\n", - "(Except if you are using some advanced soft-robotics arm that can handle films/foils 🐙👀)\n", - "\n", - "```{note}\n", - "It is possible that this cutting of film during a closing procedure disconnects the film roll with the internals.\n", - "If this happens you have to manually re-spool the film roll before you can continue.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "39a0d84f", - "metadata": {}, - "source": [ - "---\n", - "\n", - "### Querying Machine Status\n", - "\n", - "The a4S has advanced features that are available by calling the frontend's (`Sealer`/`a4s`) backend (`A4SBackend`) directly." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b85f0c49", - "metadata": {}, - "outputs": [], - "source": [ - "status = await s.backend.get_status()\n", - "print(\"current_temperature: \", status.current_temperature)\n", - "print(\"system_status: \", status.system_status)\n", - "print(\"heater_block_status: \", status.heater_block_status)\n", - "print(\"error_code: \", status.error_code)\n", - "print(\"warning_code: \", status.warning_code)\n", - "print(\"sensor_status: \")\n", - "print(\" shuttle_middle_sensor: \", status.sensor_status.shuttle_middle_sensor)\n", - "print(\" shuttle_open_sensor: \", status.sensor_status.shuttle_open_sensor)\n", - "print(\" shuttle_close_sensor: \", status.sensor_status.shuttle_close_sensor)\n", - "print(\" clean_door_sensor: \", status.sensor_status.clean_door_sensor)\n", - "print(\" seal_roll_sensor: \", status.sensor_status.seal_roll_sensor)\n", - "print(\" heater_motor_up_sensor: \", status.sensor_status.heater_motor_up_sensor)\n", - "print(\" heater_motor_down_sensor: \", status.sensor_status.heater_motor_down_sensor)\n", - "print(\"remaining_time: \", status.remaining_time)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env (3.10.15)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/01_material-handling/sealers/img/azenta_a4s.png b/docs/user_guide/01_material-handling/sealers/img/azenta_a4s.png deleted file mode 100644 index 30bf0e0dba8..00000000000 Binary files a/docs/user_guide/01_material-handling/sealers/img/azenta_a4s.png and /dev/null differ diff --git a/docs/user_guide/01_material-handling/sealers/sealers.md b/docs/user_guide/01_material-handling/sealers/sealers.md deleted file mode 100644 index 3283f401992..00000000000 --- a/docs/user_guide/01_material-handling/sealers/sealers.md +++ /dev/null @@ -1,59 +0,0 @@ -# Sealers - -## Installation - -```bash -pip install pylabrobot[serial] -``` - -In automated wet lab workflows, **microplate sealers** are essential for preserving sample integrity. -They prevent **evaporation**, **cross-contamination**, and **spillage**, especially during heating, shaking, storage, or robotic transport. - -PyLabRobot supports integration with various sealer machines, allowing you to programmatically seal plates as part of your automation workflows. - ---- - -## Types of Sealers - -There are two primary categories of sealers commonly used in automated labs: - -### Thermal Sealers - -These use heat and pressure to bond a sealing film (typically foil or heat-reactive plastic) to the top of the microplate. - -- **Examples**: Azenta a4S, Bio-Rad PX1, Agilent PlateLoc (thermal mode) -- **Best for**: Long-term storage, PCR/qPCR workflows, high-integrity applications -- **Pros**: - - Very strong seal (potentially, depends on chosen parameters) - - Compatible with a wide range of films -- **Cons**: - - Slower sealing time (typically 5–10 seconds per plate) - - Requires warm-up time - - May need precise plate/film alignment - - When peeled, thermal seals remove (at least some) well material. - Thermal seals might therefore only be usable until too much well material has been ripped off. - -### Adhesive (Pressure) Sealers - -These apply pre-cut adhesive seals to the plate using downward mechanical pressure. -They do **not** use heat, making them faster and simpler for certain workflows. - -- **Examples**: Agilent PlateLoc (adhesive mode), Thermo ALPS5000 (in adhesive mode) -- **Best for**: Medium-throughput workflows, frequent access, short-term incubation -- **Pros**: - - Faster (as low as 1–2 seconds per plate) - - No warm-up period - - Compatible with repeelable seals -- **Cons**: - - Weaker seal compared to thermal - - Not suitable for long-term storage or high-temperature protocols - ---- - - -```{toctree} -:maxdepth: 1 -:hidden: - -Azenta a4S -``` diff --git a/docs/user_guide/01_material-handling/storage/cytomat.ipynb b/docs/user_guide/01_material-handling/storage/cytomat.ipynb deleted file mode 100644 index 64883d25bf8..00000000000 --- a/docs/user_guide/01_material-handling/storage/cytomat.ipynb +++ /dev/null @@ -1,183 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "abb22091", - "metadata": {}, - "source": [ - "# TFS Cytomat Series\n", - "\n", - "The Cytomat series of incubators is used for storing microplates under\n", - "controlled environmental conditions. PyLabRobot implements the\n", - "{class}`~pylabrobot.storage.cytomat.cytomat.CytomatBackend` which\n", - "supports several models such as `C6000`, `C6002`, `C2C_50`, `C2C_425`,\n", - "`C2C_450_SHAKE` and `C5C`.\n", - "\n", - "In this tutorial we show how to:\n", - "- connect to the incubator\n", - "- configure racks\n", - "- move plates in and out\n", - "- monitor temperature and humidity\n", - "\n", - "```{note}\n", - "This notebook uses `await` statements which must be run inside an\n", - "asynchronous environment such as `asyncio`.\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b6b9218a", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.storage import CytomatBackend, CytomatType\n", - "from pylabrobot.storage.cytomat.racks import cytomat_rack_9mm_51\n", - "from pylabrobot.storage.incubator import Incubator\n", - "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n", - "from pylabrobot.resources import Coordinate\n", - "\n", - "# Connect to the incubator via a serial port\n", - "backend = CytomatBackend(model=CytomatType.C6000, port=\"/dev/ttyUSB0\")\n", - "\n", - "# Create a rack and assemble an `Incubator` resource\n", - "rack = cytomat_rack_9mm_51(\"rack_A\")\n", - "incubator = Incubator(\n", - " backend=backend,\n", - " name=\"cyto\",\n", - " size_x=860,\n", - " size_y=550,\n", - " size_z=900,\n", - " racks=[rack],\n", - " loading_tray_location=Coordinate(0, 0, 0),\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "de3fe5c6", - "metadata": {}, - "source": [ - "## Setup\n", - "\n", - "Setting up the incubator opens the serial connection and initializes the\n", - "device." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cee39594", - "metadata": {}, - "outputs": [], - "source": [ - "await incubator.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "8f379ea7", - "metadata": {}, - "source": [ - "## Storing a plate\n", - "\n", - "To store a plate we first place it on the loading tray and then call\n", - "{meth}`~pylabrobot.storage.incubator.Incubator.take_in_plate`.\n", - "You can choose a site automatically or specify one explicitly.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "25169f81", - "metadata": {}, - "outputs": [], - "source": [ - "plate = Cor_96_wellplate_360ul_Fb(\"my_plate\")\n", - "incubator.loading_tray.assign_child_resource(plate)\n", - "await incubator.take_in_plate(\"smallest\") # choose the smallest free site\n", - "\n", - "# other options:\n", - "# await incubator.take_in_plate(\"random\") # random free site\n", - "# await incubator.take_in_plate(rack[3]) # store at rack position 3\n" - ] - }, - { - "cell_type": "markdown", - "id": "31f1c241", - "metadata": {}, - "source": [ - "## Retrieving a plate\n", - "\n", - "Use {meth}`~pylabrobot.storage.incubator.Incubator.fetch_plate_to_loading_tray`\n", - "to move a plate from storage to the loading tray." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "632ec3f7", - "metadata": {}, - "outputs": [], - "source": [ - "await incubator.fetch_plate_to_loading_tray(\"my_plate\")\n", - "retrieved = incubator.loading_tray.resource" - ] - }, - { - "cell_type": "markdown", - "id": "7dc4dfb9", - "metadata": {}, - "source": [ - "## Monitoring conditions\n", - "\n", - "The Cytomat provides queries for temperature and humidity." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0d768495", - "metadata": {}, - "outputs": [], - "source": [ - "current_temp = await incubator.get_temperature()\n", - "current_humidity = await incubator.backend.get_humidity()\n", - "print(current_temp, current_humidity)" - ] - }, - { - "cell_type": "markdown", - "id": "7c27b01e", - "metadata": {}, - "source": [ - "## Shutdown\n", - "\n", - "Always close the connection when finished." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "28b68d42", - "metadata": {}, - "outputs": [], - "source": [ - "await incubator.stop()" - ] - } - ], - "metadata": { - "jupytext": { - "cell_metadata_filter": "-all", - "main_language": "python", - "notebook_metadata_filter": "-all" - }, - "language_info": { - "name": "python" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/01_material-handling/storage/img/storage_accessibility_closed_examples.png b/docs/user_guide/01_material-handling/storage/img/storage_accessibility_closed_examples.png deleted file mode 100644 index 55f29ea97b0..00000000000 Binary files a/docs/user_guide/01_material-handling/storage/img/storage_accessibility_closed_examples.png and /dev/null differ diff --git a/docs/user_guide/01_material-handling/storage/img/storage_accessibility_open_examples.png b/docs/user_guide/01_material-handling/storage/img/storage_accessibility_open_examples.png deleted file mode 100644 index c3c7d9b733b..00000000000 Binary files a/docs/user_guide/01_material-handling/storage/img/storage_accessibility_open_examples.png and /dev/null differ diff --git a/docs/user_guide/01_material-handling/storage/inheco/img/inheco_incubator_shaker_dip_switch_addressing.png b/docs/user_guide/01_material-handling/storage/inheco/img/inheco_incubator_shaker_dip_switch_addressing.png deleted file mode 100644 index f55d19773cf..00000000000 Binary files a/docs/user_guide/01_material-handling/storage/inheco/img/inheco_incubator_shaker_dip_switch_addressing.png and /dev/null differ diff --git a/docs/user_guide/01_material-handling/storage/inheco/img/inheco_incubator_shaker_mp_dwp.png b/docs/user_guide/01_material-handling/storage/inheco/img/inheco_incubator_shaker_mp_dwp.png deleted file mode 100644 index 66dbfa04234..00000000000 Binary files a/docs/user_guide/01_material-handling/storage/inheco/img/inheco_incubator_shaker_mp_dwp.png and /dev/null differ diff --git a/docs/user_guide/01_material-handling/storage/inheco/img/inheco_incubator_shaker_physical_setup_overview.png b/docs/user_guide/01_material-handling/storage/inheco/img/inheco_incubator_shaker_physical_setup_overview.png deleted file mode 100644 index e9eabddf0b4..00000000000 Binary files a/docs/user_guide/01_material-handling/storage/inheco/img/inheco_incubator_shaker_physical_setup_overview.png and /dev/null differ diff --git a/docs/user_guide/01_material-handling/storage/inheco/img/inheco_incubator_shaker_t_sensor_positioning.png b/docs/user_guide/01_material-handling/storage/inheco/img/inheco_incubator_shaker_t_sensor_positioning.png deleted file mode 100644 index 3232987ab76..00000000000 Binary files a/docs/user_guide/01_material-handling/storage/inheco/img/inheco_incubator_shaker_t_sensor_positioning.png and /dev/null differ diff --git a/docs/user_guide/01_material-handling/storage/inheco/incubator_shaker.ipynb b/docs/user_guide/01_material-handling/storage/inheco/incubator_shaker.ipynb deleted file mode 100644 index 34425a463ed..00000000000 --- a/docs/user_guide/01_material-handling/storage/inheco/incubator_shaker.ipynb +++ /dev/null @@ -1,1191 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "01bd78dc-183e-45fe-a3b0-59c8666b4f14", - "metadata": {}, - "source": [ - "# Inheco Incubator (Shaker)\n", - "\n", - "| Summary | Image |\n", - "|------------|--------|\n", - "|
  • OEM Link
  • Communication Protocol / Hardware: Serial / USB-A/B
  • Communication Level: Firmware (documentation shared by OEM)
  • Same command set for:
    • Incubator “MP”
    • Incubator “DWP”
    • Incubator Shaker “MP”
    • Incubator Shaker “DWP”
  • VID:PID 0403:6001
  • Takes in a single plate via a loading tray, heats it to the set temperature, and shakes it to the set RPM.
|
![shaker](img/inheco_incubator_shaker_mp_dwp.png)
Figure: Inheco Incubator Shaker MP & DWP models
|\n" - ] - }, - { - "cell_type": "markdown", - "id": "70f74446-c274-4803-a6ce-6c9c2d7d3bba", - "metadata": {}, - "source": [ - "## About the Machine(s)\n", - "\n", - "Inheco incubator shakers are modular machines used for plate storage, temperature control and shaking.\n", - "They differentiate themselves:\n", - "- **heater shakers** ... heat a material on which a plate is being placed; open-access; non-uniform temperature distribution around the plate; enables shaking of plate.\n", - "- **incubator shakers** ... an enclosed chamber that is being heated and houses a plate; plate access is controlled via a loading tray and a door; *highly uniform temperature distribution around the plate*; enables shaking of plate.\n", - "\n", - "The Inheco incubator devices come in 4 versions, dependent on (1) whether they provide a shaking feature & (2) the size of plates they accept:\n", - "\n", - "\n", - "| **RTS Code** | **Shaking Feature** | **Plate Format** | **Device Identifier** | **Typical Model** |\n", - "|:-------------:|:--------------:|:----------------:|:----------------------|:------------------|\n", - "| `0` | ❌ No | MP (Microplate) | `incubator_mp` | INHECO Incubator MP | \n", - "| `1` | ✅ Yes | MP (Microplate) | `incubator_shaker_mp` | INHECO Incubator Shaker MP | \n", - "| `2` | ❌ No | DWP (Deepwell Plate) | `incubator_dwp` | INHECO Incubator DWP | \n", - "| `3` | ✅ Yes | DWP (Deepwell Plate) | `incubator_shaker_dwp` | INHECO Incubator Shaker DWP | \n", - "\n", - "\n", - "```{note}\n", - "Note: All 4 machines can be controlled with the same PyLabRobot Backend, called `InhecoIncubatorShakerBackend`!\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "42739583-9f29-4063-983d-18dcdfea61ba", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Physical)" - ] - }, - { - "cell_type": "markdown", - "id": "38dcc34a", - "metadata": {}, - "source": [ - "![copy-me](img/inheco_incubator_shaker_physical_setup_overview.png)" - ] - }, - { - "cell_type": "markdown", - "id": "e0f0ef32-566b-4fa3-b358-db2a703957de", - "metadata": {}, - "source": [ - "To facilitate integration, multiple devices can be placed on top of each other to form an Incubator Shaker Stack (see infographic above), but care has to be taken to not overstrain the connections:\n", - "\n", - "Each of the 4 different shaker types requires a different amount of power.\n", - "An easier way to identify the configurations possible is to think of \"incubator power credits\" - **no stack must exceed 5 power credits** (see User and Installation Manual):\n", - "\n", - "1. An \"incubator MP\" -> 1 \"incubator power credits\" -> 5 units can be stacked on top of each other.\n", - "2. An \"incubator DWP\" -> 1.25 \"incubator power credits\" -> 4 units.\n", - "3. An \"incubator shaker MP\" -> 1.6 \"incubator power credits\" -> 3 units\n", - "4. An \"incubator shaker DWP\" -> 2.5 \"incubator power credits\" -> 2 units\n", - "\n", - "However, the machines in a single stack can be of any of the 4 types.\n", - "This means you could create stacks of: \n", - "- 2x \"incubator DWP\" (1.25 credits) + 1x \"incubator shaker DWP\" (2.5 credits)\n", - "- 3x \"incubator MP\" (1 credits) + 1x \"incubator shaker MP\" (1.6 credits) [shown in the infographic above]\n", - "\n", - "When a stack would exceed more than 5 \"incubator power credits\", you **must build multiple stacks** (ask your Inheco sales representative if you are unsure before trying this out).\n", - "\n", - "The benefit of this setup is that only **one** power cable and only **one** USB cable have to be plugged into the machine at the very bottom of a machine (i.e. stack index 0).\n", - "Machines above the bottom one only need to be connected with the machine below it using the 15-pin SUB-D connectors that come with each machine when bought from Inheco.\n", - "\n", - "```{note}\n", - "Note: In PyLabRobot, the stack is the central control element and is controlled via its own instance of the `InhecoIncubatorShakerStackBackend`.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "69f8951f", - "metadata": {}, - "source": [ - "| Explanation | Image |\n", - "|------------|--------|\n", - "|
To connect an InhecoIncubatorShakerStackBackend you must set the DIP switch identifier on the back of the bottom machine:
  • located on the back of the bottom machine,
  • defines the DIP switch configuration for the entire stack.

Setting the DIP switch to generate a machine address

Each machine has a 4-pin DIP switch. Each pin can be UP (0) or DOWN (1).

Note: the two pins to the left of the DIP switch are not part of the addressing and should remain in the DOWN position.

This forms a 4-bit binary address:
  • All pins at 0 → binary 0 0 0 0 → decimal 0
  • All pins at 1 → binary 1 1 1 1 → decimal 15 (24-1)
This address is crucial for generating valid communication commands for your Inheco stack.
|
![dip switches](img/inheco_incubator_shaker_dip_switch_addressing.png)
Figure: DIP switch layout to generate different identifiers/addresses
|\n" - ] - }, - { - "cell_type": "markdown", - "id": "d0d6256e-673a-4979-88cc-d07959ce92ea", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Programmatic)\n", - "\n", - "After the two cables have been connected to the bottom-most Inheco Incubator Shaker, you have to...\n", - "1. instantiate the `InhecoIncubatorShakerStackBackend` and give it the correct `dip_switch_id` & `stack_index`, and\n", - "2. create a `IncubatorShakerStack` frontend and give it the new backend instance.\n", - "\n", - "The \"stack\" is the central interface to all units in it.\n", - "The stack automatically identifies all units inside it (including their type), and will create both the correct connection and a physical instance for it.\n", - "\n", - "```{note}\n", - "Before a connection has been established the incubator shaker's front LED blinks.\n", - "After the connection has successfully been made, the LED will continuously be on.\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c9acf6e1-2465-42fd-bad8-f6d3fc052e97", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.storage.inheco import IncubatorShakerStack, InhecoIncubatorShakerStackBackend\n", - "\n", - "import asyncio # only needed for examples in this tutorial, optional for your purposes\n", - "import time # only needed for examples in this tutorial, optional for your purposes" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "bb6e529f-3bb3-46de-87af-5931269b04e4", - "metadata": {}, - "outputs": [], - "source": [ - "iis_stack_backend = InhecoIncubatorShakerStackBackend(dip_switch_id = 2)\n", - "\n", - "iis_stack = IncubatorShakerStack(backend=iis_stack_backend)\n", - "\n", - "await iis_stack.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "c2c7002f-a11b-402e-8100-5d9eb528a1f0", - "metadata": {}, - "source": [ - "```{note}\n", - "If you are interested in seeing information about the machine you are connecting to, you can set the `.setup()` optional argument `verbose` to `True`:\n", - "1. serial port used for connection\n", - "2. DIP switch ID used and verified\n", - "3. number of units identified in the stack\n", - "4. composition (index and type of units) of the stack \n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "0a17aadb-8373-4e24-9678-7c4453fc8661", - "metadata": {}, - "source": [ - "## Usage: Controlling Individual Units" - ] - }, - { - "cell_type": "markdown", - "id": "92e7123a-8aa7-41b7-baa9-9765bddaffe9", - "metadata": {}, - "source": [ - "### Addressing Units & Sensing Plate Presence\n", - "\n", - "The stack interface enables fast, direct access to any machine in a stack.\n", - "\n", - "Every Inheco incubator (shaker) contains an internal, reflection-based plate sensor.\n", - "(This is very useful e.g. when someone has forgotten their plate in the incubator 👀)\n", - "\n", - "Let's use this as an example of how you can address different units in the stack individually:" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "0585bf87-7d00-4d1e-9ce3-f58617d66961", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "2" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "iis_stack.num_units" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7642febc-5a5e-4e17-be6d-8a75fae7a1f4", - "metadata": { - "scrolled": true - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0 False\n", - "1 False\n" - ] - } - ], - "source": [ - "for idx in range(iis_stack.num_units):\n", - " plate_presence_check = await iis_stack[idx].request_plate_in_incubator()\n", - " print(idx, plate_presence_check)" - ] - }, - { - "cell_type": "markdown", - "id": "051629ac", - "metadata": {}, - "source": [ - "Option 2: Addressing individual units by calling the stack backend with the correct stack_index" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "04fa7214-fb34-4230-8fb4-f20e66a8e476", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0 False\n", - "1 False\n" - ] - } - ], - "source": [ - "for idx in range(iis_stack.num_units):\n", - " plate_presence_check = await iis_stack.backend.request_plate_in_incubator(\n", - " stack_index=idx\n", - " )\n", - " print(idx, plate_presence_check)" - ] - }, - { - "cell_type": "markdown", - "id": "59e6c797", - "metadata": {}, - "source": [ - "Option 3: Storing each unit as a handy variable" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "87ed2d05-620a-4e8a-9578-bd5d27a81e2a", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "False False\n" - ] - } - ], - "source": [ - "incubator_shaker_0 = iis_stack[0]\n", - "plate_presence_check_0 = await incubator_shaker_0.request_plate_in_incubator()\n", - "\n", - "incubator_shaker_1 = iis_stack[1]\n", - "plate_presence_check_1 = await incubator_shaker_1.request_plate_in_incubator()\n", - "\n", - "print(plate_presence_check_0, plate_presence_check_1)" - ] - }, - { - "cell_type": "markdown", - "id": "dea2645e-5426-43c0-9511-35b30aa290cb", - "metadata": {}, - "source": [ - "We usually use the direct indexing of the frontend method but it is up to you to choose.\n", - "e.g.: storing of units in separate variables can be very useful when using many stacks." - ] - }, - { - "cell_type": "markdown", - "id": "a30de061-fc3f-434c-882e-4972c8404479", - "metadata": {}, - "source": [ - "### Using Loading Tray" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e6009273-a626-4de9-a9db-0f0de8021a0e", - "metadata": {}, - "outputs": [], - "source": [ - "for idx in range(iis_stack.num_units):\n", - " await iis_stack[idx].open()\n", - " await asyncio.sleep(2)\n", - " await iis_stack[idx].close()" - ] - }, - { - "cell_type": "markdown", - "id": "7d86ace1-7522-4630-ad10-6f4b944fbfc9", - "metadata": {}, - "source": [ - "```{warning}\n", - "**On parallelization of commands to machines in the same incubator shaker stack**\n", - "\n", - "Each machine in the same stack communicates via the same USB(-A to -B) cable.\n", - "As a result, if you send multiple commands at the same time, they will be queued and executed one after another.\n", - "\n", - "This means you cannot open all incubator shakers in the same stack at the same time.\n", - "\n", - "However, if you arrange your Inheco Incubators into different stacks this should still be possible.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "6aaef05a-2cb9-4b12-bf86-ae6d63665036", - "metadata": {}, - "source": [ - "### Temperature Control" - ] - }, - { - "cell_type": "markdown", - "id": "7e28b749", - "metadata": {}, - "source": [ - "Show current temperature in °C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c8da2cf9-cbeb-4202-8ef7-2cc583ce16c4", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "20.1\n", - "23.6\n" - ] - } - ], - "source": [ - "for idx in range(iis_stack.num_units):\n", - " current_temp = await iis_stack[idx].get_temperature()\n", - " print(current_temp)" - ] - }, - { - "cell_type": "markdown", - "id": "e35729c9", - "metadata": {}, - "source": [ - "Time how long the machine takes to reach target temperature using standard Python - no need to re-invent the wheel" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "92fe00a9-9546-4d11-9a3c-c644188ea0c0", - "metadata": {}, - "outputs": [], - "source": [ - "target_temperature = 37\n", - "\n", - "await iis_stack[0].start_temperature_control(target_temperature)\n", - "\n", - "start_time = time.time()" - ] - }, - { - "cell_type": "markdown", - "id": "d8cf8808", - "metadata": {}, - "source": [ - "Quick check of how the temperature increases for 5 sec" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b635baae-0cc9-4d10-8644-c377f94656b9", - "metadata": { - "scrolled": true - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "20.3\n", - "20.7\n", - "21.6\n", - "22.6\n", - "23.5\n" - ] - } - ], - "source": [ - "for x in range(5):\n", - " current_temp = await iis_stack[0].get_temperature(sensor=\"main\")\n", - " print(current_temp)\n", - "\n", - " time.sleep(1)" - ] - }, - { - "cell_type": "markdown", - "id": "438e19d7-714d-46f3-8a9e-f00798ca9893", - "metadata": {}, - "source": [ - "| Explanation | Image |\n", - "|------------|--------|\n", - "|

The Inheco Incubator (Shaker) contains three independent temperature sensors:

  1. main sensor — close to the door/front, inside the machine
  2. validation sensor — back, inside the machine
  3. boost sensor — on heating foil, inside the machine

By default, iis_stack[0].get_temperature()’s argument is set to sensor=\"main\".
This can be changed to any of the following:

  • \"main\"
  • \"dif\"
  • \"boost\"
  • \"mean\" — takes all three sensors’ measurements and returns their geometric mean
|
![sensor positions](img/inheco_incubator_shaker_t_sensor_positioning.png)
Figure: Inheco Incubator Shaker Temperature Sensor Positioning
|\n" - ] - }, - { - "cell_type": "markdown", - "id": "aebaf1c5", - "metadata": {}, - "source": [ - "Wait until target temperature has been reached:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8c5caa89-e5c5-49de-996f-a13cd790aa61", - "metadata": {}, - "outputs": [], - "source": [ - "temp_reached = await iis_stack[0].wait_for_temperature(\n", - " sensor = \"mean\",\n", - " tolerance = 0.1, # ℃ - default: 0.2\n", - " interval_s = 0.2, # sec - default: 0.5\n", - " show_progress_bar = True # default: False\n", - ")\n", - "\n", - "elapsed_time = time.time() - start_time\n", - "\n", - "print(f\"\\ntime taken to reach target temperature {target_temperature}°C: {round(elapsed_time, 1)} sec\")" - ] - }, - { - "cell_type": "markdown", - "id": "55235ece", - "metadata": {}, - "source": [ - "Simple stopping of temperature control without stopping (i.e. breaking the connection) the machine itself:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "88e17cd9-de42-4f6e-9db0-b74ad74e4a85", - "metadata": {}, - "outputs": [], - "source": [ - "await iis_stack[0].stop_temperature_control()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b5a0ff93-ee2e-4049-86f7-59815163d6f2", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "False" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack[0].is_temperature_control_enabled()" - ] - }, - { - "cell_type": "markdown", - "id": "58c0aa31-9b6b-426f-a038-cdb80614c005", - "metadata": {}, - "source": [ - "### Shaking Control\n", - "\n", - "Only Incubator \"Shakers\" can use shaking commands.\n", - "\n", - "During `.setup()` the machine will check whether it is an `incubator_shaker` (\"MP\" or \"DWP\") and the Python backend only allows shaking commands being sent to the machine if it is an `incubator_shaker`, i.e. the following commands will not work if you have pure incubators." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fdac2882-59c2-4531-a115-97a644765476", - "metadata": {}, - "outputs": [], - "source": [ - "await iis_stack[0].shake(rpm=800)\n", - "\n", - "await asyncio.sleep(5)\n", - "\n", - "await iis_stack[0].stop_shaking()" - ] - }, - { - "cell_type": "markdown", - "id": "fc1da4bf-452e-4ead-bd8a-64d219449a64", - "metadata": {}, - "source": [ - "Inheco incubator shakers support precise, programmable motion in both the **X** and **Y** axes.\n", - "The resulting shaking pattern is defined by five parameters:\n", - "\n", - "- **Amplitude in X** (`Aₓ`, 0–3 mm)\n", - "- **Amplitude in Y** (`Aᵧ`, 0–3 mm)\n", - "- **Frequency in X** (`fₓ`, 6.6–30.0 Hz)\n", - "- **Frequency in Y** (`fᵧ`, 6.6–30.0 Hz)\n", - "- **Phase shift** (`φ`, the angular offset between X and Y motion, in degrees)\n", - "\n", - "Different combinations of these parameters produce circular, linear, elliptical, or\n", - "figure-eight movement paths.\n", - "\n", - "---\n", - "\n", - "#### Predefined Shaking Patterns in PyLabRobot\n", - "\n", - "To simplify configuration, PyLabRobot provides predefined motion presets that map common use cases to specific parameter combinations:\n", - "\n", - "| Pattern | Description | Parameter relationship | Required speed attribute |\n", - "|----------|--------------|------------------------|---------------------------|\n", - "| `orbital` | Circular shaking | `Aₓ = Aᵧ`, `φ = 90°`, `fₓ = fᵧ` | `rpm` |\n", - "| `elliptical` | Elliptical motion | `Aₓ ≠ Aᵧ`, `φ = 90°`, `fₓ = fᵧ` | `rpm` |\n", - "| `figure_eight` | Figure-eight (Lissajous) motion | `Aₓ ≈ Aᵧ`, `φ = 90°`, `fᵧ = 2 fₓ` | `rpm` |\n", - "| `linear_x` | Linear motion along X | `Aᵧ = 0` | `frequency_hz` |\n", - "| `linear_y` | Linear motion along Y | `Aₓ = 0` | `frequency_hz` |\n", - "\n", - "```{note}\n", - "The default behaviour of `.shake()` uses...\n", - "- an orbital shaking pattern,\n", - "- x amplitude = 3 mm,\n", - "- y amplitude = 3 mm.\n", - "\n", - "(see “Simplest usage” example above)\n" - ] - }, - { - "cell_type": "markdown", - "id": "637c7550", - "metadata": {}, - "source": [ - "Orbital shaking example with modified amplitudes" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "21ef6992-fdd2-47f7-b739-0de4cb1022e0", - "metadata": {}, - "outputs": [], - "source": [ - "await iis_stack[0].shake(\n", - " pattern=\"orbital\",\n", - " rpm=800,\n", - " amplitude_x_mm=2.0,\n", - " amplitude_y_mm=2.0\n", - ")\n", - "\n", - "await asyncio.sleep(5)\n", - "\n", - "await iis_stack[0].stop_shaking()" - ] - }, - { - "cell_type": "markdown", - "id": "2a9b5212", - "metadata": {}, - "source": [ - "Elliptical shaking example with modified amplitudes:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8b6d28a3-6c02-47cd-a5e0-1e01cc35e4d0", - "metadata": {}, - "outputs": [], - "source": [ - "await iis_stack[0].shake(\n", - " pattern=\"elliptical\",\n", - " rpm=800,\n", - " amplitude_x_mm=2.5,\n", - " amplitude_y_mm=2.5\n", - ")\n", - "\n", - "await asyncio.sleep(5)\n", - "\n", - "await iis_stack[0].stop_shaking()" - ] - }, - { - "cell_type": "markdown", - "id": "f1a72cea", - "metadata": {}, - "source": [ - "Figure-eight shaking example:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bd1b713c-0c1f-4e6c-81b0-88cee4ba8719", - "metadata": {}, - "outputs": [], - "source": [ - "await iis_stack[0].shake(\n", - " pattern=\"figure_eight\",\n", - " rpm=400,\n", - ")\n", - "\n", - "await asyncio.sleep(5)\n", - "\n", - "await iis_stack[0].stop_shaking()" - ] - }, - { - "cell_type": "markdown", - "id": "9eb382d9-3432-4d0e-9514-02f25eb7ef68", - "metadata": {}, - "source": [ - "If you feel adventurous, see the math that goes into the calculation of different shaking patterns here:" - ] - }, - { - "cell_type": "markdown", - "id": "97014c29-c023-4259-aa3f-1b7fa85c9c24", - "metadata": {}, - "source": [ - "
\n", - "📘 How PyLabRobot Implements Inheco Shaking Patterns (Mathematical Overview)\n", - "\n", - "Inheco incubator shakers move a plate by oscillating the platform in two directions — **X** and **Y** — at programmable amplitudes, frequencies, and phase offsets.\n", - "\n", - "---\n", - "\n", - "**The Core Equations**\n", - "\n", - "The motion of the platform is described by two sinusoidal functions:\n", - "\n", - "\\[\n", - "\\begin{aligned}\n", - "x(t) &= Aₓ \\sin(2\\pi fₓ t) \\\\\n", - "y(t) &= Aᵧ \\sin(2\\pi fᵧ t + φ)\n", - "\\end{aligned}\n", - "\\]\n", - "\n", - "Where:\n", - "\n", - "| Symbol | Meaning | Example |\n", - "|:--|:--|:--|\n", - "| `Aₓ`, `Aᵧ` | Amplitudes (mm) — how far the plate moves in X and Y | 2.5 mm |\n", - "| `fₓ`, `fᵧ` | Frequencies (Hz) — how fast each axis oscillates | 10 Hz, 20 Hz |\n", - "| `φ` | Phase shift (°) — timing offset between X and Y | 0°, 90°, 180° |\n", - "\n", - "Each axis moves smoothly back and forth like a spring. \n", - "When these two motions combine, they trace elegant paths such as circles, ellipses, or figure-eights.\n", - "\n", - "---\n", - "\n", - "**Pattern Intuition**\n", - "\n", - "Different shaking patterns are created by adjusting the relationships between these parameters:\n", - "\n", - "| Pattern | Conditions | Description |\n", - "|:--|:--|:--|\n", - "| **Linear X** | `Aᵧ = 0` | Motion only along X (back-and-forth line) |\n", - "| **Linear Y** | `Aₓ = 0` | Motion only along Y |\n", - "| **Orbital** | `Aₓ = Aᵧ`, `fₓ = fᵧ`, `φ = 90°` | Perfect circular motion |\n", - "| **Elliptical** | `Aₓ ≠ Aᵧ`, `fₓ = fᵧ`, `φ = 90°` | Elongated circle (ellipse) |\n", - "| **Figure-Eight (Lissajous)** | `Aₓ ≈ Aᵧ`, `fᵧ = 2 fₓ`, `φ = 90°` | Double-loop path shaped like ∞ |\n", - "\n", - "---\n", - "\n", - "**Example: Figure-Eight Motion**\n", - "\n", - "In firmware terms:\n", - "\n", - "SSP20,20,100,200,90\n", - "ASE1\n", - "\n", - "\n", - "corresponds to:\n", - "\n", - "- `Aₓ = Aᵧ = 2.0 mm`\n", - "- `fₓ = 10.0 Hz`\n", - "- `fᵧ = 20.0 Hz`\n", - "- `φ = 90°`\n", - "\n", - "This combination makes the platform’s Y motion twice as fast as its X motion — \n", - "the resulting path is a **Lissajous figure**, visually resembling a “figure-8”.\n", - "\n", - "---\n", - "\n", - "**Why This Matters**\n", - "\n", - "By controlling these parameters precisely:\n", - "- The **mixing efficiency** can be tuned to the liquid’s viscosity.\n", - "- The **path geometry** affects shear stress and aeration.\n", - "- **Repeatable motion profiles** ensure reproducibility across runs.\n", - "\n", - "Understanding this relationship helps you select the right pattern\n", - "(`orbital`, `elliptical`, `figure_eight`, etc.) for your experiment.\n", - "\n", - "
\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "id": "d18c1605-e179-4b89-932c-a1d1fd00ae10", - "metadata": {}, - "source": [ - "### Empowerment Showcase\n", - "\n", - "With control of multiple single incubator shakers a whole array of complex experimental & optimisation processes is possible.\n", - "\n", - "This PyLabRobot integration aims to make these machine powers as accessible as possible.\n", - "\n", - "One still relatively simple example:\n", - "Parallelize shaking of different incubators with different shaking + temperature conditions ... did someone say \"Design of Experiments\" 👀📊" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9016a568-3e26-4041-95cf-149d6fbe9bd6", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Waiting for target temperature 29.00 °C...\n", - "\n", - "[████████████████████████████████████----] 29.20 °C (Δ=0.20 °C | ETA: 3.0s)\n", - "[OK] Target temperature reached.\n", - "Waiting for target temperature 37.00 °C...\n", - "\n", - "[█████████████---------------------------] 36.87 °C (Δ=0.13 °C | ETA: 4.3s)\n", - "[OK] Target temperature reached.\n" - ] - } - ], - "source": [ - "await iis_stack[0].start_temperature_control(29)\n", - "await iis_stack[1].start_temperature_control(37)\n", - "\n", - "await iis_stack[0].wait_for_temperature(sensor=\"mean\", show_progress_bar=True)\n", - "await iis_stack[1].wait_for_temperature(sensor=\"mean\", show_progress_bar=True)\n", - "\n", - "\n", - "await iis_stack[0].shake(\n", - " pattern=\"orbital\",\n", - " rpm=500,\n", - ")\n", - "\n", - "await iis_stack[1].shake(\n", - " pattern=\"figure_eight\",\n", - " rpm=800,\n", - ")\n", - "\n", - "await asyncio.sleep(10)\n", - "\n", - "await iis_stack[0].stop_temperature_control()\n", - "await iis_stack[1].stop_temperature_control()\n", - "\n", - "await iis_stack[0].stop_shaking()\n", - "await iis_stack[1].stop_shaking()" - ] - }, - { - "cell_type": "markdown", - "id": "4db6a1e0-ed03-4359-9ab8-e19246d082cf", - "metadata": {}, - "source": [ - "### Self Test / Maintenance (PLR beta)\n", - "\n", - "The Inheco firmware provides a \"self-test\" which checks the drawer, temperature and shaking features.\n", - "This test can take up to 5 min.\n", - "\n", - "The test *must be* performed without a plate in the incubator.\n", - "\n", - "It generates a binary code in which each position represents a machine subsystem:\n", - "- Bit 0: Drawer\n", - "- Bit 1: Homogeneity Sensor 3 versus Sensor 1 (>2 K)\n", - "- Bit 2: Homogeneity Sensor 2 versus Sensor 1 (>2 K)\n", - "- Bit 3: Sensor 1 doesn’t reach Target Temperature after 130 sec.\n", - "- Bit 4: Y-Amplitude Shaker\n", - "- Bit 5: X-Amplitude Shaker\n", - "- Bit 6: Phase Shift Shaker\n", - "- Bit 7: Y-Frequency Shaker\n", - "- Bit 8: X-Frequency Shaker\n", - "- Bit 9: Line Boost-Heater broken\n", - "- Bit 10: Line Main-Heater broken\n", - "\n", - "A `0` means no error has been found for that subsystem, and a `1` means there is a hardware fault." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1c46a480-6d0f-4792-b388-12ce9c3513f6", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{\n", - " \"drawer_error\": False,\n", - " \"homogeneity_sensor_3_vs_1_error\": False,\n", - " \"homogeneity_sensor_2_vs_1_error\": False,\n", - " \"sensor_1_target_temp_error\": False,\n", - " \"y_amplitude_shaker_error\": False,\n", - " \"x_amplitude_shaker_error\": False,\n", - " \"phase_shift_shaker_error\": False,\n", - " \"y_frequency_shaker_error\": False,\n", - " \"x_frequency_shaker_error\": False,\n", - " \"line_boost_heater_broken\": False,\n", - " \"line_main_heater_broken\": False,\n", - "}\n" - ] - }, - "execution_count": 19, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack[0].perform_self_test()" - ] - }, - { - "cell_type": "markdown", - "id": "a2d15a39-a2e4-4d7e-ac9a-5b93770bfa9b", - "metadata": {}, - "source": [ - "This is a beta feature in PyLabRobot and we will verify the interpretation with the PyLabRobot supporting OEM, Inheco - all our machines appear to be fully functional, i.e. we couldn't check whether a faulty machine will correctly be flagged by this self-test." - ] - }, - { - "cell_type": "markdown", - "id": "e9c95159-484c-4a21-9cdb-43c4ee017bbe", - "metadata": {}, - "source": [ - "---\n", - "## Usage: Master Control via the Stack Frontend 🦾\n", - "\n", - "Even though loops make setting temperatures fast and efficient, we found it is too much code.\n", - "\n", - "This is why we enabled the frontend to have \"master control commands\" for all units in a stack." - ] - }, - { - "cell_type": "markdown", - "id": "f81a3965-280f-4163-8fae-53db746e6c62", - "metadata": {}, - "source": [ - "### Querying Statuses" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9d9a782b-49f0-4c5f-8c33-504c44ccc289", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: 'closed', 1: 'closed'}" - ] - }, - "execution_count": 20, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack.request_loading_tray_states()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "445accca-0dc4-4713-bbe5-4212ff8741d8", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: False, 1: False}" - ] - }, - "execution_count": 21, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack.request_temperature_control_states()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "96a443ac-dfb8-4744-9f42-14e74c84f333", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: False, 1: False}" - ] - }, - "execution_count": 22, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack.request_shaking_states()" - ] - }, - { - "cell_type": "markdown", - "id": "a1660de6-b823-45ca-ada3-916c2e17d571", - "metadata": {}, - "source": [ - "### Master Commands - Loading Trays" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1c71f19f-10f5-4a38-9dad-b87967b89220", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: 'open', 1: 'open'}" - ] - }, - "execution_count": 23, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack.open_all()\n", - "\n", - "await iis_stack.request_loading_tray_states()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "57fe0f47-f2ab-44db-bc0f-5cbea1f11e2c", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: 'closed', 1: 'closed'}" - ] - }, - "execution_count": 24, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack.close_all()\n", - "\n", - "await iis_stack.request_loading_tray_states()" - ] - }, - { - "cell_type": "markdown", - "id": "6660d186-971a-42dc-927f-52b1689de27c", - "metadata": {}, - "source": [ - "### Master Commands - Temperature Control" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d534ff6a-7b7b-45f7-aa6a-07c0387f286f", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: 37.8, 1: 34.4}" - ] - }, - "execution_count": 25, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack.start_all_temperature_control(target_temperature=37)\n", - "\n", - "await asyncio.sleep(10)\n", - "\n", - "await iis_stack.get_all_temperatures()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7e98bbf3-aed4-4ae8-a888-9c588d5189a2", - "metadata": {}, - "outputs": [], - "source": [ - "await iis_stack.stop_all_temperature_control()" - ] - }, - { - "cell_type": "markdown", - "id": "71fb31e4-9cd5-4f4b-8bdb-2bb1e6e2835c", - "metadata": {}, - "source": [ - "### Master Commands - Shaking Control" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "163db59d-3ae2-4dec-880a-53b323ca00bc", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: False, 1: False}" - ] - }, - "execution_count": 27, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack.request_shaking_states()" - ] - }, - { - "cell_type": "markdown", - "id": "7f34eb41-cb4a-4fbb-8c7c-36400dead6c4", - "metadata": {}, - "source": [ - "## Closing Connection\n", - "\n", - "Standard PyLabRobot way of closing the communication to the machine, i.e. the stack:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2c6fd6d7-c85c-4fc3-a35c-e23b9b18ee1d", - "metadata": {}, - "outputs": [], - "source": [ - "await iis_stack.stop()" - ] - }, - { - "cell_type": "markdown", - "id": "20cf4f13-0e54-4c26-9201-83140b738c35", - "metadata": {}, - "source": [ - "This stops all temperature control, and all shaking before disconnecting from the stack." - ] - }, - { - "cell_type": "markdown", - "id": "509f8bc7-9e9c-4250-b854-b2a0c8ded9e8", - "metadata": {}, - "source": [ - "```{note}\n", - "If you develop a small script that you find yourself re-using and that goes beyond the simple \"hello world, inheco incubator shaker\"-style examples here, please consider contributing it back to the PyLabRobot community as a Cookbook Recipe.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "98db023d-d081-4bce-be7e-466735b439c7", - "metadata": {}, - "source": [ - "---\n", - "## Usage: Multiple Stacks\n", - "\n", - "To connect more than one machine stack:\n", - "- instantiate a separate backend and frontend for each,\n", - "- you **must** hand the serial port to each stack's backend explicitly\n", - "\n", - "```{note}\n", - "When using one stack, PyLabRobot finds the machine's port automatically based on its unique VID:PID,\n", - "if multiple machines are found with the same VID:PID there is ambiguity\n", - "- e.g. the VSpin & Cytation 5 use the same identifier combo :')\n", - "```\n", - "- perform a setup for each stack. \n", - "\n", - "(set on the back of the bottom-most machine):\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1f7a4e50-9888-4327-8de1-097fe523590a", - "metadata": {}, - "outputs": [], - "source": [ - "iis_stack_backend_0 = InhecoIncubatorShakerStackBackend(dip_switch_id = 2, port=\"/dev/cu.usbserial-130\")\n", - "iis_stack_0 = IncubatorShakerStack(backend=iis_stack_backend_0)\n", - "await iis_stack.setup(verbose=True)\n", - "\n", - "iis_stack_backend_1 = InhecoIncubatorShakerStackBackend(dip_switch_id = 7, port=\"/dev/cu.usbserial-42\")\n", - "iis_stack_1 = IncubatorShakerStack(backend=iis_stack_backend_1)\n", - "await iis_stack_1.setup(verbose=True)\n", - "\n", - "iis_stack_backend_2 = InhecoIncubatorShakerStackBackend(dip_switch_id = 11, port=\"/dev/cu.usbserial-123\")\n", - "iis_stack_2 = IncubatorShakerStack(backend=iis_stack_backend_2)\n", - "await iis_stack_2.setup(verbose=True)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.11" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/01_material-handling/storage/inheco/scila.ipynb b/docs/user_guide/01_material-handling/storage/inheco/scila.ipynb deleted file mode 100644 index ea3a9768ab7..00000000000 --- a/docs/user_guide/01_material-handling/storage/inheco/scila.ipynb +++ /dev/null @@ -1,480 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "fc3ebf5f", - "metadata": {}, - "source": [ - "# Inheco SCILA\n", - "\n", - "| Summary | Image |\n", - "|------------|--------|\n", - "|
  • Automated CO₂-controlled incubator with 4 independently accessible drawers for SBS-format plates.
  • OEM Link
  • Communication Protocol / Hardware: SiLA 2 (SOAP/HTTP) / Ethernet
  • Communication Level: SiLA 2 interface (documentation shared by OEM)
  • 4 independent drawers for SBS-format plates
  • Temperature control (single zone, all drawers)
  • CO₂ and H₂O valve monitoring
  • Humidification reservoir level monitoring
  • Only one drawer can be open at a time
|
![scila](img/inheco_scila.png)
Figure: Inheco SCILA
|" - ] - }, - { - "cell_type": "markdown", - "id": "201cd7c5", - "metadata": {}, - "source": "## Setup (Physical)\n\nThe SCILA communicates over Ethernet using the SiLA 2 protocol. To connect, you need:\n1. The IP address of the SCILA on your network.\n2. (Optional) The IP address of your client machine — auto-detected if omitted.\n\nThe backend starts a local HTTP server to receive asynchronous responses from the SCILA." - }, - { - "cell_type": "markdown", - "id": "ee0d5aa9-d897-480b-b292-9b375807ec5b", - "metadata": {}, - "source": "## Setup (Programmatic)" - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "be3f3cf1-9529-4fe1-a3dc-6e60adbfe979", - "metadata": {}, - "outputs": [], - "source": [ - "import asyncio" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "474289aa", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.storage.inheco.scila import SCILABackend\n", - "\n", - "scila = SCILABackend(scila_ip=\"169.254.1.117\")\n", - "await scila.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "3cdfd2e4-90e5-46b9-a106-3d7b9f6afefd", - "metadata": {}, - "source": [ - "## Usage" - ] - }, - { - "cell_type": "markdown", - "id": "da156d46", - "metadata": {}, - "source": "### Status Requests" - }, - { - "cell_type": "markdown", - "id": "0lk1xxxljdj", - "metadata": {}, - "source": [ - "Device status (`\"standBy\"`, `\"inError\"`, `\"startup\"`, ...):" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "d5dc6eda", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'idle'" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_status()" - ] - }, - { - "cell_type": "markdown", - "id": "s58zkv4u6in", - "metadata": {}, - "source": [ - "Water level in the built-in humidification reservoir (e.g. `\"High\"`, `\"Low\"`). The SCILA uses this reservoir to maintain humidity inside the drawers:" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "faaef501", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Empty'" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_liquid_level()" - ] - }, - { - "cell_type": "markdown", - "id": "lbq75ufdvi", - "metadata": {}, - "source": [ - "Drawer status for all 4 drawers:" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "5bc58e44", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Closed'" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_drawer_status(1)" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "4251a9e7-4b9f-47ff-bae5-7b51cc9dc68a", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{1: 'Closed', 2: 'Closed', 3: 'Closed', 4: 'Closed'}" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_drawer_statuses()" - ] - }, - { - "cell_type": "markdown", - "id": "d65rfs6q106", - "metadata": {}, - "source": [ - "CO₂ and H₂O valve status:" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "6e7b7e2a", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'H2O': 'Opened', 'CO2 Normal': 'Opened', 'CO2 Boost': 'Closed'}" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_valve_status()" - ] - }, - { - "cell_type": "markdown", - "id": "xukq5oku2wr", - "metadata": {}, - "source": [ - "CO₂ flow status:" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "0b4dd0ce", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'NOK'" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_co2_flow_status()" - ] - }, - { - "cell_type": "markdown", - "id": "cqkptekq5n", - "metadata": {}, - "source": [ - "Status of a single drawer:" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "d30745a8", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Closed'" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_drawer_status(3)" - ] - }, - { - "cell_type": "markdown", - "id": "e5c47abe", - "metadata": {}, - "source": "### Drawer Control\n\nOnly one drawer can be open at a time. Opening a second drawer while one is already open will raise an error." - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "63ea94b0", - "metadata": {}, - "outputs": [], - "source": [ - "await scila.open(2)" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "3d1bca31", - "metadata": {}, - "outputs": [], - "source": [ - "await scila.close(2)" - ] - }, - { - "cell_type": "markdown", - "id": "f6d1452a", - "metadata": {}, - "source": "### Temperature Control\n\nThe SCILA has a single temperature zone shared across all 4 drawers." - }, - { - "cell_type": "markdown", - "id": "bxbcga2l5a", - "metadata": {}, - "source": [ - "Current temperature in °C:" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "f88c9c83", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "23.65" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.measure_temperature()" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "cc2f6063", - "metadata": {}, - "outputs": [], - "source": [ - "await scila.start_temperature_control(37.0)" - ] - }, - { - "cell_type": "markdown", - "id": "dpc7iwuky", - "metadata": {}, - "source": [ - "Check the target temperature and current temperature after starting:" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "id": "5f04d0ef", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "37.0" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_target_temperature()" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "id": "02a60f32", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "24.53" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await asyncio.sleep(4)\n", - "\n", - "await scila.measure_temperature()" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "id": "470241e1", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.is_temperature_control_enabled()" - ] - }, - { - "cell_type": "markdown", - "id": "mq0aijzng", - "metadata": {}, - "source": [ - "Stop temperature control and verify it is disabled:" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "id": "5881c2ce", - "metadata": {}, - "outputs": [], - "source": [ - "await scila.stop_temperature_control()" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "id": "ac8ad797", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "False" - ] - }, - "execution_count": 18, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.is_temperature_control_enabled()" - ] - }, - { - "cell_type": "markdown", - "id": "iq7rt8xr7zg", - "metadata": {}, - "source": "## Closing Connection\n\nClose the SiLA 2 HTTP server and disconnect from the SCILA." - }, - { - "cell_type": "code", - "execution_count": 19, - "id": "oshdz1zxyz", - "metadata": {}, - "outputs": [], - "source": [ - "await scila.stop()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.11" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file diff --git a/docs/user_guide/01_material-handling/storage/liconic.ipynb b/docs/user_guide/01_material-handling/storage/liconic.ipynb deleted file mode 100644 index 051b0cb40a5..00000000000 --- a/docs/user_guide/01_material-handling/storage/liconic.ipynb +++ /dev/null @@ -1,260 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "b63b4656", - "metadata": {}, - "source": [ - "# Liconic STX Series\n", - "\n", - "The Liconic STX line of automated incubators come in a variety of sizes including STX 1000, STX 500, STX 280, STX 220, STX 110, STX 44. Which corresponds to the number of plates each size can store using the standard 22 plate capacity cassettes/cartridges (plate height 17mm, 505mm total height.) There are other cassette size for plates height ranging from 5 to 104mm in height (higher plates = less number of plates storage capacity.)\n", - "\n", - "The Liconic STX line comes in a variety of climate control options including Ultra High Temp. (HTT), Incubator (IC), Dry Storage (DC2), Humid Cooler (HC), Humid Wide Range (HR), Dry Wide Range (DR2), Humidity Controlled (AR), Deep Freezer (DF) and Ultra Deep Freezer (UDF). Each have different ranges of temperatures and humidity control ability.\n", - "\n", - "Other accessories that can be included with the STX and can be utilized with this driver include N2 gassing, CO2 gassing, a Turn Station (rotation of plates 90 degrees on the transfer station), internal barcode scanners, a swap station (two transfer plate positions that can be rotated 180 degrees) and internal shaking. \n", - "\n", - "This tutorial shows how to\n", - " - Connect the Liconic incubator\n", - " - Configure racks\n", - " - Move plates in and out\n", - " - Set and monitor temperature and humidity values" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fcd75e15", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.barcode_scanners import BarcodeScanner, KeyenceBarcodeScannerBackend\n", - "from pylabrobot.resources.coordinate import Coordinate\n", - "from pylabrobot.storage import ExperimentalLiconicBackend\n", - "from pylabrobot.storage.incubator import Incubator\n", - "from pylabrobot.storage.liconic.racks import liconic_rack_17mm_22, liconic_rack_44mm_10\n", - "\n", - "\n", - "barcode_scanner_backend = KeyenceBarcodeScannerBackend(port=\"COM4\")\n", - "barcode_scanner = BarcodeScanner(backend=barcode_scanner_backend)\n", - "\n", - "liconic_backend = ExperimentalLiconicBackend(port=\"COM3\", model=\"STX220_HC\", barcode_scanner=barcode_scanner)\n", - "\n", - "rack = [\n", - " liconic_rack_44mm_10(\"cassette_0\"),\n", - " liconic_rack_44mm_10(\"cassette_1\"),\n", - " liconic_rack_44mm_10(\"cassette_2\"),\n", - " liconic_rack_17mm_22(\"cassette_3\"),\n", - " liconic_rack_17mm_22(\"cassette_4\"),\n", - " liconic_rack_17mm_22(\"cassette_5\"),\n", - " liconic_rack_17mm_22(\"cassette_6\"),\n", - " liconic_rack_17mm_22(\"cassette_7\"),\n", - " liconic_rack_17mm_22(\"cassette_8\"),\n", - " liconic_rack_17mm_22(\"cassette_9\")\n", - "]\n", - "\n", - "incubator = Incubator(\n", - " backend=liconic_backend,\n", - " name=\"My Incubator\",\n", - " size_x=100, size_y=100, size_z=100, # stubs for now...\n", - " racks=rack,\n", - " loading_tray_location=Coordinate(x=0, y=0, z=0),\n", - ")\n", - "\n", - "await incubator.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "19b3a6cc", - "metadata": { - "vscode": { - "languageId": "plaintext" - } - }, - "source": [ - "## Setup\n", - "\n", - "To setup the incubator and start sending commands first the backed needs to be declared. For the Liconic the LiconcBackend class is used with the COM port used for connection (in this case COM3) and the model needs to specified (in this case the STX 220 Humid Cooler, STX220_HC). If an internal barcode is installed the barcode_installed parameter is set to True and its COM port is also specified. These two parameters are optional so can be omitted for Liconics without an internal barcode scanner. \n", - "\n", - "Given a STX 220 (220 plate position / 22 plates per rack = 10 racks) can hold 10 racks the list of racks is built and includes mixing and matching different plate height racks. The differences in racks are handled prior to plate retrieval and storage. \n", - "\n", - "Once the these are built the base Incubator class is created and the connection to the incubator is initialized using:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9d7a4f49", - "metadata": {}, - "outputs": [], - "source": [ - "await incubator.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "52f79811", - "metadata": {}, - "source": [ - "## Usage\n", - "\n", - "To store a plate first a plate resource is initialized and then assigned to the loading tray. The method take_in_plate is then called on the incubator object." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d26e039d", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import Azenta4titudeFrameStar_96_wellplate_200ul_Vb\n", - "\n", - "new_plate = Azenta4titudeFrameStar_96_wellplate_200ul_Vb(name=\"TEST\")\n", - "incubator.loading_tray.assign_child_resource(new_plate)\n", - "await incubator.take_in_plate(\"smallest\") # choose the smallest free site\n", - "\n", - "# other options:\n", - "# await incubator.take_in_plate(\"random\") # random free site\n", - "# await incubator.take_in_plate(rack[3]) # store at rack position 3" - ] - }, - { - "cell_type": "markdown", - "id": "85dcddb7", - "metadata": {}, - "source": [ - "To retrieve a plate the plate name can used" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "00308838", - "metadata": {}, - "outputs": [], - "source": [ - "await incubator.fetch_plate_to_loading_tray(plate_name=\"TEST\")\n", - "retrieved = incubator.loading_tray.resource" - ] - }, - { - "cell_type": "markdown", - "id": "0045e703", - "metadata": {}, - "source": [ - "You can also print a barcode from this call (if barcode is installed per the backend insatiation). Returning of the barcode as a return object still needs to be implemented. Currently the barcode is just printed to the terminal.\n", - "\n", - "Barcode can returned by setting the read_barcode to True for \n", - "- take_in_plate\n", - "- fetch_plate_to_loading_tray\n", - "- move_position_to_position" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c8730560", - "metadata": {}, - "outputs": [], - "source": [ - "position = rack[9][0] # rack number 9 position 1\n", - "\n", - "await incubator.fetch_plate_to_loading_tray(plate_name=\"TEST\", read_barcode=True)\n", - "\n", - "await incubator.take_in_plate(position, read_barcode=True)\n" - ] - }, - { - "cell_type": "markdown", - "id": "d137d333", - "metadata": {}, - "source": [ - "move plate from one internal position to another internal position" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1aa68983", - "metadata": {}, - "outputs": [], - "source": [ - "await liconic_backend.move_position_to_position(plate=new_plate, dest_site=position, read_barcode=True)\n", - "# will set new_plate.barcode to the barcode read from the plate at position and move it to position." - ] - }, - { - "cell_type": "markdown", - "id": "14efdf69", - "metadata": {}, - "source": [ - "The humdity, temperature, N2 gas and CO2 gas levels can all be controlled and queried. For temperature for example:\n", - "\n", - "- To get the current temperature" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "73e38f2a", - "metadata": {}, - "outputs": [], - "source": [ - "temperature = await liconic_backend.get_temperature() # returns temperature as float in Celsius to the 10th place\n", - "print(str(temperature))" - ] - }, - { - "cell_type": "markdown", - "id": "c7383277", - "metadata": {}, - "source": [ - "- To set the temperature of the Liconic" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2c51c385", - "metadata": {}, - "outputs": [], - "source": [ - "await incubator.set_temperature(8.0) # set the temperature to 8 degrees Celsius" - ] - }, - { - "cell_type": "markdown", - "id": "4f07f349", - "metadata": {}, - "source": [ - "- You can also retrieve the set value (the value sent for set_temperature) using:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "288dea91", - "metadata": {}, - "outputs": [], - "source": [ - "set_temperature = await liconic_backend.get_target_temperature() # will return a float for the set temperature in degrees Celsius" - ] - }, - { - "cell_type": "markdown", - "id": "3a1d9ef3", - "metadata": {}, - "source": [ - "This pattern is the same for CO2, N2 and Humidity control of the Liconic. " - ] - } - ], - "metadata": { - "language_info": { - "name": "python" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/01_material-handling/storage/storage.rst b/docs/user_guide/01_material-handling/storage/storage.rst deleted file mode 100644 index 698d153b308..00000000000 --- a/docs/user_guide/01_material-handling/storage/storage.rst +++ /dev/null @@ -1,145 +0,0 @@ -Storage -======= - -A storage machine is defined as a **machine whose primary feature is** - -- `storage` of materials (e.g. wellplates, tipracks, tubes, ...) with *some form of automatable feature*. - -Examples of this simplest form of a storage machine include: - -- `Agilent Labware MiniHub `_ - open storage of labware with rotation feature -- `Lab Services PlateCarousel `_ - open storage of labware with rotation feature - - -However, this purposefully broad definition means most storage machines also include other features such as: - -- `automated material retrieval` (e.g. `Cytomat™ 2 Hotel Automated Storage `_) -- `heating` (e.g. "incubators") -- `active cooling` (e.g. "fridges" and "freezers") -- `shaking` (e.g. "incubator shakers") -- `barcode scanning` (1D and/or 2D barcodes) - -.. raw:: html - -
- - Note: The difference between a storage machine and a "plate hotel" - -
-

- Across biowetlab automation there are many terms that do not have a standardised definition, - leading to confusion and misunderstandings when automators communicate. -

-

- The term plate hotel is one of them. - We can differentiate between passive storage systems (e.g. shelves) and active - storage systems (e.g. automated retrieval system or an opening tray.) -

-

- In PLR, passive systems are not considered machines, they are just labware/Resources. - For example, a PlateCarrier can be considered a passive storage system (whether it is - vertical or horizontal). -

-

- We use the term storage machine to refer to machines that store materials - and have some form of automatable feature. - This is a more precise definition that avoids the ambiguity of the term plate hotel. -

-

- The only time the term plate hotel or hotel is used in PyLabRobot is when - referring to the name of a specific machine, such as the - - TFS Cytomat™ 2 Hotel Automated Storage. - In this case, it is used as a noun to refer to a specific product. -

-
- ------------------------------------------- - -Material Access ------------------ - -The way a storage machine allows access to materials determines how easily other machines -(e.g. robotic arms, grippers, pipettors) can interface with it. PyLabRobot distinguishes -storage machines using two axes: - -Retrieval Pattern: Stacking (Sequential) vs. Random Access -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. list-table:: - :widths: 50 50 - :header-rows: 1 - - * - **Stacking Access (Sequential)** - - **Random Access** - * - Materials stored in a fixed order (e.g. vertical stack, rotating carousel). - Only the top/front-most item is accessible without mechanical movement. - - Materials stored in individually addressable slots or shelves. - Any item can be accessed directly. - * - Slower access time for deeper items. - - Faster access to any item. - * - Simpler mechanics, smaller footprint. - - More flexible but mechanically complex. - * - **Examples:** - - - Agilent Labware MiniHub - - Lab Services PlateCarousel - - **Examples:** - - - Thermo Cytomat 2 C450 - - LiCONiC STX Series - -Accessibility: Open vs. Closed Storage -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. list-table:: - :widths: 50 50 - :header-rows: 1 - - * - **Open Storage** - - **Closed Storage** - * - Materials are exposed without obstruction. - No barrier between the robot and the stored material. - - Materials enclosed in a chamber. - Access requires opening a door, drawer, or robotic port. - * - Simplifies integration and visual inspection. - - Enables environmental control (temperature, humidity, sterility). - * - No protection from contamination or temperature drift. - - Ideal for incubators, cold storage, and sterile handling. - * - **Examples:** - - Agilent Labware MiniHub - - Manual stackers - - **Examples:** - - Thermo Cytomat 2 - - LiCONiC STX incubators - -Combined Retrieval & Access Summary -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. list-table:: - :header-rows: 1 - - * - - - **Open Storage** - - **Closed Storage** - * - **Stacking Access (Sequential)** - - Agilent Labware MiniHub - Lab Services PlateCarousel - - STX incubators with drawer-based shelves - * - **Random Access** - - Rare in open format (e.g. manual racks) - - Thermo Cytomat 2 C450 - LiCONiC STX Series - - ------------------------------------------- - -.. toctree:: - :maxdepth: 1 - :hidden: - - cytomat - inheco/incubator_shaker - inheco/scila - liconic diff --git a/docs/user_guide/01_material-handling/temperature-controllers/hamilton-heater-cooler.ipynb b/docs/user_guide/01_material-handling/temperature-controllers/hamilton-heater-cooler.ipynb deleted file mode 100644 index 051ad2cc8b8..00000000000 --- a/docs/user_guide/01_material-handling/temperature-controllers/hamilton-heater-cooler.ipynb +++ /dev/null @@ -1,135 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "18708b66", - "metadata": {}, - "source": [ - "\n", - "# Hamilton Heater Cooler (HHC)\n", - "\n", - "| Summary | Photo |\n", - "|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------|\n", - "| - [OEM Link](https://www.hamiltoncompany.com/temperature-control/hamilton-heater-cooler)
- **Communication Protocol / Hardware**: ? / ?
- **Communication Level**: Firmware

- **Temperature range**: 0 to 110°C
| ![quadrants](img/hamilton_heater_cooler.png) |\n" - ] - }, - { - "cell_type": "markdown", - "id": "a73f89dd", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Physical)\n", - "\n", - "WIP" - ] - }, - { - "cell_type": "markdown", - "id": "adb29364", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Programmatic)\n", - "\n", - "WIP" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "34531f2c", - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "30720acb", - "metadata": {}, - "outputs": [], - "source": [ - "await hhc.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "7d2e9ed2", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Usage\n", - "\n", - "### Temperature control\n", - "\n", - "WIP" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "97dbe69e", - "metadata": {}, - "outputs": [], - "source": [ - "await hhc.set_temperature(70)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0004d35d", - "metadata": {}, - "outputs": [], - "source": [ - "await hhc.wait_for_temperature()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bb2de16b", - "metadata": {}, - "outputs": [], - "source": [ - "await hhc.get_temperature()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4e9e17d7", - "metadata": {}, - "outputs": [], - "source": [ - "await hhc.deactivate()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env (3.10.15)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/01_material-handling/temperature-controllers/img/hamilton_heater_cooler.png b/docs/user_guide/01_material-handling/temperature-controllers/img/hamilton_heater_cooler.png deleted file mode 100644 index 314ff42f2e3..00000000000 Binary files a/docs/user_guide/01_material-handling/temperature-controllers/img/hamilton_heater_cooler.png and /dev/null differ diff --git a/docs/user_guide/01_material-handling/temperature-controllers/img/ot_temperature_module_gen2.webp b/docs/user_guide/01_material-handling/temperature-controllers/img/ot_temperature_module_gen2.webp deleted file mode 100644 index e30be2e62b2..00000000000 Binary files a/docs/user_guide/01_material-handling/temperature-controllers/img/ot_temperature_module_gen2.webp and /dev/null differ diff --git a/docs/user_guide/01_material-handling/temperature-controllers/inheco.ipynb b/docs/user_guide/01_material-handling/temperature-controllers/inheco.ipynb deleted file mode 100644 index dc6d46422ad..00000000000 --- a/docs/user_guide/01_material-handling/temperature-controllers/inheco.ipynb +++ /dev/null @@ -1,222 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Hello World, Inheco CPAC!\n", - "\n", - "The Inheco CPAC is a `TemperatureController` machine that enables:\n", - "- heating & cooling\n", - "\n", - "...of plates.\n", - "\n", - "- Variants:\n", - " - **CPAC Microplate**:\n", - " - Part number: 7000179\n", - " - Temperature: +4°C to +70°C\n", - " - **CPAC Microplate HT 2TEC**:\n", - " - Part number: 7000163\n", - " - Temperature: +4°C to + 110°C\n", - " - **CPAC Ultraflat**:\n", - " - Part number: 7000190, 7000193\n", - " - Temperature: +4°C to +70°C\n", - " - **CPAC Ultraflat HT 2TEC**:\n", - " - Part number: 7000166, 7000165\n", - " - Temperature: +4°C to + 110°C\n", - "\n", - "Check out the [CPAC User and installation manual](https://www.inheco.com/data/pdf/cpac-manual-1019-0826-30.pdf) for more information." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Setup Instructions (Physical)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "\n", - "Connect the ThermoShake to the Inheco TEC Control Box using the provided cable. The Control Box can control up to 6 ThermoShakes. Plug the Control Box into a power outlet and connect it to your computer using a USB B cable.\n", - "\n", - "There are two versions of the TEC Control Box:\n", - "\n", - "- The Inheco Single TEC Control (STC) unit controls one device.\n", - "- The Inheco Multi TEC Control (MTC) unit can control up to 6 Inheco devices in parallel.\n", - "\n", - "See [https://www.inheco.com/tec-controller-and-slot-modules.html](https://www.inheco.com/tec-controller-and-slot-modules.html) for more information like slot module variants.\n", - "\n", - "Also check out the [CPAC User and installation manual](https://www.inheco.com/data/pdf/cpac-manual-1019-0826-30.pdf)." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Usage" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.temperature_controlling import InhecoTECControlBox\n", - "\n", - "control_box = InhecoTECControlBox()\n", - "await control_box.setup()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.temperature_controlling import inheco_cpac_ultraflat\n", - "\n", - "tc = inheco_cpac_ultraflat(\n", - " name=\"CPAC\",\n", - " control_box=control_box,\n", - " index=1,\n", - ")\n", - "await tc.setup()\n", - "type(tc)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Temperature Control" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await tc.get_temperature() # Get current temperature in C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await tc.set_temperature(37) # Temperature in degrees C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await tc.wait_for_temperature() # Wait for the temperature to stabilize" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await tc.deactivate() # Turn off temperature control" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Closing Connection to Machine" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await tc.stop()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Closing Connection to Control Box\n", - "\n", - "When all devices are no longer needed, the connection to the control box can be closed using the {meth}`~pylabrobot.temperature_controller.inheco.control_box.InhecoTECControlBox.stop` method. This will close the connection to the control box." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await control_box.stop()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Using Multiple Inheco Devices" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "You can use multiple Inheco ThermoShake machines using one control box if you are using the Inheco MTC (Multi TEC Control) unit. In this case, simply instantiate more than one {class}`~pylabrobot.temperature_controlling.inheco.cpac_backend.InhecoCPACBackend` with the same {class}`~pylabrobot.temperature_controlling.inheco.control_box.InhecoTECControlBox` instance, but different `index` values (1-6)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "tc2 = inheco_cpac_ultraflat(\n", - " name=\"CPAC\",\n", - " control_box=control_box,\n", - " index=2,\n", - ")\n", - "await tc2.setup()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb b/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb deleted file mode 100644 index 0c14c08ad9f..00000000000 --- a/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb +++ /dev/null @@ -1,381 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Opentrons Temperature Module\n", - "\n", - "| Summary | Photo |\n", - "|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------|\n", - "| - [OEM Link](https://opentrons.com/products/temperature-module-gen2?sku=991-00350-0)
- **Communication Protocol / Hardware**: ? / USB-A
- **Communication Level**: ?

- **OEM version**: GEN2
- **Temperature range**: 4 to 95°C
| ![quadrants](img/ot_temperature_module_gen2.webp) |\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Physical)\n", - "\n", - "Connect with USB to opentrons or to computer running pylabrobot" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Programmatic)\n", - "\n", - "\n", - "### Setup with Opentrons" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.temperature_controlling import TemperatureController\n", - "from pylabrobot.temperature_controlling.opentrons import OpentronsTemperatureModuleV2\n", - "from pylabrobot.temperature_controlling.opentrons_backend import (\n", - " OpentronsTemperatureModuleBackend,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Using the Opentrons temperature controller currently requires an Opentrons robot. The robot must be connected to the host computer and to the temperature module." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.liquid_handling import LiquidHandler\n", - "from pylabrobot.liquid_handling.backends.opentrons_backend import OpentronsBackend\n", - "from pylabrobot.resources.opentrons import OTDeck\n", - "\n", - "ot = OpentronsBackend(host=\"169.254.184.185\", port=31950) # Get the ip from the Opentrons app\n", - "lh = LiquidHandler(backend=ot, deck=OTDeck())\n", - "await lh.setup()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "After setting up the robot, use the `OpentronsBackend.list_connected_modules()` to list the connected temperature modules. You are looking for the `'id'` of the module you want to use." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[{'id': 'fc409cc91770129af8eb0a01724c56cb052b306a',\n", - " 'serialNumber': 'TDV21P20201224B13',\n", - " 'firmwareVersion': 'v2.1.0',\n", - " 'hardwareRevision': 'temp_deck_v21',\n", - " 'hasAvailableUpdate': False,\n", - " 'moduleType': 'temperatureModuleType',\n", - " 'moduleModel': 'temperatureModuleV2',\n", - " 'data': {'status': 'idle', 'currentTemperature': 34.0},\n", - " 'usbPort': {'port': 1,\n", - " 'portGroup': 'main',\n", - " 'hub': False,\n", - " 'path': '1.0/tty/ttyACM0/dev'}}]" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await ot.list_connected_modules()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Initialize the OpentronsTemperatureModuleV2 with the `id` of the module you want to use." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [], - "source": [ - "t = OpentronsTemperatureModuleV2(name=\"t\", opentrons_id=\"fc409cc91770129af8eb0a01724c56cb052b306a\")\n", - "await t.setup()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The `OpentronsTemperatureModuleV2` is a subclass of {class}`~pylabrobot.temperature_controlling.temperature_controller.TemperatureController`." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "isinstance(t, TemperatureController)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Be sure to assign the temperature controller to the robot deck before you use it. This is done with the usual {func}`~pylabrobot.resources.opentrons.deck.assign_child_at_slot` function." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "lh.deck.assign_child_at_slot(t, slot=3)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "### Setup with Com Port" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "To communicate with the temperature module, find the COM port for the USB connection. Use that to replace `COM#` when setting up the temperature module in your code." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.temperature_controlling.opentrons import OpentronsTemperatureModuleV2\n", - "\n", - "tc = OpentronsTemperatureModuleV2(name='tc', opentrons_id=None, serial_port=\"OM#\")\n", - "await tc.setup()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Usage\n", - "\n", - "### Temperature control" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "You can set the temperature in Celsius using {func}`~pylabrobot.temperature_controlling.temperature_controller.TemperatureController.set_temperature`." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [], - "source": [ - "await t.set_temperature(37)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Use {func}`~pylabrobot.temperature_controlling.temperature_controller.TemperatureController.wait_for_temperature` to wait for the temperature to stabilize at the target temperature." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [], - "source": [ - "await t.wait_for_temperature()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The temperature can be read using {func}`~pylabrobot.temperature_controlling.temperature_controller.TemperatureController.get_temperature`." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "37.0" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await t.get_temperature()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "If you are done with the temperature controller, you can use {func}`~pylabrobot.temperature_controlling.temperature_controller.TemperatureController.deactivate` to turn it off. The temperature controller will return to ambient temperature." - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [], - "source": [ - "await t.deactivate()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Pipetting from the OT-2 temperature module" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Assign some tips to the deck and pick one up so that we can aspirate:" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources.opentrons import opentrons_96_tiprack_300ul\n", - "\n", - "tips300 = opentrons_96_tiprack_300ul(name=\"tips\")\n", - "lh.deck.assign_child_at_slot(tips300, slot=11)" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.pick_up_tips(tips300[\"A5\"])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Access the temperature controller's tube rack with the `tube_rack` attribute." - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.aspirate(t.tube_rack[\"A1\"], vols=[20])" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.aspirate(t.tube_rack[\"A6\"], vols=[20])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Return the tips to the tip rack when you are done." - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": {}, - "outputs": [], - "source": [ - "await lh.return_tips()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.9" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/01_material-handling/temperature-controllers/temperature-controllers.rst b/docs/user_guide/01_material-handling/temperature-controllers/temperature-controllers.rst deleted file mode 100644 index 96ccb19511d..00000000000 --- a/docs/user_guide/01_material-handling/temperature-controllers/temperature-controllers.rst +++ /dev/null @@ -1,129 +0,0 @@ -Temperature Controllers -======================= - -Temperature controllers are defined as **machines with one or both** of these **features**: - -- `heating` -- `actively cooling` - -a **material** or **enclosed volume** from a room-temperature baseline (≈20-25°C). - -Based on this definition machines that include temperature control, but are not primarily temperature controllers, should build on top of the `TemperatureController` definition. - -These multi-functional machines can be described as "composite machines". -Examples of such machines include: - -- Heater shakers: `shake` + `heat` (e.g. Inheco Thermoshake) -- qPCR machines: `measure fluorescence` + `heat` + `cool` (e.g. Thermo Fisher Scientific QuantStudio 5) -- Smart storage machines: `store` + `heat` + (`cool`) (e.g. Thermo Fisher Scientific Cytomats) - -Temperature Controller machines can be implemented with a variety of heating/cooling technologies suited to different workflows. - ------------------------------------------- - -Actuation technologies ----------------------- - -Multiple technologies can be used to implement temperature control, each with its own advantages and limitations. -The two most common types are: - -1. **Thermoelectric (Peltier) modules** - are solid-state devices that pump heat via the Peltier effect, enabling both heating and cooling by reversing current flow. - Compact and modular, they mount directly on robotic decks. - - *Examples:* - - - Inheco Cold Plate Air Cooled (CPAC) Heater/Cooler (not yet supported in PLR) - - Hamilton Heater Cooler (HHC) - - Opentrons Temperature Module GEN2 - -- **Pros:** Bidirectional control; fast response; minimal footprint -- **Cons:** Limited ΔT from ambient (±65°C max); efficiency drops near extremes; requires heatsinking/ventilation - -2. **Liquid-circulation systems** - use external chillers or heaters to pump fluid (water or glycol) through channels around a sample block, delivering uniform, stable temperatures well below and above ambient. - - *Examples:* - - - Inheco Cold Plate Liquid Cooled (CPLC) Heater/Cooler (not yet supported in PLR) - -- **Pros:** Broad temperature range; excellent uniformity; precise PID control -- **Cons:** Bulky; requires plumbing and space; higher cost - ------------------------------------------- - -Implementation --------------- - -Backend -^^^^^^^ - -PyLabRobot programmatically defines Temperature Controller machines based on the :class:`~pylabrobot.temperature_controlling.temperature_controller.TemperatureController` base class. - -e.g.: - -.. code-block:: python - - from pylabrobot.temperature_controlling.temperature_controller import ( - TemperatureControllerBackend - ) - - hhc_backend = HamiltonHeaterCoolerBackend(device_address="/dev/ttyUSB0") - -Resource Model -^^^^^^^^^^^^^^ - -Physically speaking, PLR models most standalone temperature controllers as either `ResourceHolder` or `PlateHolder` objects. -I.e. these machines have physical dimensions and can hold plates or other resources in a specified `child_location`. - -e.g.: - -.. code-block:: python - - def Hamilton_heater_cooler_resource(name: str) -> PlateHolder: - """ - Hamilton cat. no.: 6601900-01 - """ - return PlateHolder( - name=name, - size_x=145.5, - size_y=104.0, - size_z=67.8, - child_location=Coordinate(11.5, 8.0, 67.8), - model="hamilton_heater_cooler", - pedestal_size_z=0, - ) - - hhc_resource_model = Hamilton_heater_cooler_resource( - name="Hamilton Heater Cooler no1" - ) - -Frontend -^^^^^^^^ - -The frontend then enables fast and user-friendly access to the temperature controller's functionality and storing of complete machine interfaces using familiar names. - -e.g.: - -.. code-block:: python - - Hamilton_heater_cooler = TemperatureController( - backend=hhc_backend, - resource_model=hhc_resource_model - ) - - - # Action command: - Hamilton_heater_cooler.set_temperature(37) - - # Read command: - current = Hamilton_heater_cooler.get_temperature() - - -.. toctree:: - :maxdepth: 1 - :hidden: - - ot-temperature-controller - hamilton-heater-cooler - inheco diff --git a/docs/user_guide/01_material-handling/thermocycling/inheco-odtc.ipynb b/docs/user_guide/01_material-handling/thermocycling/inheco-odtc.ipynb deleted file mode 100644 index d0782d21857..00000000000 --- a/docs/user_guide/01_material-handling/thermocycling/inheco-odtc.ipynb +++ /dev/null @@ -1,316 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Inheco ODTC (On Deck Thermal Cycler)\n", - "\n", - "The Inheco ODTC is an on-deck thermal cycler designed for automated PCR workflows. It features:\n", - "\n", - "- Precise temperature control for PCR cycling\n", - "- Heated lid to prevent condensation\n", - "- Motorized door for automated plate handling\n", - "- SiLA 2 communication interface\n", - "\n", - "**Specifications:**\n", - "- Temperature range: 4°C to 99°C\n", - "- Heating/cooling rate: up to 4.4°C/s\n", - "- 96-well plate format\n", - "\n", - "See the [Inheco ODTC product page](https://www.inheco.com/odtc.html) for more information." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Setup\n", - "\n", - "The ODTC communicates over Ethernet using the SiLA 2 protocol. You'll need:\n", - "1. The IP address of the ODTC\n", - "2. Network connectivity between your computer and the ODTC" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources.coordinate import Coordinate\n", - "from pylabrobot.thermocycling.inheco import ExperimentalODTCBackend\n", - "from pylabrobot.thermocycling.thermocycler import Thermocycler\n", - "\n", - "odtc = Thermocycler(\n", - " name=\"odtc\",\n", - " size_x=159.0,\n", - " size_y=245.0,\n", - " size_z=228.0,\n", - " backend=ExperimentalODTCBackend(ip=\"169.254.151.99\"), # Replace with your ODTC's IP address\n", - " child_location=Coordinate(0, 0, 0) # TODO: resource modeling...\n", - ")\n", - "await odtc.setup()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Door Control\n", - "\n", - "Open and close the door for plate access:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await odtc.open_lid()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await odtc.close_lid()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Temperature Control\n", - "\n", - "### Reading Sensor Data\n", - "\n", - "Get current temperatures from all sensors:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "sensor_data = await odtc.backend.get_sensor_data()\n", - "print(sensor_data)\n", - "# Example output:\n", - "# {'Mount': 25.0, 'Mount_Monitor': 25.1, 'Lid': 30.0, 'Lid_Monitor': 30.1,\n", - "# 'Ambient': 22.0, 'PCB': 28.0, 'Heatsink': 26.0, 'Heatsink_TEC': 25.5}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Setting Block Temperature\n", - "\n", - "Set a constant block temperature. Note that the ODTC uses a \"pre-method\" approach which takes several minutes to stabilize:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await odtc.set_block_temperature([37.0]) # Set to 37°C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Check current block temperature\n", - "temp = await odtc.get_block_current_temperature()\n", - "print(f\"Block temperature: {temp[0]}°C\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Deactivating Temperature Control" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await odtc.deactivate_block()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Running PCR Protocols\n", - "\n", - "The ODTC can run complex PCR protocols defined using `Protocol`, `Stage`, and `Step` objects.\n", - "\n", - "### Defining a Protocol\n", - "\n", - "A protocol consists of stages, each containing steps with temperature and hold time. Stages can repeat multiple times for cycling." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.thermocycling.standard import Protocol, Stage, Step\n", - "\n", - "# Example: Standard 3-step PCR protocol\n", - "pcr_protocol = Protocol(\n", - " stages=[\n", - " # Initial denaturation\n", - " Stage(\n", - " steps=[Step(temperature=[95.0], hold_seconds=300)], # 95°C for 5 min\n", - " repeats=1\n", - " ),\n", - " # PCR cycling (30 cycles)\n", - " Stage(\n", - " steps=[\n", - " Step(temperature=[95.0], hold_seconds=30), # Denature: 95°C for 30s\n", - " Step(temperature=[55.0], hold_seconds=30), # Anneal: 55°C for 30s\n", - " Step(temperature=[72.0], hold_seconds=60), # Extend: 72°C for 60s\n", - " ],\n", - " repeats=30\n", - " ),\n", - " # Final extension\n", - " Stage(\n", - " steps=[Step(temperature=[72.0], hold_seconds=600)], # 72°C for 10 min\n", - " repeats=1\n", - " ),\n", - " # Hold\n", - " Stage(\n", - " steps=[Step(temperature=[4.0], hold_seconds=0)], # 4°C hold\n", - " repeats=1\n", - " ),\n", - " ]\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Running the Protocol" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await odtc.run_protocol(\n", - " protocol=pcr_protocol,\n", - " block_max_volume=20.0, # Maximum sample volume in µL\n", - " start_block_temperature=25.0, # Starting block temperature\n", - " start_lid_temperature=105.0, # Lid temperature (typically 105°C to prevent condensation)\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Custom Ramp Rates\n", - "\n", - "You can specify custom temperature ramp rates for each step:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Protocol with custom ramp rates\n", - "custom_protocol = Protocol(\n", - " stages=[\n", - " Stage(\n", - " steps=[\n", - " Step(temperature=[95.0], hold_seconds=60, rate=4.4), # Fast ramp (4.4°C/s)\n", - " Step(temperature=[60.0], hold_seconds=30, rate=2.0), # Slower ramp (2.0°C/s)\n", - " ],\n", - " repeats=1\n", - " ),\n", - " ]\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await odtc.run_protocol(\n", - " protocol=custom_protocol,\n", - " block_max_volume=25.0,\n", - " start_block_temperature=25.0,\n", - " start_lid_temperature=105.0,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Closing the Connection" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await odtc.stop()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.24" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/01_material-handling/thermocycling/thermocycling.md b/docs/user_guide/01_material-handling/thermocycling/thermocycling.md deleted file mode 100644 index 9d76b3231a5..00000000000 --- a/docs/user_guide/01_material-handling/thermocycling/thermocycling.md +++ /dev/null @@ -1,22 +0,0 @@ -# Thermocycling - -This section provides an overview of how to use thermocyclers in PyLabRobot. - -Thermocyclers are essential for temperature-controlled processes like PCR (Polymerase Chain Reaction). PyLabRobot offers a high-level `Thermocycler` class to interact with these devices, along with various backends for different hardware. - -## Key Features - -- **Temperature Control:** Set and monitor block and lid temperatures. -- **Lid Control:** Open and close the thermocycler lid. -- **Profile Execution:** Run complex temperature profiles, including standard PCR protocols. -- **Status Monitoring:** Query the current state of the thermocycler, including temperature, lid status, and profile progress. - -## Supported Thermocyclers - -- Opentrons Thermocycler - -```{toctree} -:maxdepth: 1 - -Inheco ODTC -``` diff --git a/docs/user_guide/01_material-handling/tilting.md b/docs/user_guide/01_material-handling/tilting.md deleted file mode 100644 index 9c9fb36bf8a..00000000000 --- a/docs/user_guide/01_material-handling/tilting.md +++ /dev/null @@ -1,20 +0,0 @@ -# Tilting - -## Installation - -```bash -pip install pylabrobot[serial] -``` - -Currently only the Hamilton tilt module is supported. - -```python -from pylabrobot.tilting.hamilton import HamiltonTiltModule - -tilter = HamiltonTiltModule(name="tilter", com_port="COM1") - -await lh.move_plate(my_plate, tilter) - -await tilter.set_angle(10) # absolute angle, clockwise, in degrees -await tilter.tilt(-1) # relative -``` diff --git a/docs/user_guide/02_analytical/_analytical.rst b/docs/user_guide/02_analytical/_analytical.rst deleted file mode 100644 index 4bef6272b2c..00000000000 --- a/docs/user_guide/02_analytical/_analytical.rst +++ /dev/null @@ -1,19 +0,0 @@ -Analytical -========== - -Everything whose primary function is to measure something. -This includes flow cytometers, plate readers, and scales. - - - ------------------------------------------- - -.. toctree:: - :maxdepth: 1 - :hidden: - - flow-cytometers/_flow-cytometers - plate-reading/plate-reading - scales/scales - - diff --git a/docs/user_guide/02_analytical/flow-cytometers/_flow-cytometers.rst b/docs/user_guide/02_analytical/flow-cytometers/_flow-cytometers.rst deleted file mode 100644 index 56dfa17ef61..00000000000 --- a/docs/user_guide/02_analytical/flow-cytometers/_flow-cytometers.rst +++ /dev/null @@ -1,12 +0,0 @@ -Flow Cytometers -=============== - -The Cytoflex is a WIP. - ------------------------------------------- - -.. toctree:: - :maxdepth: 1 - :hidden: - - beckman-coulter-cytoflex diff --git a/docs/user_guide/02_analytical/flow-cytometers/beckman-coulter-cytoflex.rst b/docs/user_guide/02_analytical/flow-cytometers/beckman-coulter-cytoflex.rst deleted file mode 100644 index 481eb3ce4a0..00000000000 --- a/docs/user_guide/02_analytical/flow-cytometers/beckman-coulter-cytoflex.rst +++ /dev/null @@ -1,4 +0,0 @@ -Beckman CytoFLEX -================ - -Cytoflex coming soon. diff --git a/docs/user_guide/02_analytical/plate-reading/bmg-clariostar.ipynb b/docs/user_guide/02_analytical/plate-reading/bmg-clariostar.ipynb deleted file mode 100644 index f561fa01c87..00000000000 --- a/docs/user_guide/02_analytical/plate-reading/bmg-clariostar.ipynb +++ /dev/null @@ -1,324 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "18708b66", - "metadata": {}, - "source": [ - "# BMG Labtech CLARIOstar (Plus)\n", - "\n", - "| Summary | Photo |\n", - "|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------|\n", - "| - [OEM Link](https://www.bmglabtech.com/en/clariostar-plus/)
- **Communication Protocol / Hardware**: Serial (FTDI)/ USB-A
- **Communication Level**: Firmware
- **Measurement Modes**: Absorbance, Luminescence, Fluorescence
- **Plate Delivery**: Loading tray
- **Additional Standard Features**: Temperature sontrol, Shaking

- **Additional Upgrades**: Injector system, increased max temperature, plate stacking system, ... | ![quadrants](img/bmg-labtech-clariostar-plus.png) |\n" - ] - }, - { - "cell_type": "markdown", - "id": "80e2e5dc", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Physical)\n", - "\n", - "The CLARIOstar and CLARIOstar Plus require a minimum of two cable connections to be operational:\n", - "1. Power cord (standard IEC C13)\n", - "2. USB cable (USB-B with security screws at CLARIOstar end; USB-A at control PC end)\n", - "\n", - "Optional:\n", - "If you have a plate stacking unit to use with the CLARIOstar (Plus), an additional RS-232 port is available on the CLARIOstar (Plus).\n" - ] - }, - { - "cell_type": "markdown", - "id": "adb29364", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Programmatic)\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "34531f2c", - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "markdown", - "id": "e7a179e9", - "metadata": {}, - "source": [ - "To control the BMG Labtech CLARIOstar (Plus), generate a `PlateReader` frontend instance that uses a `CLARIOstarBackend` instance as its backend.\n", - "\n", - "To access the CLARIOstar-specific machine features you can still use the backend directly.\n", - "For convenience, it is useful to therefore store the backend instance as a separate `clariostar_backend` variable." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "363b8144", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.plate_reading import PlateReader\n", - "\n", - "from pylabrobot.plate_reading.clario_star_backend import CLARIOstarBackend\n", - "clariostar_backend = CLARIOstarBackend()\n", - "\n", - "pr = PlateReader(\n", - " name=\"CLARIOstar\",\n", - " backend=clariostar_backend,\n", - " size_x=0.0, # TODO: generate new handling for resources with loading tray \n", - " size_y=0.0,\n", - " size_z=0.0\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "30720acb", - "metadata": {}, - "outputs": [], - "source": [ - "await pr.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "65555028", - "metadata": {}, - "source": [ - "```{note}\n", - "Expected behaviour: the machine should perform its initialization routine.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "7d2e9ed2", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Usage / Machine Features\n", - "\n", - "### Loading Tray" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c0834a6e", - "metadata": {}, - "outputs": [], - "source": [ - "await pr.open()" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "77c3406b", - "metadata": {}, - "outputs": [], - "source": [ - "# perform arm movement to move your plate of interest onto the CLARIOstar's loading tray\n", - "# this movement can be performed by a human\n", - "# or it can be performed by a robotic arm" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "092a02fa", - "metadata": {}, - "outputs": [], - "source": [ - "await pr.close()" - ] - }, - { - "cell_type": "markdown", - "id": "9c4d21b7", - "metadata": {}, - "source": [ - "### Set Temperature\n", - "\n", - "The CLARIOstar offers a temperature control feature.\n", - "Reaching a set temperature is relatively slow compared to standalone temperature controllers.\n", - "We therefore recommend setting the temperature early on in your automated Protocol (aP)." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "bb2de16b", - "metadata": {}, - "outputs": [], - "source": [ - "# WIP: feature exposure in active development" - ] - }, - { - "cell_type": "markdown", - "id": "bf4840ea", - "metadata": {}, - "source": [ - "### Set Shaking\n", - "\n", - "The CLARIOstar offers a shaking feature." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "60b71bea", - "metadata": {}, - "outputs": [], - "source": [ - "# WIP: feature in active development" - ] - }, - { - "cell_type": "markdown", - "id": "c4be6218", - "metadata": {}, - "source": [ - "---\n", - "### Measuring Absorbance\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e23acc3d", - "metadata": {}, - "outputs": [], - "source": [ - "# WIP: feature in active development including\n", - "# reading subsets of wells\n", - "# specifying orbital diameter\n", - "# specifying number of technical replicate measurements per well\n", - "# specifying start position for reading: topleft, topright, bottomleft, bottomright\n", - "# ...\n", - "\n", - "results_absorbance = await pr.read_absorbance()\n" - ] - }, - { - "cell_type": "markdown", - "id": "d9a13de2", - "metadata": {}, - "source": [ - "`results` will be a width x height array of absorbance values.\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "id": "e57e55de", - "metadata": {}, - "source": [ - "\n", - "#### Performing a Spectral Scan\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8cae50a6", - "metadata": {}, - "outputs": [], - "source": [ - "# WIP: feature in active development" - ] - }, - { - "cell_type": "markdown", - "id": "c7a70d7e", - "metadata": {}, - "source": [ - "### Measuring Luminescence\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c8656ef6", - "metadata": {}, - "outputs": [], - "source": [ - "# WIP: feature in active development\n", - "\n", - "results_luminescence = await pr.read_luminescence()" - ] - }, - { - "cell_type": "markdown", - "id": "8554a66c", - "metadata": {}, - "source": [ - "### Measuring Fluorescence\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "637f06a7", - "metadata": {}, - "outputs": [], - "source": [ - "# WIP: feature in active development" - ] - }, - { - "cell_type": "markdown", - "id": "362dd696", - "metadata": {}, - "source": [ - "### Using the Injector Needles\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cd676c58", - "metadata": {}, - "outputs": [], - "source": [ - "# WIP: feature in active development" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "plr", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.11" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/02_analytical/plate-reading/byonoy.ipynb b/docs/user_guide/02_analytical/plate-reading/byonoy.ipynb deleted file mode 100644 index 6d8348cd102..00000000000 --- a/docs/user_guide/02_analytical/plate-reading/byonoy.ipynb +++ /dev/null @@ -1,1577 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "63d8a7a6-1107-4334-8b73-598aa1ca97c4", - "metadata": {}, - "source": "# Byonoy Absorbance 96 Automate\n\n- [OEM Link](https://byonoy.com/absorbance-automate/)\n- **Communication Protocol / Hardware:** HID / USB-A/C\n- **Communication Level:** Firmware\n- VID:PID `16d0:1199`\n- Takes a single SLAS-format 96-wellplate on the detection unit, enables movement of the cap/illumination unit over it, and reads all 96 wells simultaneously.\n- Up to 6 configurable absorbance wavelengths (dependent on specifications during purchase)." - }, - { - "cell_type": "markdown", - "id": "840adda3-0ea1-4e7c-b0cb-34dd2244de69", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Physical)\n", - "\n", - "The Byonoy Absorbance 96 Automate (A96A) is a an absorbance plate reader consisting of...\n", - "1. a `detection_unit` containing the liqht sensors,\n", - "2. a `illumination_unit` containing the light source,\n", - "3. a `parking_unit` representing a simple resource_holder for the `illumination_unit`, and\n", - "4. an `sbs_adapter` which is an optional holder for the `detection_unit` or `parking_unit`, enabling placement of this machine onto a standard SLAS/SBS-format plate holder.\n", - "\n", - "### Communication\n", - "It requires only one cable connections to be operational:\n", - "1. USB cable (USB-C at `base` end; USB-A at control PC end)" - ] - }, - { - "cell_type": "markdown", - "id": "e4aa8066-9eb5-4f8a-8d69-372712bdb3b5", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Programmatic)\n", - "\n", - "If used with a liquid handler, first setup the liquid handler:" - ] - }, - { - "cell_type": "raw", - "id": "fe2cca99-8ff6-431f-97c4-ed32211578af", - "metadata": {}, - "source": [ - "import logging\n", - "from pylabrobot.io import LOG_LEVEL_IO\n", - "from datetime import datetime\n", - "\n", - "current_date = datetime.today().strftime('%Y-%m-%d')\n", - "protocol_mode = \"execution\"\n", - "\n", - "# Create the shared file handler once\n", - "fh = logging.FileHandler(f\"{current_date}_testing_{protocol_mode}.log\", mode=\"a\")\n", - "fh.setLevel(LOG_LEVEL_IO)\n", - "formatter = logging.Formatter(\n", - " \"%(asctime)s [%(levelname)s] %(name)s - %(message)s\"\n", - ")\n", - "fh.setFormatter(formatter)\n", - "\n", - "# Configure the main pylabrobot logger\n", - "logger_plr = logging.getLogger(\"pylabrobot\")\n", - "logger_plr.setLevel(LOG_LEVEL_IO)\n", - "if not any(isinstance(h, logging.FileHandler) and h.baseFilename == fh.baseFilename\n", - " for h in logger_plr.handlers):\n", - " logger_plr.addHandler(fh)\n", - "\n", - "# Other loggers can reuse the same file handler\n", - "logger_manager = logging.getLogger(\"manager\")\n", - "logger_device = logging.getLogger(\"device\")\n", - "\n", - "for logger in [logger_manager, logger_device]:\n", - " logger.setLevel(logging.DEBUG) # or logging.INFO\n", - " if not any(isinstance(h, logging.FileHandler) and h.baseFilename == fh.baseFilename\n", - " for h in logger.handlers):\n", - " logger.addHandler(fh)\n", - "\n", - "# START LOGGING\n", - "logger_manager.info(\"START AUTOMATED PROTOCOL\")\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2477b684-443f-4a2f-b16c-99649f443de4", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "1fd4d917", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerChatterboxBackend\n", - "from pylabrobot.resources import STARDeck\n", - "\n", - "lh = LiquidHandler(deck=STARDeck(), backend=LiquidHandlerChatterboxBackend())" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "7355585a-e751-46b6-897b-d09c8be38852", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import (\n", - " hamilton_mfx_carrier_L5_base, MFX_CAR_L4_SHAKER , # MFX CARRIERS\n", - " MFX_DWP_rackbased_module, hamilton_mfx_plateholder_DWP_metal_tapped,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "20dbb226-5cc3-49f6-ac62-0c4f4e11eff9", - "metadata": {}, - "outputs": [], - "source": [ - "# \n", - "\n", - "mfx_carrier_2_plateholders = hamilton_mfx_carrier_L5_base(\n", - " name=\"mfx_carrier_2_plateholders\",\n", - " modules={\n", - " 4: hamilton_mfx_plateholder_DWP_metal_tapped(name=f\"mfx_plateholder_1\"),\n", - " 2: hamilton_mfx_plateholder_DWP_metal_tapped(name=f\"mfx_plateholder_parking_unit\"),\n", - " 0: hamilton_mfx_plateholder_DWP_metal_tapped(name=f\"mfx_plateholder_detection_unit\")\n", - " }\n", - ")\n", - "\n", - "lh.deck.assign_child_resource(mfx_carrier_2_plateholders, rails=12)\n" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "abde0e65", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Setting up the liquid handler.\n" - ] - } - ], - "source": [ - "await lh.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "165bd434-5899-4623-ac67-91d2aad55e7c", - "metadata": {}, - "source": [ - "Then generate a plate definition for the plate you want to read:" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "5be9a197", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources.coordinate import Coordinate\n", - "from pylabrobot.resources.cellvis.plates import CellVis_96_wellplate_350uL_Fb\n", - "\n", - "\n", - "demo_plate = CellVis_96_wellplate_350uL_Fb(name='demo_plate')\n", - "\n", - "mfx_carrier_2_plateholders[4] = demo_plate" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "38a9cf2d-840c-49ce-a886-da29c67fb124", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.plate_reading.byonoy import (\n", - " byonoy_sbs_adapter,\n", - " byonoy_a96a_detection_unit, \n", - " byonoy_a96a_illumination_unit,\n", - " byonoy_a96a_parking_unit\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "beb292d0-cc18-439f-b8c4-ae3013aa35d9", - "metadata": {}, - "outputs": [], - "source": [ - "# Detection Unit\n", - "a96a_sbs_adapter_DU = byonoy_sbs_adapter(name=\"a96a_sbs_adapter_DU\")\n", - "a96a_detection_unit = byonoy_a96a_detection_unit(name=\"a96a_detection_unit\")\n", - "a96a_sbs_adapter_DU.assign_child_resource(a96a_detection_unit)\n", - "\n", - "mfx_carrier_2_plateholders[0] = a96a_sbs_adapter_DU\n", - "\n", - "# Parking Unit\n", - "a96a_sbs_adapter_PU = byonoy_sbs_adapter(name=\"a96a_sbs_adapter_PU\")\n", - "a96a_parking_unit = byonoy_a96a_parking_unit(name=\"a96a_parking_unit\")\n", - "a96a_sbs_adapter_PU.assign_child_resource(a96a_parking_unit)\n", - "\n", - "mfx_carrier_2_plateholders[2] = a96a_sbs_adapter_PU\n", - "\n", - "\n", - "a96a_illumination_unit = byonoy_a96a_illumination_unit(name=\"a96a_illumination_unit\")\n" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "642ba0d5-f66a-48a6-a0b5-3ae609c39a0f", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Resource 'a96a_illumination_unit' is very high on the deck: 257.948 mm. Be careful when traversing the deck.\n" - ] - } - ], - "source": [ - "a96a_detection_unit.assign_child_resource(a96a_illumination_unit)" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "7ee80f6a-02dd-4f10-8708-121f1ac9423b", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Coordinate(x=337.75, y=67.0, z=200.95)" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "a96a_detection_unit.get_location_wrt(lh.deck)" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "2a473088-4d8e-43fd-ad20-d90f31f77741", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Rail Resource Type Coordinates (mm)\n", - "===========================================================================================\n", - "(-6) ├── trash_core96 Trash (-58.200, 106.000, 216.400)\n", - " │\n", - "(12) ├── mfx_carrier_2_plateholders MFXCarrier (347.500, 063.000, 100.000)\n", - " │ ├── demo_plate Plate (351.500, 456.000, 182.050)\n", - " │ ├── a96a_sbs_adapter_PU ResourceHolder (351.500, 264.000, 183.950)\n", - " │ │ ├── a96a_parking_unit ByonoyBaseUnit (337.750, 259.000, 200.950)\n", - " │ ├── a96a_sbs_adapter_DU ResourceHolder (351.500, 072.000, 183.950)\n", - " │ │ ├── a96a_detection_unit ByonoyBaseUnit (337.750, 067.000, 200.950)\n", - " │\n", - "(55) ├── waste_block Resource (1315.000, 115.000, 100.000)\n", - " │ ├── teaching_tip_rack TipRack (1320.900, 461.100, 100.000)\n", - " │ ├── core_grippers HamiltonCoreGrippers (1337.500, 125.000, 205.000)\n", - " │\n", - "(56) ├── trash Trash (1340.000, 190.600, 137.100)\n", - "\n" - ] - } - ], - "source": [ - "lh.summary()" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "81c0ef1a-a2cc-4094-b194-ee1781f782b3", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Rail Resource Type Coordinates (mm)\n", - "===============================================================================================\n", - "(-6) ├── trash_core96 Trash (-58.200, 106.000, 216.400)\n", - " │\n", - "(12) ├── mfx_carrier_2_plateholders MFXCarrier (347.500, 063.000, 100.000)\n", - " │ ├── demo_plate Plate (351.500, 456.000, 182.050)\n", - " │ ├── a96a_sbs_adapter_PU ResourceHolder (351.500, 264.000, 183.950)\n", - " │ │ ├── a96a_parking_unit ByonoyBaseUnit (337.750, 259.000, 200.950)\n", - " │ ├── a96a_sbs_adapter_DU ResourceHolder (351.500, 072.000, 183.950)\n", - " │ │ ├── a96a_detection_unit ByonoyBaseUnit (337.750, 067.000, 200.950)\n", - " │ │ │ ├── a96a_illumination_unitResource (337.750, 067.000, 215.050)\n", - " │\n", - "(55) ├── waste_block Resource (1315.000, 115.000, 100.000)\n", - " │ ├── teaching_tip_rack TipRack (1320.900, 461.100, 100.000)\n", - " │ ├── core_grippers HamiltonCoreGrippers (1337.500, 125.000, 205.000)\n", - " │\n", - "(56) ├── trash Trash (1340.000, 190.600, 137.100)\n", - "\n" - ] - } - ], - "source": [ - "lh.summary()" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "id": "5da9d7e2-fada-41b9-ab68-aff1e995b7d2", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Resource 'a96a_illumination_unit' is very high on the deck: 257.948 mm. Be careful when traversing the deck.\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Picking up resource: ResourcePickup(resource=Resource(name='a96a_illumination_unit', location=Coordinate(000.000, 000.000, 014.100), size_x=155.26, size_y=95.48, size_z=42.898, category=None), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=13.5, direction=)\n", - "Dropping resource: ResourceDrop(resource=Resource(name='a96a_illumination_unit', location=Coordinate(000.000, 000.000, 014.100), size_x=155.26, size_y=95.48, size_z=42.898, category=None), destination=Coordinate(x=337.75, y=259.0, z=200.95), destination_absolute_rotation=Rotation(x=0, y=0, z=0), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=13.5, pickup_direction=, direction=, rotation=0)\n" - ] - } - ], - "source": [ - "await lh.move_resource(a96a_illumination_unit, a96a_parking_unit, pickup_distance_from_top=13.5)" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "id": "0cf647e4-1190-458d-a377-21fc31076419", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Rail Resource Type Coordinates (mm)\n", - "===============================================================================================\n", - "(-6) ├── trash_core96 Trash (-58.200, 106.000, 216.400)\n", - " │\n", - "(12) ├── mfx_carrier_2_plateholders MFXCarrier (347.500, 063.000, 100.000)\n", - " │ ├── \n", - " │ ├── a96a_sbs_adapter_PU ResourceHolder (351.500, 264.000, 183.950)\n", - " │ │ ├── a96a_parking_unit ByonoyBaseUnit (337.750, 259.000, 200.950)\n", - " │ │ │ ├── a96a_illumination_unitResource (337.750, 259.000, 215.050)\n", - " │ ├── a96a_sbs_adapter_DU ResourceHolder (351.500, 072.000, 183.950)\n", - " │ │ ├── a96a_detection_unit ByonoyBaseUnit (337.750, 067.000, 200.950)\n", - " │\n", - "(55) ├── waste_block Resource (1315.000, 115.000, 100.000)\n", - " │ ├── teaching_tip_rack TipRack (1320.900, 461.100, 100.000)\n", - " │ ├── core_grippers HamiltonCoreGrippers (1337.500, 125.000, 205.000)\n", - " │\n", - "(56) ├── trash Trash (1340.000, 190.600, 137.100)\n", - "\n" - ] - } - ], - "source": [ - "lh.summary()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e11fc4d6-39fa-47cb-bec7-862a1ac85c9c", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "54f6e3d0-d477-4acf-b6f0-80378c937181", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "raw", - "id": "f72c8d26-136d-4b40-9c7e-2876ced1aa01", - "metadata": {}, - "source": [ - "a96a_detection_unit.assign_child_resource(demo_plate)" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "id": "3668cae5-23c0-4004-a601-cc83ab89615d", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Picking up resource: ResourcePickup(resource=Plate(name='demo_plate', size_x=127.6, size_y=85.75, size_z=13.83, location=None), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=9.87, direction=)\n", - "Dropping resource: ResourceDrop(resource=Plate(name='demo_plate', size_x=127.6, size_y=85.75, size_z=13.83, location=None), destination=Coordinate(x=337.75, y=67.0, z=200.95), destination_absolute_rotation=Rotation(x=0, y=0, z=0), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=9.87, pickup_direction=, direction=, rotation=0)\n" - ] - } - ], - "source": [ - "await lh.move_plate(demo_plate, a96a_detection_unit)" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "id": "400d6f72-2c5f-4ec7-90fd-42aa7cd83a0f", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Rail Resource Type Coordinates (mm)\n", - "===============================================================================================\n", - "(-6) ├── trash_core96 Trash (-58.200, 106.000, 216.400)\n", - " │\n", - "(12) ├── mfx_carrier_2_plateholders MFXCarrier (347.500, 063.000, 100.000)\n", - " │ ├── \n", - " │ ├── a96a_sbs_adapter_PU ResourceHolder (351.500, 264.000, 183.950)\n", - " │ │ ├── a96a_parking_unit ByonoyBaseUnit (337.750, 259.000, 200.950)\n", - " │ │ │ ├── a96a_illumination_unitResource (337.750, 259.000, 215.050)\n", - " │ ├── a96a_sbs_adapter_DU ResourceHolder (351.500, 072.000, 183.950)\n", - " │ │ ├── a96a_detection_unit ByonoyBaseUnit (337.750, 067.000, 200.950)\n", - " │ │ │ ├── demo_plate Plate (360.250, 072.000, 216.950)\n", - " │\n", - "(55) ├── waste_block Resource (1315.000, 115.000, 100.000)\n", - " │ ├── teaching_tip_rack TipRack (1320.900, 461.100, 100.000)\n", - " │ ├── core_grippers HamiltonCoreGrippers (1337.500, 125.000, 205.000)\n", - " │\n", - "(56) ├── trash Trash (1340.000, 190.600, 137.100)\n", - "\n" - ] - } - ], - "source": [ - "lh.summary()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e503fceb-092f-4687-b07b-ef4cc4146944", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": 21, - "id": "f9e0ddc2-bd3f-4802-b2da-021b89eadaa6", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Resource 'a96a_illumination_unit' is very high on the deck: 257.948 mm. Be careful when traversing the deck.\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Picking up resource: ResourcePickup(resource=Resource(name='a96a_illumination_unit', location=Coordinate(000.000, 000.000, 014.100), size_x=155.26, size_y=95.48, size_z=42.898, category=None), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=13.5, direction=)\n", - "Dropping resource: ResourceDrop(resource=Resource(name='a96a_illumination_unit', location=Coordinate(000.000, 000.000, 014.100), size_x=155.26, size_y=95.48, size_z=42.898, category=None), destination=Coordinate(x=337.75, y=67.0, z=200.95), destination_absolute_rotation=Rotation(x=0, y=0, z=0), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=13.5, pickup_direction=, direction=, rotation=0)\n" - ] - } - ], - "source": [ - "await lh.move_resource(a96a_illumination_unit, a96a_detection_unit, pickup_distance_from_top=13.5)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "55efae40-199e-4e3d-8070-d153fd1734c2", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1aaf6867-9254-4f41-9e63-e45bcea26fb6", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": 22, - "id": "b176939a-8e24-4c21-bd2b-f79799473a83", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Rail Resource Type Coordinates (mm)\n", - "===============================================================================================\n", - "(-6) ├── trash_core96 Trash (-58.200, 106.000, 216.400)\n", - " │\n", - "(12) ├── mfx_carrier_2_plateholders MFXCarrier (347.500, 063.000, 100.000)\n", - " │ ├── \n", - " │ ├── a96a_sbs_adapter_PU ResourceHolder (351.500, 264.000, 183.950)\n", - " │ │ ├── a96a_parking_unit ByonoyBaseUnit (337.750, 259.000, 200.950)\n", - " │ ├── a96a_sbs_adapter_DU ResourceHolder (351.500, 072.000, 183.950)\n", - " │ │ ├── a96a_detection_unit ByonoyBaseUnit (337.750, 067.000, 200.950)\n", - " │ │ │ ├── demo_plate Plate (360.250, 072.000, 216.950)\n", - " │ │ │ ├── a96a_illumination_unitResource (337.750, 067.000, 215.050)\n", - " │\n", - "(55) ├── waste_block Resource (1315.000, 115.000, 100.000)\n", - " │ ├── teaching_tip_rack TipRack (1320.900, 461.100, 100.000)\n", - " │ ├── core_grippers HamiltonCoreGrippers (1337.500, 125.000, 205.000)\n", - " │\n", - "(56) ├── trash Trash (1340.000, 190.600, 137.100)\n", - "\n" - ] - } - ], - "source": [ - "lh.summary()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2dab4931-8203-4697-a56c-175b41ace6c2", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": 23, - "id": "1ead86de-3288-4fe6-9e9d-7bcbcfb7ad8a", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Picking up resource: ResourcePickup(resource=Plate(name='demo_plate', size_x=127.6, size_y=85.75, size_z=13.83, location=Coordinate(022.500, 005.000, 016.000)), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=13.5, direction=)\n", - "Dropping resource: ResourceDrop(resource=Plate(name='demo_plate', size_x=127.6, size_y=85.75, size_z=13.83, location=Coordinate(022.500, 005.000, 016.000)), destination=Coordinate(x=351.5, y=456.0, z=182.05), destination_absolute_rotation=Rotation(x=0, y=0, z=0), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=13.5, pickup_direction=, direction=, rotation=0)\n" - ] - } - ], - "source": [ - "await lh.move_resource(demo_plate, mfx_carrier_2_plateholders[4], pickup_distance_from_top=13.5)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "30d3b220-0ac3-4f5d-a414-a241c4cead5e", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": 14, - "id": "0837df63-1ec6-4c53-9eef-826a57fecf95", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Coordinate(x=360.25, y=72.0, z=216.95)" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "demo_plate.get_location_wrt(lh.deck)" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "id": "4e8af209-b3b0-4969-b107-4bf3f8394985", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Coordinate(x=337.75, y=67.0, z=200.95)" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "a96a_detection_unit.get_location_wrt(lh.deck)" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "id": "c720d02c-0a7b-47be-a79c-dde06e6213ce", - "metadata": {}, - "outputs": [ - { - "ename": "AttributeError", - "evalue": "'ByonoyBaseUnit' object has no attribute 'child_location'", - "output_type": "error", - "traceback": [ - "\u001b[31m---------------------------------------------------------------------------\u001b[39m", - "\u001b[31mAttributeError\u001b[39m Traceback (most recent call last)", - "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[16]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m a96a_detection_unit.get_location_wrt(lh.deck)+ \u001b[43ma96a_detection_unit\u001b[49m\u001b[43m.\u001b[49m\u001b[43mchild_location\u001b[49m\n", - "\u001b[31mAttributeError\u001b[39m: 'ByonoyBaseUnit' object has no attribute 'child_location'" - ] - } - ], - "source": [ - "a96a_detection_unit.get_location_wrt(lh.deck)+ a96a_detection_unit.child_location" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "40458d1a-a723-4f2d-a15d-ad50b736d6e6", - "metadata": {}, - "outputs": [], - "source": [ - "a96a_detection_unit.assign_child_resource(a96a_illumination_unit)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7ede3638-c73f-4a34-a33a-b44cafde96df", - "metadata": {}, - "outputs": [], - "source": [ - "lh.summary()" - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "id": "3308a6e2-f31b-4a27-94ba-bf8adc56953b", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "ByonoyA96ABaseUnit(name='a96a_detection_unit', location=Coordinate(-13.750, -05.000, 017.000), size_x=155.26, size_y=95.48, size_z=18.5, category=resource_holder)\n" - ] - }, - { - "data": { - "text/plain": [ - "[None]" - ] - }, - "execution_count": 21, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "[print(x) for x in a96a_sbs_adapter.children]" - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "id": "09deefed-475e-4a1a-a897-94d90ab3a98b", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Plate(name='demo_plate', size_x=127.6, size_y=85.75, size_z=13.83, location=Coordinate(022.500, 005.000, 016.000))\n", - "Resource(name='a96a_illumination_unit', location=Coordinate(000.000, 000.000, 014.100), size_x=155.26, size_y=95.48, size_z=42.898, category=None)\n" - ] - }, - { - "data": { - "text/plain": [ - "[None, None]" - ] - }, - "execution_count": 22, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "[print(x) for x in a96a_detection_unit.children]" - ] - }, - { - "cell_type": "markdown", - "id": "bee933e6-b6df-4de7-aad1-40a2f0ba6721", - "metadata": {}, - "source": [ - "Now instantiate the Byonoy absorbance plate reader:" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "6aa99372", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.plate_reading.byonoy import (\n", - " byonoy_absorbance_adapter,\n", - " byonoy_absorbance96_base_and_reader\n", - ")\n", - "\n", - "cap_adapter = byonoy_absorbance_adapter(name='cap_adapter')\n", - "\n", - "base, reader_cap = byonoy_absorbance96_base_and_reader(name='base', assign=True)\n", - "\n", - "lh.deck.assign_child_resource(cap_adapter, location=Coordinate(400, 0, 0))" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "a10f9bb9", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Connected to Bynoy Absorbance 96 Automate (via HID with VID=5840:PID=4505) on b'DevSrvsID:4308410804'\n", - "Identified available wavelengths: [420, 600] nm\n" - ] - }, - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await reader_cap.setup(verbose=True)\n", - "\n", - "reader_cap.setup_finished" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "7089dbcd-4c88-434a-8e71-bdbe05130908", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'path': b'DevSrvsID:4308410804',\n", - " 'vendor_id': 5840,\n", - " 'product_id': 4505,\n", - " 'serial_number': 'BYOMAA00058',\n", - " 'release_number': 512,\n", - " 'manufacturer_string': 'Byonoy GmbH',\n", - " 'product_string': 'Absorbance 96 Automate',\n", - " 'usage_page': 65280,\n", - " 'usage': 1,\n", - " 'interface_number': 0,\n", - " 'bus_type': }" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "reader_cap.backend.io.device_info" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "49ecf2d4-f8ec-4ed1-8ed0-a8eecc04e584", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[420, 600]" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "reader_cap.backend.available_wavelengths" - ] - }, - { - "cell_type": "markdown", - "id": "29947461-c095-4c5c-a98f-dd434eea7472", - "metadata": {}, - "source": [ - "## Test Movement for Plate Reading" - ] - }, - { - "cell_type": "raw", - "id": "32de1568-625b-4114-ae55-df1c03ea9230", - "metadata": {}, - "source": [ - "# move the reader off the base\n", - "await lh.move_resource(reader_cap, Coordinate(200, 0, 0))" - ] - }, - { - "cell_type": "raw", - "id": "4199936d-efd1-423c-9714-20b0ae581e10", - "metadata": { - "scrolled": true - }, - "source": [ - "await lh.move_resource(plate, base.plate_holder)" - ] - }, - { - "cell_type": "raw", - "id": "b11f154e-2025-4092-9a52-fb14af1a1520", - "metadata": {}, - "source": [ - "await lh.move_resource(reader_cap, base.reader_holder)" - ] - }, - { - "cell_type": "raw", - "id": "0b975857-6b26-49c9-947d-db25763e332d", - "metadata": {}, - "source": [ - "adapter.assign_child_resource(base)" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "b2e6e986", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "(ResourceHolder(name='cap_adapter', location=Coordinate(400.000, 000.000, 000.000), size_x=127.76, size_y=85.59, size_z=14.07, category=resource_holder),\n", - " ByonoyBase(name='base_base', location=None, size_x=138, size_y=95.7, size_z=27.7, category=None),\n", - " PlateReader(name='base_reader', location=Coordinate(000.000, 000.000, 010.660), size_x=138, size_y=95.7, size_z=0, category=None))" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "cap_adapter, base, reader_cap" - ] - }, - { - "cell_type": "markdown", - "id": "1ccafe3d-56c1-405f-b79e-6d4f8930e49d", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Usage / Machine Features" - ] - }, - { - "cell_type": "markdown", - "id": "30619f34-af58-4a74-b4fd-e2d53033c2de", - "metadata": {}, - "source": [ - "### Query Machine Configuration" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "2254228f-2864-4174-a615-9d1aed119ad5", - "metadata": { - "scrolled": true - }, - "outputs": [ - { - "data": { - "text/plain": [ - "[420, 600]" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await reader_cap.backend.get_available_absorbance_wavelengths()" - ] - }, - { - "cell_type": "markdown", - "id": "fc15c1b4-be77-4180-a5ce-d8a31480d0d4", - "metadata": {}, - "source": [ - "### Measure Absorbance" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "e5a1d2e2-7b2c-4077-bde6-338f257b1993", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
01234567891011
00.000002-0.0000020.0000830.0000380.0000482.975314e-050.000075NoneNoneNoneNoneNone
10.0000620.0000510.0000400.0000180.0000643.082320e-050.000044NoneNoneNoneNoneNone
20.0000880.0000550.0000690.0000090.0000797.937726e-050.000078NoneNoneNoneNoneNone
30.0000800.0000500.0000090.0000690.0000673.182423e-050.000070NoneNoneNoneNoneNone
40.0000420.0000030.0001100.000005-0.000005-1.815412e-050.000070NoneNoneNoneNoneNone
50.0000550.000054-0.0000230.0000410.0000369.664112e-070.000039NoneNoneNoneNoneNone
60.0000460.0000250.0000190.0000170.0000393.658781e-050.000066NoneNoneNoneNoneNone
70.0000380.0000180.0000550.0000410.000034-3.216584e-05NaNNoneNoneNoneNoneNone
\n", - "
" - ], - "text/plain": [ - " 0 1 2 3 4 5 6 \\\n", - "0 0.000002 -0.000002 0.000083 0.000038 0.000048 2.975314e-05 0.000075 \n", - "1 0.000062 0.000051 0.000040 0.000018 0.000064 3.082320e-05 0.000044 \n", - "2 0.000088 0.000055 0.000069 0.000009 0.000079 7.937726e-05 0.000078 \n", - "3 0.000080 0.000050 0.000009 0.000069 0.000067 3.182423e-05 0.000070 \n", - "4 0.000042 0.000003 0.000110 0.000005 -0.000005 -1.815412e-05 0.000070 \n", - "5 0.000055 0.000054 -0.000023 0.000041 0.000036 9.664112e-07 0.000039 \n", - "6 0.000046 0.000025 0.000019 0.000017 0.000039 3.658781e-05 0.000066 \n", - "7 0.000038 0.000018 0.000055 0.000041 0.000034 -3.216584e-05 NaN \n", - "\n", - " 7 8 9 10 11 \n", - "0 None None None None None \n", - "1 None None None None None \n", - "2 None None None None None \n", - "3 None None None None None \n", - "4 None None None None None \n", - "5 None None None None None \n", - "6 None None None None None \n", - "7 None None None None None " - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "readings_420_nested_list = await reader_cap.backend.read_absorbance(\n", - " wells=plate.children[:55],\n", - " wavelength = 420, # units: nm\n", - " output_nested_list=True\n", - ")\n", - "\n", - "import pandas as pd\n", - "\n", - "pd.DataFrame(readings_420_nested_list)" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "9fccbccb-d569-4883-be04-290c639b99f0", - "metadata": {}, - "outputs": [], - "source": [ - "import time" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "fbf13573-8754-4a8d-8d26-93dff422ab22", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
01234567891011
00.0000970.0000790.0000870.0000920.0000850.0000970.0000860.0000880.0000740.0001110.0000660.000076
10.0000500.0000740.0000630.0000540.0000730.0000660.0000500.0000610.0000820.0000950.0000510.000059
20.0000930.0000490.0000310.0000810.0000670.0000830.0000660.0001040.0000740.0000640.0000400.000069
30.0000960.0000740.0000230.0000750.0001000.0000530.0000640.0000870.0000700.0000730.0000500.000054
40.0000870.0000740.0001610.0000700.0000800.0000690.0001010.0001060.0001120.0001030.0000590.000062
50.0000580.0000670.0000230.0000680.0000360.0000530.0000350.0000440.0000450.0000970.0000390.000033
60.0000800.0000360.0000120.0000790.0000620.0000610.0000460.0000840.0000430.0000500.0000260.000064
70.0000870.0000530.0000720.0000600.0000760.0000310.0000340.0000840.0000860.0000540.0000320.000079
\n", - "
" - ], - "text/plain": [ - " 0 1 2 3 4 5 6 \\\n", - "0 0.000097 0.000079 0.000087 0.000092 0.000085 0.000097 0.000086 \n", - "1 0.000050 0.000074 0.000063 0.000054 0.000073 0.000066 0.000050 \n", - "2 0.000093 0.000049 0.000031 0.000081 0.000067 0.000083 0.000066 \n", - "3 0.000096 0.000074 0.000023 0.000075 0.000100 0.000053 0.000064 \n", - "4 0.000087 0.000074 0.000161 0.000070 0.000080 0.000069 0.000101 \n", - "5 0.000058 0.000067 0.000023 0.000068 0.000036 0.000053 0.000035 \n", - "6 0.000080 0.000036 0.000012 0.000079 0.000062 0.000061 0.000046 \n", - "7 0.000087 0.000053 0.000072 0.000060 0.000076 0.000031 0.000034 \n", - "\n", - " 7 8 9 10 11 \n", - "0 0.000088 0.000074 0.000111 0.000066 0.000076 \n", - "1 0.000061 0.000082 0.000095 0.000051 0.000059 \n", - "2 0.000104 0.000074 0.000064 0.000040 0.000069 \n", - "3 0.000087 0.000070 0.000073 0.000050 0.000054 \n", - "4 0.000106 0.000112 0.000103 0.000059 0.000062 \n", - "5 0.000044 0.000045 0.000097 0.000039 0.000033 \n", - "6 0.000084 0.000043 0.000050 0.000026 0.000064 \n", - "7 0.000084 0.000086 0.000054 0.000032 0.000079 " - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/plain": [ - "1.5100939273834229" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "start_time = time.time()\n", - "\n", - "readings_600_nested_list = await reader_cap.backend.read_absorbance(\n", - " wells=plate.children[:],\n", - " wavelength = 600, # units: nm\n", - " output_nested_list=True\n", - ")\n", - "display(pd.DataFrame(readings_600_nested_list))\n", - "\n", - "\n", - "time.time() - start_time" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a6f77438-147e-4e3d-bcf8-dbfa0f443a46", - "metadata": {}, - "outputs": [], - "source": "start_time = time.time()\n\nreadings_600_nested_list = await reader_cap.backend.read_absorbance(\n wells=plate.children[:],\n wavelength = 600, # units: nm\n output_nested_list=True\n)\ndisplay(pd.DataFrame(readings_600_nested_list))\n\ntime.time() - start_time" - }, - { - "cell_type": "code", - "execution_count": null, - "id": "60719244-d75d-4e34-bb89-266608837ff0", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": 15, - "id": "1749dc00-c760-4993-b374-fb2ee09d2175", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
420nm600nm
A10.0000640.000100
B10.0000970.000033
C10.0001650.000086
D10.0001050.000082
E10.0001060.000132
.........
D80.0000730.000117
E80.0000850.000107
F80.0000570.000053
G80.0001240.000102
H80.0000790.000128
\n", - "

64 rows × 2 columns

\n", - "
" - ], - "text/plain": [ - " 420nm 600nm\n", - "A1 0.000064 0.000100\n", - "B1 0.000097 0.000033\n", - "C1 0.000165 0.000086\n", - "D1 0.000105 0.000082\n", - "E1 0.000106 0.000132\n", - ".. ... ...\n", - "D8 0.000073 0.000117\n", - "E8 0.000085 0.000107\n", - "F8 0.000057 0.000053\n", - "G8 0.000124 0.000102\n", - "H8 0.000079 0.000128\n", - "\n", - "[64 rows x 2 columns]" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "first_n_columns = 8\n", - "\n", - "readings_420 = await reader_cap.backend.read_absorbance(\n", - " wells=plate.children[:8*first_n_columns],\n", - " wavelength = 420 # units: nm\n", - ")\n", - "readings_600 = await reader_cap.backend.read_absorbance(\n", - " wells=plate.children[:8*first_n_columns],\n", - " wavelength = 600 # units: nm\n", - ")\n", - "\n", - "well_indexed_df = pd.DataFrame([readings_420, readings_600], index=[\"420nm\", \"600nm\"]).T\n", - "well_indexed_df" - ] - }, - { - "cell_type": "markdown", - "id": "1a33230d-8243-4d21-88e1-4a4eb6cba7c8", - "metadata": {}, - "source": [ - "## Disconnect from Reader" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "id": "21a72488", - "metadata": {}, - "outputs": [], - "source": [ - "await reader_cap.stop()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "62b8732a-8bd7-427d-85c3-ab900f2a48b6", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.11" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file diff --git a/docs/user_guide/02_analytical/plate-reading/byonoy/absorbance.ipynb b/docs/user_guide/02_analytical/plate-reading/byonoy/absorbance.ipynb deleted file mode 100644 index 769788b5b78..00000000000 --- a/docs/user_guide/02_analytical/plate-reading/byonoy/absorbance.ipynb +++ /dev/null @@ -1,231 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "63d8a7a6-1107-4334-8b73-598aa1ca97c4", - "metadata": {}, - "source": [ - "# Byonoy Absorbance 96 Automate\n", - "\n", - "- [OEM Link](https://byonoy.com/absorbance-automate/)\n", - "- **Communication Protocol / Hardware:** HID / USB-A/C\n", - "- **Communication Level:** Firmware\n", - "- VID:PID `16d0:1199`\n", - "- Takes a single SLAS-format 96-wellplate on the detection unit, enables movement of the cap/illumination unit over it, and reads all 96 wells simultaneously.\n", - "- Up to 6 configurable absorbance wavelengths (dependent on specifications during purchase)." - ] - }, - { - "cell_type": "markdown", - "id": "840adda3-0ea1-4e7c-b0cb-34dd2244de69", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Physical)\n", - "\n", - "The Byonoy Absorbance 96 Automate (A96A) is a an absorbance plate reader consisting of...\n", - "1. a `detection_unit` containing the light sensors,\n", - "2. a `illumination_unit` containing the light source,\n", - "3. a `parking_unit` representing a simple resource_holder for the `illumination_unit` that is equivalent to the detection unit in terms of shape, and\n", - "4. an `sbs_adapter` which is an optional holder for the `detection_unit` or `parking_unit`, enabling placement of this machine onto a standard SLAS/SBS-format plate holder.\n", - "\n", - "### Communication\n", - "It requires only one cable connection to be operational:\n", - "1. USB cable (USB-C at `base` end; USB-A at control PC end)" - ] - }, - { - "cell_type": "markdown", - "id": "e4aa8066-9eb5-4f8a-8d69-372712bdb3b5", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Programmatic)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4907224e", - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "38a9cf2d-840c-49ce-a886-da29c67fb124", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.plate_reading.byonoy import byonoy_a96a" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ad91bfca", - "metadata": {}, - "outputs": [], - "source": [ - "reader, illumination_unit = byonoy_a96a(\"Absorbance96 Automate\")\n", - "reader" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0f86d997", - "metadata": {}, - "outputs": [], - "source": [ - "await reader.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "30619f34-af58-4a74-b4fd-e2d53033c2de", - "metadata": {}, - "source": [ - "### Query Machine Configuration" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2254228f-2864-4174-a615-9d1aed119ad5", - "metadata": {}, - "outputs": [], - "source": [ - "wavelengths = await reader.backend.get_available_absorbance_wavelengths()\n", - "wavelengths" - ] - }, - { - "cell_type": "markdown", - "id": "fc15c1b4-be77-4180-a5ce-d8a31480d0d4", - "metadata": {}, - "source": [ - "### Measure Absorbance" - ] - }, - { - "cell_type": "markdown", - "id": "9c1d898e", - "metadata": {}, - "source": [ - "Before you can do a plate reading measurement in PLR, you need to assign a plate to the reader." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "34f54968", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n", - "demo_plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8f6a50bd", - "metadata": {}, - "outputs": [], - "source": [ - "reader.assign_child_resource(demo_plate)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e5a1d2e2-7b2c-4077-bde6-338f257b1993", - "metadata": {}, - "outputs": [], - "source": [ - "wavelength = wavelengths[0] # Choose the first available wavelength\n", - "\n", - "data = await reader.read_absorbance(\n", - " wavelength=wavelength,\n", - " use_new_return_type=True,\n", - ")\n", - "\n", - "print(f\"Wavelength: {data[0]['wavelength']} nm\")\n", - "print(f\"Time: {data[0]['time']}\")\n", - "print(f\"Temperature: {data[0]['temperature']}\")\n", - "print(\"Data\")\n", - "print(data[0]['data'])" - ] - }, - { - "cell_type": "markdown", - "id": "1a33230d-8243-4d21-88e1-4a4eb6cba7c8", - "metadata": {}, - "source": [ - "## Disconnect from Reader" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "21a72488", - "metadata": {}, - "outputs": [], - "source": [ - "await reader.stop()" - ] - }, - { - "cell_type": "markdown", - "id": "ad4bdba5", - "metadata": {}, - "source": [ - "## Resource model" - ] - }, - { - "cell_type": "markdown", - "id": "a2f610f3", - "metadata": {}, - "source": [ - "In the example above, we instantiated the Byonoy Absorbance 96 Automate reader using the `byonoy_a96a` function, which automatically creates the necessary resources for the reader." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5e58d4ef", - "metadata": {}, - "outputs": [], - "source": [ - "reader.illumination_unit_holder.resource is illumination_unit" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/02_analytical/plate-reading/byonoy/luminescence.ipynb b/docs/user_guide/02_analytical/plate-reading/byonoy/luminescence.ipynb deleted file mode 100644 index e283e15788f..00000000000 --- a/docs/user_guide/02_analytical/plate-reading/byonoy/luminescence.ipynb +++ /dev/null @@ -1,239 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "63d8a7a6-1107-4334-8b73-598aa1ca97c4", - "metadata": {}, - "source": [ - "# Byonoy Luminescence 96 Automate\n", - "\n", - "- [OEM Link](https://byonoy.com/luminescence-96-automate/)\n", - "- **Communication Protocol / Hardware:** HID / USB-A/C\n", - "- **Communication Level:** Firmware\n", - "- VID:PID `16d0:119b`\n", - "- Takes a single SLAS-format 96-wellplate on the base unit, enables movement of the reader unit over it, and reads all 96 wells simultaneously." - ] - }, - { - "cell_type": "markdown", - "id": "840adda3-0ea1-4e7c-b0cb-34dd2244de69", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Physical)\n", - "\n", - "The Byonoy Luminescence 96 Automate (L96A) is a luminescence plate reader consisting of...\n", - "1. a `base_unit` which holds the plate,\n", - "2. a `reader_unit` containing the light sensors that sits on top of the base unit.\n", - "\n", - "### Communication\n", - "It requires only one cable connection to be operational:\n", - "1. USB cable (USB-C at `reader_unit` end; USB-A at control PC end)" - ] - }, - { - "cell_type": "markdown", - "id": "e4aa8066-9eb5-4f8a-8d69-372712bdb3b5", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Programmatic)" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "4907224e", - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "38a9cf2d-840c-49ce-a886-da29c67fb124", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.plate_reading.byonoy import byonoy_l96a" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "ad91bfca", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "ByonoyLuminescence96Automate(name='Luminescence96 Automate_reader', location=Coordinate(000.000, 000.000, 006.300), size_x=138, size_y=97.5, size_z=41.7, category=plate_reader)" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "base_unit, reader_unit = byonoy_l96a(\"Luminescence96 Automate\")\n", - "reader_unit" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "0f86d997", - "metadata": {}, - "outputs": [], - "source": [ - "await reader_unit.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "fc15c1b4-be77-4180-a5ce-d8a31480d0d4", - "metadata": {}, - "source": [ - "### Measure Luminescence" - ] - }, - { - "cell_type": "markdown", - "id": "9c1d898e", - "metadata": {}, - "source": [ - "Before you can do a plate reading measurement in PLR, you need to assign a plate to the reader." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "34f54968", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n", - "demo_plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "8f6a50bd", - "metadata": {}, - "outputs": [], - "source": [ - "reader_unit.assign_child_resource(demo_plate)" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "e5a1d2e2-7b2c-4077-bde6-338f257b1993", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Time: 1770236622.314785\n", - "Temperature: None\n", - "Data\n", - "[[200552272.0, 201907664.0, 218108208.0, 234161952.0, 251950976.0, 8104904.0, 1781678.0, 1690456.0, 2708988.0, 2377269.0, 5855379.0, 289570432.0], [183959648.0, 183223568.0, 186845840.0, 187736528.0, 3184948.0, 1934996.0, 1431882.0, 1225921.0, 1186311.0, 1238371.0, 1392693.0, 3190326.0], [209724544.0, 200640128.0, 4695649.0, 2133116.0, 2067423.0, 1974764.0, 1769515.0, 1576723.0, 1396270.0, 1392762.0, 1163494.0, 1340111.0], [8631671.0, 2948252.0, 1878185.0, 1944314.0, 1928056.0, 1818789.0, 1538142.0, 1391728.0, 1203908.0, 1180876.0, 1094184.0, 1385255.0], [2561222.0, 2007909.0, 2202352.0, 2187162.0, 2016595.0, 1628429.0, 1532333.0, 1342909.0, 1310737.0, 1187646.0, 1123093.0, 1284595.0], [2612154.0, 2109698.0, 2374385.0, 2376566.0, 2073923.0, 1862185.0, 1761917.0, 1637089.0, 1429714.0, 1279758.0, 937221.0, 1471533.0], [3401529.0, 2545716.0, 2582033.0, 2696296.0, 2458846.0, 2448346.0, 2106862.0, 1956427.0, 1705456.0, 1578239.0, 1126263.0, 1378166.0], [6511548.0, 5467399.0, 6249315.0, 6408278.0, 5996644.0, 5163665.0, 5879837.0, 5222682.0, 4798715.0, 4238464.0, 3052718.0, 1889772.0]]\n" - ] - } - ], - "source": [ - "data = await reader_unit.read_luminescence(\n", - " focal_height=1,\n", - " use_new_return_type=True,\n", - ")\n", - "\n", - "print(f\"Time: {data[0]['time']}\")\n", - "print(f\"Temperature: {data[0]['temperature']}\")\n", - "print(\"Data\")\n", - "print(data[0]['data'])" - ] - }, - { - "cell_type": "markdown", - "id": "1a33230d-8243-4d21-88e1-4a4eb6cba7c8", - "metadata": {}, - "source": [ - "## Disconnect from Reader" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "21a72488", - "metadata": {}, - "outputs": [], - "source": [ - "await reader_unit.stop()" - ] - }, - { - "cell_type": "markdown", - "id": "ad4bdba5", - "metadata": {}, - "source": [ - "## Resource model" - ] - }, - { - "cell_type": "markdown", - "id": "a2f610f3", - "metadata": {}, - "source": [ - "In the example above, we instantiated the Byonoy Luminescence 96 Automate reader using the `byonoy_l96a` function, which automatically creates the necessary resources for the reader." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "5e58d4ef", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "base_unit.reader_unit_holder.resource is reader_unit" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.25" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/02_analytical/plate-reading/cytation.ipynb b/docs/user_guide/02_analytical/plate-reading/cytation.ipynb deleted file mode 100644 index 21844714c5b..00000000000 --- a/docs/user_guide/02_analytical/plate-reading/cytation.ipynb +++ /dev/null @@ -1,629 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Cytation\n", - "\n", - "Cytation is an Agilent BioTek microplate reader / imager combination. This backend has been tested on the Cytation 1 and 5.\n", - "\n", - "See installation instructions `cytation-imager`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "from pylabrobot.plate_reading import ImageReader, ImagingMode, Objective\n", - "from pylabrobot.plate_reading import CytationBackend" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "# for imaging, we need an environment variable to point to the Spinnaker GenTL file\n", - "import os\n", - "os.environ[\"SPINNAKER_GENTL64_CTI\"] = \"/usr/local/lib/spinnaker-gentl/Spinnaker_GenTL.cti\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "pr = ImageReader(name=\"PR\", size_x=0,size_y=0,size_z=0, backend=CytationBackend())\n", - "await pr.setup(use_cam=True)" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'1320200 Version 2.07'" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await pr.backend.get_firmware_version()" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.open(slow=False)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Before closing, assign a plate to the plate reader. This determines the spacing of the loading tray in the machine, as well as the positioning of wells where spectrophotometric measurements and pictures will be taken." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import CellVis_24_wellplate_3600uL_Fb\n", - "plate = CellVis_24_wellplate_3600uL_Fb(name=\"plate\")\n", - "pr.assign_child_resource(plate)" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.close(slow=True)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Plate reading\n", - "\n", - "Note: these measurements were taken with a 96 well plate." - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "data = await pr.read_absorbance(wavelength=434)\n", - "plt.imshow(data)" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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4YRobm6OCRMmxHnnnRdPP/103rMKb/ny5VEqlWLJkiV5TymkF154IT75yU/GuHHjYsyYMXH00UfH448/nvesQunr64tLL700pk2bFmPGjIkjjzwyvvWtbw36Xg9EPPzww3HuuedGW1tblEqluPvuuwf8eZZlcdlll8XkyZNjzJgxMX/+/Hj22WfzGVtnCh0Wd911VyxdujQuv/zyeOKJJ+KYY46Js846K7Zt25b3tEJZs2ZNdHR0xNq1a+OBBx6IXbt2xZlnnhm9vb15TyusdevWxc033xyzZ8/Oe0ohvfzyyzFv3rw44IAD4t57740//vGP8d3vfjcOOeSQvKcVylVXXRUrVqyIG264If70pz/FVVddFVdffXVcf/31eU+re729vXHMMcfEjTfe+KZ/fvXVV8d1110XN910Uzz66KNx0EEHxVlnnRWvv17n7yS2L2QFduKJJ2YdHR395/v6+rK2trass7Mzx1XFt23btiwisjVr1uQ9pZC2b9+eHXXUUdkDDzyQfeADH8guvvjivCcVzle+8pXslFNOyXtG4Z199tnZhRdeOOCyj3zkI9nChQtzWlRMEZGtWrWq/3y1Ws0mTZqUfec73+m/7JVXXsnK5XL24x//OIeF9aWwz1js3Lkz1q9fH/Pnz++/rKGhIebPnx+PPPJIjsuKr7u7OyIixo4dm/OSYuro6Iizzz57wOcmtbnnnntizpw5cf7558eECRPi2GOPjVtvvTXvWYVz8sknx+rVq+OZZ56JiIjf//738Zvf/CYWLFiQ87Ji27RpU2zZsmXA13hra2vMnTvX95/I4U3IUnnppZeir68vJk6cOODyiRMnxp///OecVhVftVqNJUuWxLx582LWrFl5zymcO++8M5544olYt25d3lMK7bnnnosVK1bE0qVL42tf+1qsW7cuLrroohg1alQsWrQo73mF8dWvfjV6enpi+vTp0djYGH19fXHFFVfEwoUL855WaFu2bImIeNPvP//6s7ezwoYFI6OjoyOeeuqp+M1vfpP3lMLp6uqKiy++OB544IEYPXp03nMKrVqtxpw5c+LKK6+MiIhjjz02nnrqqbjpppuERQ1+8pOfxI9+9KNYuXJlzJw5MzZs2BBLliyJtrY29yMjprA/Cjn00EOjsbExtm7dOuDyrVu3xqRJk3JaVWyLFy+OX/7yl/HrX/96RN7afn+3fv362LZtWxx33HHR1NQUTU1NsWbNmrjuuuuiqakp+vr68p5YGJMnT44ZM2YMuOw973lPbN68OadFxfTlL385vvrVr8bHP/7xOProo+NTn/pUXHLJJdHZ2Zn3tEL71/cY33/eXGHDYtSoUXH88cfH6tWr+y+rVquxevXqOOmkk3JcVjxZlsXixYtj1apV8atf/SqmTZuW96RCOv300+PJJ5+MDRs29J/mzJkTCxcujA0bNkRjY2PeEwtj3rx5b/iV52eeeSYOO+ywnBYV06uvvhoNDQMf5hsbG6Narea0aP8wbdq0mDRp0oDvPz09PfHoo4/6/hMF/1HI0qVLY9GiRTFnzpw48cQT49prr43e3t644IIL8p5WKB0dHbFy5cr4+c9/Hs3Nzf0/I2xtbY0xY8bkvK44mpub3/C6lIMOOijGjRvn9So1uuSSS+Lkk0+OK6+8Mj760Y/GY489FrfcckvccssteU8rlHPPPTeuuOKKmDp1asycOTN+97vfxTXXXBMXXnhh3tPq3o4dO2Ljxo395zdt2hQbNmyIsWPHxtSpU2PJkiXx7W9/O4466qiYNm1aXHrppdHW1hbnnXdefqPrRd6/lrK3rr/++mzq1KnZqFGjshNPPDFbu3Zt3pMKJyLe9HT77bfnPa3w/Lrp8P3iF7/IZs2alZXL5Wz69OnZLbfckvekwunp6ckuvvjibOrUqdno0aOzI444Ivv617+eVSqVvKfVvV//+tdv+ri4aNGiLMv+41dOL7300mzixIlZuVzOTj/99Ozpp5/Od3Sd8LbpAEAyhX2NBQBQf4QFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMv8ftizzR1e5mXcAAAAASUVORK5CYII=", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "data = await pr.read_fluorescence(\n", - " excitation_wavelength=485, emission_wavelength=528, focal_height=7.5\n", - ")\n", - "plt.imshow(data)" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "data = await pr.read_luminescence(focal_height=4.5)\n", - "plt.imshow(data)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Shaking" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.shake(\n", - " shake_type=CytationBackend.ShakeType.LINEAR,\n", - " frequency=4 # linear frequency in mm, 1 <= frequency <= 6\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.stop_shaking()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Heating and cooling\n", - "\n", - "Cytation supports heating and active cooling." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.set_temperature(temperature=37) # Temperature in degrees C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.get_current_temperature() # Returns temperature in degrees C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.stop_heating_or_cooling() # Stop temperature control" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Imaging" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Installation\n", - "\n", - "See [Cytation imager installation instructions](../../_getting-started/installation.md#cytation-imager)." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Usage\n", - "\n", - "Supported objectives:\n", - "\n", - "- `O_4x_PL_FL_PHASE`\n", - "- `O_20x_PL_FL_PHASE`\n", - "- `O_40x_PL_FL_PHASE`\n", - "\n", - "Supported imaging modes:\n", - "\n", - "- `C377_647`\n", - "- `C400_647`\n", - "- `C469_593`\n", - "- `ACRIDINE_ORANGE`\n", - "- `CFP`\n", - "- `CFP_FRET_V2`\n", - "- `CFP_YFP_FRET`\n", - "- `CFP_YFP_FRET_V2`\n", - "- `CHLOROPHYLL_A`\n", - "- `CY5`\n", - "- `CY5_5`\n", - "- `CY7`\n", - "- `DAPI`\n", - "- `GFP`\n", - "- `GFP_CY5`\n", - "- `OXIDIZED_ROGFP2`\n", - "- `PROPOIDIUM_IODIDE`\n", - "- `RFP`\n", - "- `RFP_CY5`\n", - "- `TAG_BFP`\n", - "- `TEXAS_RED`\n", - "- `YFP`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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WA0wnvrUpx40pAE1CnnoQx+67f9KjL785S/fO9ngpnBdW+3De/xRj3rUMdcsXN84nH/++Vsjm5qydMYf85jHbO2PJ5woWvG2t71ta3pUKl7NnS688MJbZY5HBnZHDF73utftTj311N3v/M7v7D7wgQ/snv70p+++6Iu+aPev//qvt/XQbjfw4he/ePc1X/M1u3/+538++Ln66qsPvn/GM56xu9e97rV7y1vesvuzP/uz3YMe9KDdt37rtx58/z//8z+7+9///ruzzjpr9+d//ue7yy67bHeXu9xl98IXvnB3MkN4+Mmf/MndRRddVErr7uKLLz7m+1/4hV/Y3fnOd95dcsklu7/8y7/cPfrRj97d5z732V177bUH1zzqUY/aPeABD9i9613v2v3RH/3R7n73u9/uB3/wBw++v+aaa3Zf+qVfunvCE56we//737977Wtfu/u8z/u83W/+5m/uTia4KVw/+clP3nA59/hHP/rRY65ZuL5xeOQjH7l71atetc39L/7iL3bf9V3ftfvyL//y3X/8x3+cUFrxN3/zN7vP//zP3z3nOc/ZffCDH9z92q/92u5Od7rT7vLLL7/V53x7hSPH6L/5m795d9555x38f9111+3ucY977M4///zbdFy3N0Yfgbs++NjHPrY75ZRTdm94wxsOPvurv/qrjZi+853v3P7vsN7xjnfc/cu//MvBNRdccMHuC7/wC3f//d//fSvM4H8/HGY+n/70p3d3u9vddr/4i794DK4/93M/d2MgQUSu+/70T//04Jo3velNuzvc4Q67f/qnf9r+f+UrX7k7/fTTj8HzC17wgt0ZZ5yxO1nhhhj9937v997gPQvXNx8+8pGPbDh7+9vffkJpxfOf//xN8Zjw+Mc/fhM0FhwfHCnTff153/Oe92wmz9ncpv/f+c533qZju71BJuPMnve9730382XmtSD81m954jizfj2y4bjfX/u1X7v1aQaPfOQjt25V9dBe8JlQD+96o0+81qwi19PEaybkb/qmbzq4puvb4+9+97sPrnnYwx629fSeuM+kWt/uBceagzMVn3HGGXvnnnvu1l8cLFzffKjP++wceqJoRdfMZ7hm0fTjhyPF6P/t3/5t77rrrjtm0wT9HxFdcHwQc8kHdvnll+9dcMEFGxPKD1k7xPAYYYsI3hCO+319a+C7BZ8J8HJje7ffMaYJn/M5n7MR1oX7mwf541/96lfvveUtb9l72ctetvf2t7997+yzz97oR7BwffPg05/+9N6znvWsvW/7tm/bu//97799dqJoxQ1dkzBw7bXX3qLzOipwZNvULvj/hwge+Lqv+7qN8d/73vfee/3rX78FiS1YcHuHH/iBHzj4O42yff4VX/EVm5b/iEc84jYd2+0RCrh7//vfv/eOd7zjth7KgqOu0d/lLnfZu9Od7vQZUZ39f7e73e02G9ftHZLIv+qrvmrvQx/60IbHXCQf+9jHbhDH/b6+NfDdgs8EeLmxvdvvj3zkI8d8X3Ry0eEL958d5KKKfrTHg4Xr44dnPvOZe2984xv33vrWt+7d8573PPj8RNGKG7qmbIileJyEjD4z0Td+4zdu5rhpUur/Bz/4wbfp2G7PUErRhz/84S3tK/yecsopx+A4n2Q+fDju9/ve975jCOUVV1yxHcyv/uqvvk3m8L8d7nOf+2wEbeI102T+4InXiGa+T3DVVVdtezyri2tKLcs3OnGfH/r000+/Ved0e4J//Md/3Hz07fFg4fqmoTjHmPzFF1+84aY9POFE0Yqumc9wzaLpNwN2RzC9rkjlCy+8cIuc/ZEf+ZEtvW5GdS64cXjuc5+7e9vb3rb727/9290f//Efb6kvpbwUVStlpjSaq666akuZefCDH7z9HE6Z+c7v/M4t7aY0mC/5ki856dPrPv7xj28pRP109H75l395+/vv//7vD9Lr2quXXnrp7r3vfe8WFX596XXf8A3fsHv3u9+9e8c73rH7yq/8ymNSvop0LuXriU984pb21HkoNelkSfk6Hlz33Y//+I9vkd/t8SuvvHJ35plnbrj8xCc+cfCMhesbh3PPPXdLB41WzDTF//qv/zq45kTQCul1z3ve87ao/Ve84hUrve5mwpFj9EF5lm2u8ulLtysPdsHxQ6krd7/73Tf8fdmXfdn2/4c+9KGD72M8P/qjP7qlFnUAH/vYx24HfMLf/d3f7c4+++wtrzghIeHhU5/61O5khre+9a0b0zn8U6qXFLuf+qmf2phHwuojHvGI3V//9V8f84x///d/35jNF3zBF2wpSD/0Qz+0Ma4J5eA/5CEP2Z7R+iVAnGxwY7iOEcVYYiilf9373vfe6m0cVgYWrm8crg+//ZRbf6JpRev59V//9RtNuu9973vMOxbcNKx+9AsWLFiwYMERhiPlo1+wYMGCBQsWHAuL0S9YsGDBggVHGBajX7BgwYIFC44wLEa/YMGCBQsWHGFYjH7BggULFiw4wrAY/YIFCxYsWHCEYTH6BQsWLFiw4AjDYvQLFixYsGDBEYbF6BcsWLBgwYIjDIvRL1iwYMGCBUcYFqNfsGDBggUL9o4u/F/GQ7DVqtyHzQAAAABJRU5ErkJggg==", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "res = await pr.capture(\n", - " well=(1, 2),\n", - " mode=ImagingMode.BRIGHTFIELD,\n", - " objective=Objective.O_4X_PL_FL_Phase,\n", - " focal_height=0.833,\n", - " exposure_time=5,\n", - " gain=16,\n", - " led_intensity=10,\n", - ")\n", - "plt.imshow(res.images[0], cmap=\"gray\", vmin=0, vmax=255)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Autofocus\n", - "\n", - "Auto-focus can be configured with `pr.backend.set_auto_focus_search_range` where the parameters are the minimum and maximum focus heights in mm respectively." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 41, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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EDK9dfhXWGGFFVZYYTdDLruZAbffaPSq9SV9S9VYYe+DegVttJlassLehRXP+w5WXO8bWPH834P4Bndr7HeMnp83Jr9uooseVO1a4aKFFc/7DlZc7xtY8fzfwQVRZP9/BNcKK6ywwgorrLDrYGX0K6ywwgorrLCLYdcy+qISjznmmHMti7vC3oMVz/sGVjzvO1hxvW9gxfO+g10bdb/CCiussMIKK+xijX6FFVZYYYUVVlgZ/QorrLDCCivsalgZ/QorrLDCCivsYlgZ/QorrLDCCivsYlgZ/QorrLDCCivsYtiVjL6b87qhqssD7njHOy73Ra9wweElL3nJHnfA97rJTW6y/X0XXxx11FHLjU5d5NCVxK4QBZUevu9977tcINKlJEcfffT2RTT7K5x22mlb97vf/ZZyleH0Ix/5yB7flwDz4he/eLnopss2jjjiiOVSkQld8vGIRzxi+0rVxz/+8ds3LoLvfOc7W3e7292W/V+J0de85jVb+xv8J1x3rfbOPX6ve91rj2dWXJ8/HHvssctlMV2Q0xl/4AMfuFyPO2Fv0YpTTjll67a3ve2Sitdtcscff/w+meOugc0ugw984AOby172spt3vetdmzPOOGPzxCc+cXPlK1958+tf//riHtolBo455pjNzW52s80vf/nL7ddvfvOb7e+f/OQnb6597WtvTjzxxM3Xvva1zZ3udKfNne985+3v//nPf25ufvObb4444ojNN7/5zc0nP/nJ3A3zMwAABrtJREFUzSGHHLJ53vOet9mfITy84AUv2Hz4wx8upXVzwgkn7PH9q171qs2VrnSlzUc+8pHNt7/97c3973//zfWud73NX//61+1n7nWve21udatbbb785S9vPv/5z29ueMMbbh7+8Idvf//HP/5xc9WrXnXziEc8YnP66adv3v/+928uf/nLb97+9rdv9if4T7h+9KMfveBy7vHf//73ezyz4vr84Z73vOfm3e9+9zL3b33rW5v73Oc+m+tc5zqbP//5z3uVVvz4xz/eXOEKV9g885nP3Hzve9/bvOlNb9pc+tKX3nz605/e53O+pMKuY/R3uMMdNkcdddT2///6178217jGNTbHHnvsxTquSxqjj8CdG/zhD3/YHHjggZsPfehD2599//vfX4jpl770peX/DusBBxyw+dWvfrX9zHHHHbe54hWvuPn73/++D2bwvw87mc/ZZ5+9udrVrrZ57WtfuweuL3e5yy0MJIjI9buvfvWr28986lOf2lzqUpfa/PznP1/+f+tb37o5+OCD98Dzc5/73M2hhx662V/hvBj9Ax7wgPP8zYrrCw9nnXXWgrNTTz11r9KK5zznOYviMeGhD33oImiscMFgV5nuu8P361//+mLynJfb9P+XvvSli3VslzTIZJzZ8/rXv/5ivnQHd/jtbuSJ48z63ZsNx73f4ha3WO6+Bve85z2X26rOOOOMi2E2//tw5plnLnefT7x2WUWup4nXTMi3v/3tt5/p+fb4V77yle1n7n73uy93fU/cZ1LtTu8V9jQHZyo+9NBDt4488sjlPnOw4vrCQ/fOz5tD9xat6JnZhmdWmn7BYVcx+t/+9rdb//rXv/bYNEH/R0RXuGAQc8kH9ulPf3rruOOOW5hQfsiuQwyPEbaI4HnhuPdzWwPfrfDvAC/nt3d7jzFNuMxlLrMQ1hX3Fw7yx7/nPe/ZOvHEE7de/epXb5166qlb9773vRf6Eay4vnBw9tlnbz396U/fustd7rJ185vffPlsb9GK83omYeCvf/3rRTqv3QK79praFf7vEMEDt7zlLRfGf93rXnfrgx/84BIktsIKl3R42MMetv13GmX7/AY3uMGi5R9++OEX69guiVDA3emnn771hS984eIeygq7XaM/5JBDti596Uv/W1Rn/1/tale72MZ1SYck8hvf+MZbP/zhDxc85iL5wx/+cJ447v3c1sB3K/w7wMv57d3ezzrrrD2+Lzq56PAV9/8d5KKKfrTHgxXXFxye+tSnbn384x/fOvnkk7euda1rbX++t2jFeT1TNsSqeOyHjD4z0e1ud7vFHDdNSv1/2GGHXaxjuyRDKUU/+tGPlrSv8HvggQfugeN8kvnw4bj37373u3sQys9+9rPLwbzpTW96sczhfx2ud73rLQRt4jXTZP7gideIZr5PcNJJJy17PKuLZ0otyzc6cZ8f+uCDD96nc7okwc9+9rPFR98eD1Zc/2cozjEmf8IJJyy4aQ9P2Fu0omdmG55ZafqFgM0uTK8rUvn4449fImef9KQnLel1M6pzhfOHZz3rWZtTTjllc+aZZ26++MUvLqkvpbwUVStlpjSak046aUmZOeyww5bXzpSZe9zjHkvaTWkwV7nKVfb79Lo//elPSwpRr47e61//+uXvn/70p9vpde3Vj370o5vvfOc7S1T4uaXX3eY2t9l85Stf2XzhC1/Y3OhGN9oj5atI51K+HvnIRy5pT52HUpP2l5SvC4Lrvnv2s5+9RH63xz/3uc9tbnvb2y64/Nvf/rbdxorr84cjjzxySQeNVsw0xb/85S/bz+wNWiG97uijj16i9t/ylres6XUXEnYdow/Ks2xzlU9ful15sCtccCh15epXv/qCv2te85rL/z/84Q+3v4/xPOUpT1lSizqAD3rQg5YDPuEnP/nJ5t73vveSV5yQkPDwj3/8Y7M/w8knn7wwnZ2vUr2k2L3oRS9amEfC6uGHH775wQ9+sEcbv/vd7xZmc9BBBy0pSI997GMXxjWhHPy73vWuSxutXwLE/gbnh+sYUYwlhlL613Wve92l3sZOZWDF9fnDueG3V7n1e5tWtJ63vvWtF5p0/etff48+VvjPsN5Hv8IKK6ywwgq7GHaVj36FFVZYYYUVVtgTVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCClu7F/4fbsHV1Hx2UAEAAAAASUVORK5CYII=", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "from pylabrobot.plate_reading.standard import AutoFocus\n", - "from pylabrobot.plate_reading.imager import evaluate_focus_nvmg_sobel\n", - "\n", - "res = await pr.capture(\n", - " well=(1, 2),\n", - " mode=ImagingMode.BRIGHTFIELD,\n", - " objective=Objective.O_4X_PL_FL_Phase,\n", - " focal_height=AutoFocus(\n", - " low=1.8,\n", - " high=2.0,\n", - " evaluate_focus=evaluate_focus_nvmg_sobel,\n", - " timeout=30,\n", - " ),\n", - " exposure_time=5,\n", - " gain=16,\n", - " led_intensity=10\n", - ")\n", - "plt.imshow(res.images[0], cmap=\"gray\", vmin=0, vmax=255)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Autoexposure" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Two autoexposure functions are available in the PLR library:\n", - "- `max_pixel_at_fraction`: the value of the highest pixel in the image is a fraction of the maximum possible value (e.g. highest value is 50% of max, which would be 255/2 = 127.5 in the case of an 8 bit image)\n", - "- `fraction_overexposed`: the fraction of pixels at the cap (eg 255 for an 8 bit image) should be a certain fraction of the total number of pixels (e.g. 0.5% of pixels should be at the cap, so 0.005 * total_pixels). This is useful for images that are not well illuminated, as it ensures that a certain fraction of pixels is overexposed, which can help with image quality.\n", - "\n", - "You can also define your own autoexposure function.\n", - "\n", - "The `AutoExposure` dataclass is used to configure the autoexposure settings, including the evaluation function, maximum number of rounds, and low and high exposure time limits." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.plate_reading.imager import Imager, max_pixel_at_fraction, fraction_overexposed\n", - "from pylabrobot.plate_reading.standard import AutoExposure\n", - "\n", - "res = await pr.capture(\n", - " exposure_time=AutoExposure(\n", - " # evaluate_exposure=fraction_overexposed(fraction=0.005, margin=0.005/10),\n", - " evaluate_exposure=max_pixel_at_fraction(fraction=0.90, margin=0.05),\n", - " max_rounds=15,\n", - " low=1,\n", - " high=100\n", - " ),\n", - " well=(2, 2),\n", - " mode=ImagingMode.PHASE_CONTRAST,\n", - " objective=Objective.O_20X_PL_FL_Phase,\n", - " focal_height=1.8, # focal height must be specified when using auto exposure\n", - " gain=20 # gain must be specified when using auto exposure\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Exporting\n", - "\n", - "`.capture` returns a `List[Image]` where `Image = List[List[float]]` where each item is `0 <= x <= 255`. You can export this to an image file in many ways. Here's one example of exporting to a 16-bit tiff:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from PIL import Image\n", - "import numpy as np\n", - "\n", - "array = np.array(res.images[0], dtype=np.float32)\n", - "array_uint16 = (array * (65535 / 255)).astype(np.uint16)\n", - "Image.fromarray(array_uint16).save(\"test.tiff\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Coverage\n", - "\n", - "Use the `coverage` parameter to take multiple pictures of the same well. The `coverage` parameter is an tuple `(num_rows, num_columns)` or `\"full\"`.\n", - "\n", - "When we send the exact same commands as gen5.exe, with overlap = 0, we still get some overlap in the resulting images. This is probably because gen5.exe crops. For now, we don't support stitching or cropping in PLR yet, but we will in the future." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "16" - ] - }, - "execution_count": 31, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "num_rows = 4\n", - "num_cols = 4\n", - "\n", - "res = await pr.capture(\n", - " well=(1, 2),\n", - " mode=ImagingMode.BRIGHTFIELD,\n", - " objective=Objective.O_4X_PL_FL_Phase,\n", - " focal_height=0.833,\n", - " exposure_time=5,\n", - " gain=16,\n", - " coverage=(num_rows, num_cols),\n", - " center_position=(-6, 0),\n", - ")\n", - "len(res.images)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA4sAAAJ8CAYAAABX8zR2AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlHJYcgAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs/dnTftt61/XvDbHv+77vSTCmIQnpk52AUFGkkcKyL/8APLYsqzy0PLA80CqrLPQAqdIiGGJCIAkhhCR7JwQSENDYYoMtKvYdsH/1fszr+X32yLy/37XW8+y119prXlV33fc955hjjjnHNca4Plc3Pv7pT3/60x+76aabbrrppptuuummm2666aahn7N/brrppptuuummm2666aabbropusHiTTfddNNNN91000033XTTTT+LbrB400033XTTTTfddNNNN91008+iGyzedNNNN91000033XTTTTfd9LPoBos33XTTTTfddNNNN9100003/Sy6weJNN91000033XTTTTfddNNNP4tusHjTTTfddNNNN91000033XTTz6IbLN5000033XTTTTfddNNNN930s+gGizfddNNNN91000033XTTTTf9LPqCj71D+vP+vD/vnRa96bNAH//4x58+P/fn/tyPffrTn/6MY1HH+vycn/P/4f8/8Sf+xNM5ZVH/XfOme6Hq+5N/8k/+rHNf8AVf8HTuj//xP/4Z7al9ff7f//f/fWvdfnuOs61nuT/yR/7IG9t90zujv/Av/As/493Wl3hmqXNX/a+vzr48+epRX1+VuSp79Xt5/Ly+T88Q/11de967svvsf8qf8qe89Rm3Hd5XdWz5R8/YWFH2fKfG9TmOle27MfWn/ql/6vO1ykb7zB3rXj2P3//X//V/PZVxvrr+rD/rz/qMZ/WpfvV1bbTvXJn/7X/7357u8af9aX/aZ4zf/+f/+X+enlN9O0f5/umf/unPeEc3vXv6wi/8ws/ol6h+6f3iJzwTnXNsZesn1yA80XE8G20Z88Ly4PJR1PjAy9WBl7auzim342brOe99/l++8r1t2bY6j7+N4bPcWef5fI/WqvP+u1477h7m10fPfHX/d/p+3nb8teh//p//589q/R8V+vP//D//fe+7m256N2P5HYPFmz43ZIEg6O0xQmMLzpVguwvgudCcgPBqcdx7bPldaM+6Kn91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AHtr33X97QBs9DKPszEeoRfDLjV9RuTtNVoqLTPHQw6ilJwaRqGJJz3pSQeBx1hFz3lCzT+nT+8Ej+FpZ6blTCnKAe+sh3fI255zBKREZ0h3pmGpoh1J4Lu6T3ZOVEZTDT6KHJTpEI8r5a2zDcvYKH238Uv/6TzUsgnMzfv7XfTfPQm4mTZTmrBx6sTq/e2td8ff8As81vd1Nq2OJOdWKbspBDm86oKdEls2Rc66jIsFp0N8HX5W11ojJbiBNjrLs26Z9jvZxwgqktzxRnVDrENwipbvjNEz3JeDMEMoBa3IfHNr3+vcW0fT0h89h3z2XfK4YzXQF7ncuY/kCPn61re+9WBQyizwTIYl3ILLxnzqU5+6GdLGrKmVxlpFb9Bv5xrmNPGuInIdO0AGmTveYl6a23GYGn+mYM+jtvxNhpfVI2rovpRB72GMnNLePSO+8bwXuckJ3HFe8a+nPe1pmyFsbVJe61WAR9JDqkndK885rz0bX+xoAjSb4e4+EUzzLjXe+xrf+pc1tbIEzgdlwmWc5KChQzmSgvHmbEuf51iYDabCgYwN/Jmjoo65vkNHZX7RwXzu/5yHnRYAP+FXjRfDEc+uOVPR5eRd0VByD4+Ivgpc5Oz1veaQNcCpN0IBoFLkc0yVlYP+So83fk7XGcUMaq5YXT6dNCPa//SC6COnyDQYp87ce37iZZumz64Gs2Zw0l4yL1kOes5srHO1COJMR73o888NN9y9tPAFCxYsWLBgwYIFCxYsWHBKg5sFCxYsWLBgwYIFCxYsWPAjA5axuGDBggULFixYsGDBggULroBlLC5YsGDBggULFixYsGDBgitgGYsLFixYsGDBggULFixYsOAKWMbiggULFixYsGDBggULFiy4ApaxuGDBggULFixYsGDBggULroBlLC5YsGDBggULFixYsGDBgitgGYsLFixYsGDBggULFixYsOAKWMbiggULFixYsGDBggULFiy4ApaxuGDBggULFixYsGDBggULroBlLC5YsGDBggULFixYsGDBgitgGYsLFixYsGDBggULFixYsOAKWMbiggULFixYsGDBggULFiy4ApaxuGDBggULFixYsGDBggULroBlLC5YsGDBggULFixYsGDBgitgGYsLFixYsGDBggULFixYsOAKWMbiggULFixYsGDBggULFiy4ApaxuGDBggUL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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "fig = plt.figure(figsize=(12, 8))\n", - "for row in range(num_rows):\n", - " for col in range(num_cols):\n", - " plt.subplot(num_rows, num_cols, row * num_cols + col + 1)\n", - " plt.imshow(res.images[row * num_cols + col], cmap=\"gray\", vmin=0, vmax=255)\n", - " plt.axis(\"off\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### How long does it take to capture an image?" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2089.59 ms ± 15.72 ms\n", - "Overhead: 185.59 ms\n" - ] - } - ], - "source": [ - "import time\n", - "import numpy as np\n", - "\n", - "exposure_time = 1904\n", - "\n", - "# first time setting imaging mode is slower\n", - "_ = await pr.capture(well=(1, 1), mode=ImagingMode.BRIGHTFIELD, focal_height=3.3, exposure_time=exposure_time)\n", - "\n", - "l = []\n", - "for i in range(10):\n", - " t0 = time.monotonic_ns()\n", - " _ = await pr.capture(well=(1, 1), mode=ImagingMode.BRIGHTFIELD, focal_height=3.3, exposure_time=exposure_time)\n", - " t1 = time.monotonic_ns()\n", - " l.append((t1 - t0) / 1e6)\n", - "\n", - "print(f\"{np.mean(l):.2f} ms ± {np.std(l):.2f} ms\")\n", - "print(f\"Overhead: {(np.mean(l) - exposure_time):.2f} ms\")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/user_guide/02_analytical/plate-reading/img/bmg-labtech-clariostar-plus.png b/docs/user_guide/02_analytical/plate-reading/img/bmg-labtech-clariostar-plus.png deleted file mode 100644 index b66172bf41f..00000000000 Binary files a/docs/user_guide/02_analytical/plate-reading/img/bmg-labtech-clariostar-plus.png and /dev/null differ diff --git a/docs/user_guide/02_analytical/plate-reading/pico.ipynb b/docs/user_guide/02_analytical/plate-reading/pico.ipynb deleted file mode 100644 index bd94ad3b032..00000000000 --- a/docs/user_guide/02_analytical/plate-reading/pico.ipynb +++ /dev/null @@ -1,220 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# ImageXpress Pico\n", - "\n", - "The [Molecular Devices ImageXpress Pico](https://www.moleculardevices.com/products/cellular-imaging-systems/high-content-imaging/imagexpress-pico) is an automated cell imaging system. PyLabRobot communicates with it over SiLA 2 / gRPC.\n", - "\n", - "## Requirements\n", - "\n", - "- The Pico must be running the SiLA 2 server (default port 8091).\n", - "- `pip install pylabrobot[sila] numpy Pillow`" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Setup" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.plate_reading import Imager, ImagingMode, Objective\n", - "from pylabrobot.microscopes.molecular_devices.pico.backend import ExperimentalPicoBackend" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Create the backend, specifying which objectives and filter cubes are physically installed on your instrument. The keys are 0-indexed turret / filter-wheel positions." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "backend = ExperimentalPicoBackend(\n", - " host=\"192.168.1.100\",\n", - " objectives={0: Objective.O_4X_PL_FL},\n", - " filter_cubes={0: ImagingMode.DAPI, 1: ImagingMode.BRIGHTFIELD},\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "pico = Imager(name=\"pico\", size_x=0, size_y=0, size_z=0, backend=backend)\n", - "await pico.setup()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Assign a plate\n", - "\n", - "The plate geometry is used to derive the labware parameters sent to the Pico. Any PLR `Plate` definition works." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import CellVis_96_wellplate_350uL_Fb\n", - "\n", - "plate = CellVis_96_wellplate_350uL_Fb(name=\"plate\")\n", - "pico.assign_child_resource(plate)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Loading a plate\n", - "\n", - "Open the plate drawer, place the plate, and close it." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pico.backend.open_door()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Place the plate in the drawer, then close it.\n", - "await pico.backend.close_door()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Capture an image\n", - "\n", - "Supported objectives:\n", - "\n", - "- `O_2_5X_N_PLAN` — N PLAN 2.5x/0.07\n", - "- `O_4X_PL_FL` — PL FLUOTAR 4x/0.13\n", - "- `O_10X_PL_FL` — PL FLUOTAR 10x/0.30\n", - "- `O_20X_PL_FL` — PL FLUOTAR 20x/0.40\n", - "- `O_40X_PL_FL` — PL FLUOTAR 40x/0.60\n", - "\n", - "Supported imaging modes:\n", - "\n", - "- `BRIGHTFIELD`\n", - "- `DAPI`\n", - "- `GFP`\n", - "- `RFP`\n", - "- `TEXAS_RED`\n", - "- `CY5`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "res = await pico.capture(\n", - " well=(0, 0), # A1\n", - " mode=ImagingMode.BRIGHTFIELD,\n", - " objective=Objective.O_4X_PL_FL,\n", - " exposure_time=10.0, # ms\n", - " focal_height=1.0, # mm\n", - " gain=0,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "\n", - "plt.imshow(res.images[0], cmap=\"gray\")\n", - "plt.title(f\"Exposure: {res.exposure_time:.1f} ms\")\n", - "plt.axis(\"off\")\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "You can also pass a `Well` object directly:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "res = await pico.capture(\n", - " well=plate.get_well(\"B3\"),\n", - " mode=ImagingMode.DAPI,\n", - " objective=Objective.O_4X_PL_FL,\n", - " exposure_time=15.0,\n", - " focal_height=1.0,\n", - " gain=0,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Cleanup" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pico.stop()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.10.0" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb b/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb deleted file mode 100644 index 8e8972884d7..00000000000 --- a/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb +++ /dev/null @@ -1,421 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "39d0c1a5", - "metadata": {}, - "source": "# Plate reading\n\nPyLabRobot supports the following plate readers:\n\n```{toctree}\n:maxdepth: 1\n\nbmg-clariostar\nbyonoy/absorbance\nbyonoy/luminescence\nbyonoy\ncytation\npico\nspectramax-gemini-em\nsynergyh1\ntecan-spark\ntecan-infinite\n```\n\nThis example uses the `PlateReaderChatterboxBackend`. When using a real machine, use the corresponding backend." - }, - { - "cell_type": "code", - "execution_count": null, - "id": "33571473", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Setting up the plate reader.\n" - ] - } - ], - "source": [ - "from pylabrobot.plate_reading import PlateReader, PlateReaderChatterboxBackend\n", - "pr = PlateReader(name=\"plate reader\", backend=PlateReaderChatterboxBackend(), size_x=0, size_y=0, size_z=0)\n", - "await pr.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "f8e17a8f", - "metadata": {}, - "source": [ - "## Basic plate reading" - ] - }, - { - "cell_type": "markdown", - "id": "8f026de0", - "metadata": {}, - "source": [ - "First you need to assign a plate to the plate reader." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "55a22956", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", - "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", - "pr.assign_child_resource(plate)" - ] - }, - { - "cell_type": "markdown", - "id": "b504f303", - "metadata": {}, - "source": [ - "### Luminescence\n", - "\n", - "![Cytation{1,5} supported](https://img.shields.io/badge/Cytation-supported-blue)\n", - "![ClarioSTAR supported](https://img.shields.io/badge/ClarioSTAR-supported-blue)\n", - "![Synergy H1 supported](https://img.shields.io/badge/SynergyH1-supported-blue)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f1b0e9b9", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Reading luminescence at focal height 4.5.\n", - "Read the following wells:\n", - " | 1| 2| 3| 4| 5| 6| 7| 8| 9| 10| 11| 12|\n", - "---------------------------------------------------------------------------------------------------\n", - "A | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "B | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "C | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "D | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "E | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "F | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "G | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "H | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n" - ] - } - ], - "source": [ - "data = await pr.read_luminescence(focal_height=4.5)" - ] - }, - { - "cell_type": "markdown", - "id": "8a756e56", - "metadata": {}, - "source": [ - "Data is a 2d array of optional floats." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "68dff25c", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]]" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "data" - ] - }, - { - "cell_type": "markdown", - "id": "5d7bc15a", - "metadata": {}, - "source": [ - "### Absorbance\n", - "\n", - "![Cytation{1,5} supported](https://img.shields.io/badge/Cytation-supported-blue)\n", - "![ClarioSTAR supported](https://img.shields.io/badge/ClarioSTAR-supported-blue)\n", - "![Synergy H1 supported](https://img.shields.io/badge/SynergyH1-supported-blue)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ee711c57", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Reading absorbance at wavelength 450.\n", - "Read the following wells:\n", - " | 1| 2| 3| 4| 5| 6| 7| 8| 9| 10| 11| 12|\n", - "---------------------------------------------------------------------------------------------------\n", - "A | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "B | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "C | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "D | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "E | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "F | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "G | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "H | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n" - ] - }, - { - "data": { - "text/plain": [ - "[[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]]" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await pr.read_absorbance(wavelength=450)" - ] - }, - { - "cell_type": "markdown", - "id": "b3f93810", - "metadata": {}, - "source": [ - "Data is a 2d array of optional floats." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "74359d8a", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]]" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "data" - ] - }, - { - "cell_type": "markdown", - "id": "74fdf54d", - "metadata": {}, - "source": [ - "### Fluorescence\n", - "\n", - "![Cytation{1,5} supported](https://img.shields.io/badge/Cytation-supported-blue)\n", - "![ClarioSTAR not support](https://img.shields.io/badge/ClarioSTAR-not%20supported-red)\n", - "![Synergy H1 supported](https://img.shields.io/badge/SynergyH1-supported-blue)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f9cdb44b", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Reading fluorescence at excitation wavelength 485, emission wavelength 520, and focal height 4.5.\n", - "Read the following wells:\n", - " | 1| 2| 3| 4| 5| 6| 7| 8| 9| 10| 11| 12|\n", - "---------------------------------------------------------------------------------------------------\n", - "A | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "B | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "C | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "D | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "E | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "F | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "G | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n", - "H | 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000| 0.000|\n" - ] - }, - { - "data": { - "text/plain": [ - "[[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]]" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await pr.read_fluorescence(excitation_wavelength=485, emission_wavelength=520, focal_height=4.5)" - ] - }, - { - "cell_type": "markdown", - "id": "87d0a591", - "metadata": {}, - "source": [ - "Data is a 2d array of optional floats." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "da310f3e", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]]" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "data" - ] - }, - { - "cell_type": "markdown", - "id": "dc936154", - "metadata": {}, - "source": [ - "## Reading parts of plates\n", - "\n", - "![Cytation{1,5} supported](https://img.shields.io/badge/Cytation-supported-blue)\n", - "![ClarioSTAR not support](https://img.shields.io/badge/ClarioSTAR-not%20supported-red)\n", - "![Synergy H1 supported](https://img.shields.io/badge/SynergyH1-supported-blue)" - ] - }, - { - "cell_type": "markdown", - "id": "081173b2", - "metadata": {}, - "source": [ - "The example below shows luminescence, but the API is the same for luminescence, absorbance and fluorescence." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "716887a6", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Reading luminescence at focal height 4.5.\n", - "Read the following wells:\n", - " | 1| 2| 3| 4| 5| 6| 7| 8| 9| 10| 11| 12|\n", - "---------------------------------------------------------------------------------------------------\n", - "A | 0.000| 0.000| 0.000| 0.000| 0.000| | | | | | | |\n", - "B | 0.000| 0.000| 0.000| 0.000| 0.000| | | | | | | |\n", - "C | 0.000| 0.000| 0.000| 0.000| 0.000| | | | | | | |\n", - "D | | | | | | | | | | | | |\n", - "E | | | | | | | | | | | | |\n", - "F | | | | | | | | | | | | |\n", - "G | | | | | | | | | | | | |\n", - "H | | | | | | | | | | | | |\n" - ] - } - ], - "source": [ - "data = await pr.read_luminescence(focal_height=4.5, wells=plate[\"A1:C5\"])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e9d21c1e", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[[0.0, 0.0, 0.0, 0.0, 0.0, None, None, None, None, None, None, None],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, None, None, None, None, None, None, None],\n", - " [0.0, 0.0, 0.0, 0.0, 0.0, None, None, None, None, None, None, None],\n", - " [None, None, None, None, None, None, None, None, None, None, None, None],\n", - " [None, None, None, None, None, None, None, None, None, None, None, None],\n", - " [None, None, None, None, None, None, None, None, None, None, None, None],\n", - " [None, None, None, None, None, None, None, None, None, None, None, None],\n", - " [None, None, None, None, None, None, None, None, None, None, None, None]]" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "data" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.23" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/02_analytical/plate-reading/spectramax-gemini-em.ipynb b/docs/user_guide/02_analytical/plate-reading/spectramax-gemini-em.ipynb deleted file mode 100644 index 06c5e66056b..00000000000 --- a/docs/user_guide/02_analytical/plate-reading/spectramax-gemini-em.ipynb +++ /dev/null @@ -1,803 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "ed97a2cc", - "metadata": {}, - "source": [ - "# SpectraMax Gemini EM\n", - "\n", - "The Molecular Devices SpectraMax Gemini EM is a fluorescence plate reader controlled over a\n", - "serial RS-232 interface. PyLabRobot supports this reader with the\n", - "{class}`~pylabrobot.plate_reading.MolecularDevicesSpectraMaxGeminiEMBackend`.\n", - "\n", - "## Installation\n", - "\n", - "```bash\n", - "pip install pylabrobot[serial]\n", - "```\n", - "\n", - "On Linux, the USB-to-RS-232 adapter will typically appear as `/dev/ttyUSB0` or `/dev/ttyUSB1`.\n", - "On Windows, it appears as a `COM` port." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "6742e81a", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-05-17 13:10:52,216 - pylabrobot.io.serial - INFO - Using explicitly provided port: /dev/ttyUSB0 (for VID=None, PID=None)\n" - ] - } - ], - "source": [ - "from pylabrobot.plate_reading import PlateReader, MolecularDevicesSpectraMaxGeminiEMBackend\n", - "\n", - "backend = MolecularDevicesSpectraMaxGeminiEMBackend(port=\"/dev/ttyUSB0\")\n", - "pr = PlateReader(name=\"gemini\", backend=backend, size_x=0, size_y=0, size_z=0)\n", - "\n", - "await pr.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "2d64c8b7-a946-4178-a1be-e0c97dea5cc4", - "metadata": {}, - "source": [ - "### Open Tray" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "429c5df1", - "metadata": {}, - "outputs": [], - "source": [ - "await pr.open()" - ] - }, - { - "cell_type": "markdown", - "id": "e71af884-ac5d-477f-ba4d-bbf7151b2ca6", - "metadata": {}, - "source": [ - "### Close Tray" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "72250570", - "metadata": {}, - "outputs": [], - "source": [ - "await pr.close()" - ] - }, - { - "cell_type": "markdown", - "id": "90e68eea", - "metadata": {}, - "source": [ - "\n", - "## Validated behavior\n", - "\n", - "The Gemini EM backend was validated against a Molecular Devices SpectraMax Gemini EM using\n", - "a USB-to-RS-232 adapter. The instrument identified itself as:\n", - "\n", - "```text\n", - "GEMINI EM\n", - "2.00b78 01Mar04\n", - "```\n", - "\n", - "The following operations were validated:\n", - "\n", - "- opening and closing the drawer\n", - "- querying reader status\n", - "- querying and setting temperature\n", - "- endpoint fluorescence reads on a 96-well plate\n", - "- endpoint fluorescence reads on a 384-well plate\n", - "- endpoint luminescence reads on a 96-well plate\n", - "- endpoint time-resolved fluorescence reads on a 96-well plate\n", - "- kinetic time-resolved fluorescence reads on a 96-well plate\n", - "- emission and excitation fluorescence spectrum reads on a 96-well plate\n", - "- fluorescence fill wellscan reads on a 96-well plate\n", - "- top and bottom fluorescence read-stage selection\n", - "- configurable excitation and emission wavelengths\n", - "\n", - "The Windows/SoftMax Pro setup used during development was for protocol discovery only. The intended\n", - "deployment path is direct serial control from Linux using the USB-to-RS-232 adapter, typically as\n", - "`/dev/ttyUSB0` or `/dev/ttyUSB1`.\n", - "\n", - "Absorbance and fluorescence polarization are not implemented for this backend.\n", - "\n", - "## Support status\n", - "\n", - "| Mode | Backend status | Hardware status |\n", - "| --- | --- | --- |\n", - "| Drawer open/close | implemented | validated |\n", - "| Status and temperature query | implemented | validated |\n", - "| Temperature setpoint | implemented | validated |\n", - "| Fluorescence endpoint | implemented | validated, 96-well and 384-well |\n", - "| Fluorescence top/bottom read | implemented | validated |\n", - "| Luminescence endpoint | implemented | validated, top read |\n", - "| Time-resolved fluorescence endpoint | implemented | validated |\n", - "| Time-resolved fluorescence kinetic | implemented | validated with a short run |\n", - "| Fluorescence emission spectrum | implemented | validated with a short sweep |\n", - "| Fluorescence excitation spectrum | implemented | validated with a short sweep |\n", - "| Fluorescence wellscan | implemented | validated with fill pattern |\n", - "| Rectangular partial-region reads | implemented | unit tested |\n", - "| Arbitrary non-rectangular partial reads | not implemented | not validated |\n", - "| Absorbance | not supported by Gemini EM | not applicable |\n", - "| Fluorescence polarization | not implemented | not validated |\n", - "\n", - "## Fluorescence reads\n", - "\n", - "Assign a plate to the reader before reading. The backend uses the plate resource geometry to\n", - "configure the reader's plate position." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "e20d3b47", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", - "\n", - "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", - "pr.assign_child_resource(plate)\n", - "\n", - "data = await backend.read_fluorescence(\n", - " plate=plate,\n", - " wells=plate.get_all_items(),\n", - " excitation_wavelength=485,\n", - " emission_wavelength=520,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "ab6f037d", - "metadata": {}, - "source": [ - "\n", - "For a TRITC-like fluorescence read:" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "8bbc6209", - "metadata": {}, - "outputs": [], - "source": [ - "data = await backend.read_fluorescence(\n", - " plate=plate,\n", - " wells=plate.get_all_items(),\n", - " excitation_wavelength=557,\n", - " emission_wavelength=576,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "422cfe65", - "metadata": {}, - "source": [ - "\n", - "For a bottom read, pass `read_from_bottom=True`:" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "d947905e", - "metadata": {}, - "outputs": [], - "source": [ - "data = await backend.read_fluorescence(\n", - " plate=plate,\n", - " wells=plate.get_all_items(),\n", - " excitation_wavelength=485,\n", - " emission_wavelength=520,\n", - " read_from_bottom=True,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "ec5d69d5", - "metadata": {}, - "source": [ - "\n", - "Full-plate reads and rectangular contiguous well regions are supported. Arbitrary non-rectangular\n", - "well selections raise `NotImplementedError`.\n", - "\n", - "## Luminescence reads\n", - "\n", - "Endpoint luminescence is available through the Gemini EM backend. The implementation follows the\n", - "SoftMax Pro command sequence captured during development, including `!READTYPE LUM`,\n", - "`!EMWAVELENGTH 0`, `!TOPREADCLEAR OFF`, and `!READSTAGE TOP`." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "69a2f9ca", - "metadata": {}, - "outputs": [], - "source": [ - "data = await backend.read_luminescence(\n", - " plate=plate,\n", - " wells=plate.get_all_items(),\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "fed7b43e", - "metadata": {}, - "source": [ - "\n", - "Optional pre-read shaking is supported:" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "2a5ce1f0", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.plate_reading.molecular_devices import ShakeSettings\n", - "\n", - "data = await backend.read_luminescence(\n", - " plate=plate,\n", - " wells=plate.get_all_items(),\n", - " shake_settings=ShakeSettings(before_read=True, before_read_duration=10),\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "c4c6ee47", - "metadata": {}, - "source": [ - "\n", - "Only endpoint, top-read luminescence is currently implemented.\n", - "\n", - "## Time-Resolved Fluorescence\n", - "\n", - "Endpoint time-resolved fluorescence is available through the Gemini EM backend. The implementation\n", - "uses the Gemini EM command form observed from SoftMax Pro:\n", - "\n", - "```text\n", - "!READTYPE TIME \n", - "```\n", - "\n", - "For example:" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "283533e0", - "metadata": {}, - "outputs": [], - "source": [ - "data = await backend.experimental_read_time_resolved_fluorescence(\n", - " plate=plate,\n", - " excitation_wavelengths=[485],\n", - " emission_wavelengths=[525],\n", - " cutoff_filters=[7],\n", - " delay_time=50,\n", - " integration_time=850,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "eb1efe84", - "metadata": {}, - "source": [ - "\n", - "For a bottom TRF read:" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "c0fa5761", - "metadata": {}, - "outputs": [], - "source": [ - "data = await backend.experimental_read_time_resolved_fluorescence(\n", - " plate=plate,\n", - " excitation_wavelengths=[485],\n", - " emission_wavelengths=[525],\n", - " cutoff_filters=[7],\n", - " delay_time=50,\n", - " integration_time=850,\n", - " read_from_bottom=True,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "e694a570", - "metadata": {}, - "source": [ - "\n", - "Kinetic TRF is also supported with `ReadType.KINETIC` and `KineticSettings`:" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "05c7613a", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.plate_reading.molecular_devices import KineticSettings, ReadType\n", - "\n", - "data = await backend.experimental_read_time_resolved_fluorescence(\n", - " plate=plate,\n", - " excitation_wavelengths=[485],\n", - " emission_wavelengths=[525],\n", - " cutoff_filters=[7],\n", - " delay_time=50,\n", - " integration_time=850,\n", - " read_type=ReadType.KINETIC,\n", - " kinetic_settings=KineticSettings(interval=30, num_readings=21),\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "fee71830", - "metadata": {}, - "source": [ - "\n", - "Endpoint and kinetic TRF are currently implemented for full plates and rectangular contiguous well\n", - "regions. Arbitrary non-rectangular well selections are not supported.\n", - "\n", - "## Fluorescence Spectra\n", - "\n", - "Gemini-specific helpers are available for fluorescence spectra. They use `SpectrumSettings`\n", - "internally but expose fixed-excitation and fixed-emission sweeps directly.\n", - "\n", - "For an emission spectrum with fixed excitation:" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "bb22c2f5", - "metadata": {}, - "outputs": [ - { - "ename": "CancelledError", - "evalue": "", - "output_type": "error", - "traceback": [ - "\u001b[31m---------------------------------------------------------------------------\u001b[39m", - "\u001b[31mCancelledError\u001b[39m Traceback (most recent call last)", - "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[10]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m data = await backend.experimental_read_fluorescence_emission_spectrum(\n\u001b[32m 2\u001b[39m plate=plate,\n\u001b[32m 3\u001b[39m wells=plate.get_all_items(),\n\u001b[32m 4\u001b[39m excitation_wavelength=\u001b[32m350\u001b[39m,\n", - "\u001b[36mFile \u001b[39m\u001b[32m~/git/pylabrobot/pylabrobot/plate_reading/molecular_devices/spectramax_gemini_em_backend.py:406\u001b[39m, in \u001b[36mMolecularDevicesSpectraMaxGeminiEMBackend.experimental_read_fluorescence_emission_spectrum\u001b[39m\u001b[34m(self, plate, wells, excitation_wavelength, start_emission_wavelength, step, num_steps, cutoff_filters, read_order, calibrate, shake_settings, carriage_speed, read_from_bottom, pmt_gain, flashes_per_well, timeout)\u001b[39m\n\u001b[32m 403\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m._set_order(settings)\n\u001b[32m 405\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m._read_now()\n\u001b[32m--> \u001b[39m\u001b[32m406\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m._wait_for_idle(timeout=timeout)\n\u001b[32m 407\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m._transfer_data(settings)\n", - "\u001b[36mFile \u001b[39m\u001b[32m~/git/pylabrobot/pylabrobot/plate_reading/molecular_devices/backend.py:689\u001b[39m, in \u001b[36mMolecularDevicesBackend._wait_for_idle\u001b[39m\u001b[34m(self, timeout)\u001b[39m\n\u001b[32m 687\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m time.time() - start_time > timeout:\n\u001b[32m 688\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mTimeoutError\u001b[39;00m(\u001b[33m\"\u001b[39m\u001b[33mTimeout waiting for plate reader to become idle.\u001b[39m\u001b[33m\"\u001b[39m)\n\u001b[32m--> \u001b[39m\u001b[32m689\u001b[39m status = \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m.get_status()\n\u001b[32m 690\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m status \u001b[38;5;129;01mand\u001b[39;00m status[\u001b[32m1\u001b[39m] == \u001b[33m\"\u001b[39m\u001b[33mIDLE\u001b[39m\u001b[33m\"\u001b[39m:\n\u001b[32m 691\u001b[39m \u001b[38;5;28;01mbreak\u001b[39;00m\n", - "\u001b[36mFile \u001b[39m\u001b[32m~/git/pylabrobot/pylabrobot/plate_reading/molecular_devices/backend.py:333\u001b[39m, in \u001b[36mMolecularDevicesBackend.get_status\u001b[39m\u001b[34m(self)\u001b[39m\n\u001b[32m 332\u001b[39m \u001b[38;5;28;01masync\u001b[39;00m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mget_status\u001b[39m(\u001b[38;5;28mself\u001b[39m) -> List[\u001b[38;5;28mstr\u001b[39m]:\n\u001b[32m--> \u001b[39m\u001b[32m333\u001b[39m res = \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m.send_command(\u001b[33m\"\u001b[39m\u001b[33m!STATUS\u001b[39m\u001b[33m\"\u001b[39m)\n\u001b[32m 334\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mlen\u001b[39m(res) > \u001b[32m1\u001b[39m:\n\u001b[32m 335\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m res[\u001b[32m1\u001b[39m].split()\n", - "\u001b[36mFile \u001b[39m\u001b[32m~/git/pylabrobot/pylabrobot/plate_reading/molecular_devices/backend.py:287\u001b[39m, in \u001b[36mMolecularDevicesBackend.send_command\u001b[39m\u001b[34m(self, command, timeout, num_res_fields)\u001b[39m\n\u001b[32m 285\u001b[39m timeout_time = time.time() + timeout\n\u001b[32m 286\u001b[39m \u001b[38;5;28;01mwhile\u001b[39;00m \u001b[38;5;28;01mTrue\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m287\u001b[39m raw_response += \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m.io.readline()\n\u001b[32m 288\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m asyncio.sleep(\u001b[32m0.001\u001b[39m)\n\u001b[32m 289\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m time.time() > timeout_time:\n", - "\u001b[36mFile \u001b[39m\u001b[32m~/git/pylabrobot/pylabrobot/io/serial.py:244\u001b[39m, in \u001b[36mSerial.readline\u001b[39m\u001b[34m(self)\u001b[39m\n\u001b[32m 241\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mself\u001b[39m._executor \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;129;01mor\u001b[39;00m \u001b[38;5;28mself\u001b[39m._ser \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[32m 242\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mRuntimeError\u001b[39;00m(\u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mCall setup() first for device \u001b[39m\u001b[33m'\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[38;5;28mself\u001b[39m._human_readable_device_name\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m'\u001b[39m\u001b[33m.\u001b[39m\u001b[33m\"\u001b[39m)\n\u001b[32m--> \u001b[39m\u001b[32m244\u001b[39m data = \u001b[38;5;28;01mawait\u001b[39;00m loop.run_in_executor(\u001b[38;5;28mself\u001b[39m._executor, \u001b[38;5;28mself\u001b[39m._ser.readline)\n\u001b[32m 246\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mlen\u001b[39m(data) != \u001b[32m0\u001b[39m:\n\u001b[32m 247\u001b[39m logger.log(LOG_LEVEL_IO, \u001b[33m\"\u001b[39m\u001b[33m[\u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[33m] readline \u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[33m\"\u001b[39m, \u001b[38;5;28mself\u001b[39m._port, data)\n", - "\u001b[31mCancelledError\u001b[39m: " - ] - } - ], - "source": [ - "data = await backend.experimental_read_fluorescence_emission_spectrum(\n", - " plate=plate,\n", - " wells=plate.get_all_items(),\n", - " excitation_wavelength=350,\n", - " start_emission_wavelength=400,\n", - " step=10,\n", - " num_steps=36,\n", - " read_from_bottom=True,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "eb84d2e5", - "metadata": {}, - "source": [ - "\n", - "This sends the Gemini EM spectrum mode:\n", - "\n", - "```text\n", - "!EXWAVELENGTH 350\n", - "!MODE EMSPECTRUM 400 10 36\n", - "!ORDER WAVELENGTH\n", - "```\n", - "\n", - "For an excitation spectrum with fixed emission:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1dc20c10", - "metadata": {}, - "outputs": [], - "source": [ - "data = await backend.experimental_read_fluorescence_excitation_spectrum(\n", - " plate=plate,\n", - " wells=plate.get_all_items(),\n", - " emission_wavelength=600,\n", - " start_excitation_wavelength=350,\n", - " step=20,\n", - " num_steps=4,\n", - " read_from_bottom=True,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "2e68b915", - "metadata": {}, - "source": [ - "\n", - "This sends:\n", - "\n", - "```text\n", - "!EMWAVELENGTH 600\n", - "!AUTOFILTER EX OFF\n", - "!MODE EXSPECTRUM 350 20 4\n", - "!ORDER WAVELENGTH\n", - "```\n", - "\n", - "The spectrum helpers default to cutoff filter `1`, matching the captured SoftMax Pro spectrum\n", - "protocols. Pass `cutoff_filters=[...]` to override this.\n", - "\n", - "## Wellscan fluorescence\n", - "\n", - "The Gemini EM backend includes a Gemini-specific `experimental_read_fluorescence_wellscan` method. SoftMax\n", - "Pro implements wellscan by enabling wellscan mode and performing separate reads at shifted plate\n", - "origins. The backend mirrors that behavior instead of averaging scan points silently." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "be68e7a3", - "metadata": {}, - "outputs": [], - "source": [ - "data = await backend.experimental_read_fluorescence_wellscan(\n", - " plate=plate,\n", - " wells=plate.get_all_items(),\n", - " excitation_wavelength=485,\n", - " emission_wavelength=525,\n", - " pattern=\"fill\",\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "c5c8bbeb", - "metadata": {}, - "source": [ - "\n", - "Supported patterns are:\n", - "\n", - "```text\n", - "horizontal\n", - "vertical\n", - "cross\n", - "fill\n", - "```\n", - "\n", - "Each returned read includes `wellscan_point`, `wellscan_x`, and `wellscan_y` fields.\n", - "\n", - "## 384-well plates\n", - "\n", - "384-well fluorescence reads were validated with `Greiner_384_wellplate_28ul_Fb`:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f6894ce7", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import Greiner_384_wellplate_28ul_Fb\n", - "\n", - "plate = Greiner_384_wellplate_28ul_Fb(name=\"plate\")\n", - "pr.assign_child_resource(plate)\n", - "\n", - "data = await backend.read_fluorescence(\n", - " plate=plate,\n", - " wells=plate.get_all_items(),\n", - " excitation_wavelength=557,\n", - " emission_wavelength=576,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "982a6bdc", - "metadata": {}, - "source": [ - "\n", - "## OEM command observations\n", - "\n", - "SoftMax Pro was used during development to observe the OEM serial behavior. The observed\n", - "bottom-read setup sequence was:\n", - "\n", - "```text\n", - "!OPTION\n", - "!TEMP\n", - "!CLEAR DATA\n", - "!TAG OFF\n", - "!WELLSCANMODE\n", - "!XPOS 14.380 9 12\n", - "!YPOS 11.235 9 8\n", - "!SHAKE OFF\n", - "!SHAKE 0 0 0 0 0\n", - "!STRIP 1 12\n", - "!READTYPE FLU\n", - "!EMWAVELENGTH 525\n", - "!AUTOFILTER OFF\n", - "!EMFILTER 7\n", - "!EXWAVELENGTH 490\n", - "!FPW 6\n", - "!TOPREADCLEAR ON\n", - "!AUTOPMT ON\n", - "!CSPEED 8\n", - "!PMTCAL ON\n", - "!MODE ENDPOINT\n", - "!ORDER COLUMN\n", - "!READSTAGE BOT\n", - "!READ\n", - "```\n", - "\n", - "The Gemini EM responds to `!READSTAGE BOT` and `!READSTAGE TOP` with a single `OK` response\n", - "field. This differs from some other Molecular Devices readers and is handled by the Gemini EM\n", - "backend.\n", - "\n", - "SoftMax Pro sent `!TOPREADCLEAR ON` before selecting either read stage. The Gemini EM backend\n", - "does the same before sending `!READSTAGE TOP` or `!READSTAGE BOT`.\n", - "\n", - "### Luminescence\n", - "\n", - "SoftMax Pro used this sequence for a luminescence endpoint read with a 10 second pre-read shake:\n", - "\n", - "```text\n", - "!CLEAR DATA\n", - "!TAG OFF\n", - "!WELLSCANMODE\n", - "!XPOS 14.380 9 12\n", - "!YPOS 11.235 9 8\n", - "!SHAKE ON\n", - "!SHAKE 10 0 0 0 0\n", - "!STRIP 1 12\n", - "!READTYPE LUM\n", - "!EMWAVELENGTH 0\n", - "!FPW 6\n", - "!TOPREADCLEAR OFF\n", - "!AUTOPMT ON\n", - "!CSPEED 8\n", - "!PMTCAL ON\n", - "!MODE ENDPOINT\n", - "!ORDER COLUMN\n", - "!READSTAGE TOP\n", - "!READ\n", - "```\n", - "\n", - "### Time-resolved fluorescence\n", - "\n", - "SoftMax Pro used this sequence for a TRF endpoint read:\n", - "\n", - "```text\n", - "!TAG OFF\n", - "!WELLSCANMODE\n", - "!XPOS 14.380 9 12\n", - "!YPOS 11.235 9 8\n", - "!SHAKE ON\n", - "!SHAKE 10 0 0 0 0\n", - "!STRIP 1 12\n", - "!READTYPE TIME 50 850\n", - "!EMWAVELENGTH 525\n", - "!AUTOFILTER OFF\n", - "!EMFILTER 7\n", - "!EXWAVELENGTH 485\n", - "!FPW 6\n", - "!TOPREADCLEAR ON\n", - "!AUTOPMT ON\n", - "!CSPEED 8\n", - "!PMTCAL ON\n", - "!MODE ENDPOINT\n", - "!ORDER COLUMN\n", - "!READSTAGE BOT\n", - "!READ\n", - "```\n", - "\n", - "For a TRF kinetic assay, SoftMax Pro used:\n", - "\n", - "```text\n", - "!SHAKE ON\n", - "!SHAKE 5 30 27 3 0\n", - "!READTYPE TIME 50 850\n", - "!AUTOPMT OFF\n", - "!PMT MED\n", - "!MODE KINETIC 30 21\n", - "!READSTAGE TOP\n", - "!READ\n", - "```\n", - "\n", - "During the kinetic run, SoftMax Pro repeatedly polled `!QUEUE` and called `!TRANSFER`.\n", - "\n", - "### Fluorescence spectra\n", - "\n", - "For an emission spectrum with fixed excitation, SoftMax Pro sent:\n", - "\n", - "```text\n", - "!EXWAVELENGTH 350\n", - "!AUTOFILTER OFF\n", - "!EMFILTER 1\n", - "!MODE EMSPECTRUM 400 10 36\n", - "!ORDER WAVELENGTH\n", - "!READSTAGE BOT\n", - "!READ\n", - "```\n", - "\n", - "For an excitation spectrum with fixed emission, SoftMax Pro sent:\n", - "\n", - "```text\n", - "!EMWAVELENGTH 600\n", - "!AUTOFILTER OFF\n", - "!EMFILTER 1\n", - "!AUTOFILTER EX OFF\n", - "!MODE EXSPECTRUM 350 20 4\n", - "!ORDER WAVELENGTH\n", - "!READSTAGE BOT\n", - "!READ\n", - "```\n", - "\n", - "### Partial-region selection\n", - "\n", - "SoftMax Pro changed the selected wells by changing plate geometry and strip selection. One\n", - "captured partial-region read used:\n", - "\n", - "```text\n", - "!XPOS 14.380 9 12\n", - "!YPOS 20.235 9 6\n", - "!STRIP 2 6\n", - "```\n", - "\n", - "The Gemini EM backend maps rectangular contiguous well subsets to this command model. The selected\n", - "rows change the `!YPOS` origin and row count, while the selected columns change `!STRIP`. For\n", - "example, wells `B2:G7` on a 96-well plate map to row offset 1, six rows, strip 2, and six columns.\n", - "\n", - "Arbitrary non-rectangular well selections are not supported. Wellscan uses explicit scan geometry\n", - "and does not yet infer scan geometry from `wells`.\n", - "\n", - "### Wellscan patterns\n", - "\n", - "SoftMax Pro enabled wellscan with:\n", - "\n", - "```text\n", - "!WELLSCANMODE\n", - "!WELLSCANMODE ON\n", - "```\n", - "\n", - "It then performed one read per scan point, changing `!XPOS` and `!YPOS` between reads. The\n", - "captured 3-point and 9-point scans used this coordinate model:\n", - "\n", - "```text\n", - "center X = 14.380\n", - "center Y = 20.235\n", - "offset = 1.133 mm\n", - "\n", - "X positions = 13.247, 14.380, 15.513\n", - "Y positions = 19.102, 20.235, 21.368\n", - "```\n", - "\n", - "The horizontal pattern order was:\n", - "\n", - "```text\n", - "13.247, 20.235\n", - "14.380, 20.235\n", - "15.513, 20.235\n", - "```\n", - "\n", - "The vertical pattern order was:\n", - "\n", - "```text\n", - "14.380, 19.102\n", - "14.380, 20.235\n", - "14.380, 21.368\n", - "```\n", - "\n", - "The cross pattern order was:\n", - "\n", - "```text\n", - "14.380, 19.102\n", - "13.247, 20.235\n", - "14.380, 20.235\n", - "15.513, 20.235\n", - "14.380, 21.368\n", - "```\n", - "\n", - "The fill pattern was a 3 by 3 row-major raster:\n", - "\n", - "```text\n", - "13.247, 19.102\n", - "14.380, 19.102\n", - "15.513, 19.102\n", - "13.247, 20.235\n", - "14.380, 20.235\n", - "15.513, 20.235\n", - "13.247, 21.368\n", - "14.380, 21.368\n", - "15.513, 21.368\n", - "```\n", - "\n", - "After the first scan point, SoftMax Pro did not resend the full optical setup. It resent position,\n", - "shake state, `!PMTCAL OFF`, `!STRIP`, and then `!READ`.\n", - "\n", - "## Development notes\n", - "\n", - "The backend intentionally lives in its own module,\n", - "`pylabrobot/plate_reading/molecular_devices/spectramax_gemini_em_backend.py`. It uses the shared\n", - "Molecular Devices serial protocol implementation where possible, while keeping Gemini-specific\n", - "behavior isolated from the SpectraMax M5 and SpectraMax 384 Plus backends.\n", - "\n", - "During protocol discovery, a serial bridge/logger can be placed between SoftMax Pro and the\n", - "reader:\n", - "\n", - "```text\n", - "SoftMax Pro -> virtual COM port -> Python bridge/logger -> physical serial port -> Gemini EM\n", - "```\n", - "\n", - "This allows OEM traffic to be captured without changing the production backend." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6ce6410d-a43f-440d-a9a5-4c40f89a44d9", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.15" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/02_analytical/plate-reading/synergyh1.ipynb b/docs/user_guide/02_analytical/plate-reading/synergyh1.ipynb deleted file mode 100644 index 8821545e00f..00000000000 --- a/docs/user_guide/02_analytical/plate-reading/synergyh1.ipynb +++ /dev/null @@ -1,285 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": "# Synergy H1\n\n## Installation\n\n```bash\npip install pylabrobot[ftdi]\n```\n\nSynergy H1 is an Agilent BioTek microplate reader that can read absorbance, fluorescence, and luminescence. Please refer to the [user guide](https://cqls.oregonstate.edu/sites/cqls.oregonstate.edu/files/synergy_h1_user_manual_sd-xb000426.pdf) for installation instructions." - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "from pylabrobot.plate_reading import PlateReader\n", - "from pylabrobot.plate_reading import SynergyH1Backend" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "pr = PlateReader(name=\"PR\", size_x=0,size_y=0,size_z=0, backend=SynergyH1Backend())\n", - "await pr.setup()" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'1320200 Version 2.07'" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await pr.backend.get_firmware_version()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.open()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Before closing, assign a plate to the plate reader. This determines the spacing of the loading tray in the machine, as well as the positioning of wells where spectrophotometric measurements and pictures will be taken." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import CellVis_24_wellplate_3600uL_Fb\n", - "plate = CellVis_24_wellplate_3600uL_Fb(name=\"plate\")\n", - "pr.assign_child_resource(plate)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.close()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Plate reading\n", - "\n", - "Note: these measurements were taken with a 96 well plate." - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "data = await pr.read_absorbance(wavelength=434)\n", - "plt.imshow(data)" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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4YRobm6OCRMmxHnnnRdPP/103rMKb/ny5VEqlWLJkiV5TymkF154IT75yU/GuHHjYsyYMXH00UfH448/nvesQunr64tLL700pk2bFmPGjIkjjzwyvvWtbw36Xg9EPPzww3HuuedGW1tblEqluPvuuwf8eZZlcdlll8XkyZNjzJgxMX/+/Hj22WfzGVtnCh0Wd911VyxdujQuv/zyeOKJJ+KYY46Js846K7Zt25b3tEJZs2ZNdHR0xNq1a+OBBx6IXbt2xZlnnhm9vb15TyusdevWxc033xyzZ8/Oe0ohvfzyyzFv3rw44IAD4t57740//vGP8d3vfjcOOeSQvKcVylVXXRUrVqyIG264If70pz/FVVddFVdffXVcf/31eU+re729vXHMMcfEjTfe+KZ/fvXVV8d1110XN910Uzz66KNx0EEHxVlnnRWvv17n7yS2L2QFduKJJ2YdHR395/v6+rK2trass7Mzx1XFt23btiwisjVr1uQ9pZC2b9+eHXXUUdkDDzyQfeADH8guvvjivCcVzle+8pXslFNOyXtG4Z199tnZhRdeOOCyj3zkI9nChQtzWlRMEZGtWrWq/3y1Ws0mTZqUfec73+m/7JVXXsnK5XL24x//OIeF9aWwz1js3Lkz1q9fH/Pnz++/rKGhIebPnx+PPPJIjsuKr7u7OyIixo4dm/OSYuro6Iizzz57wOcmtbnnnntizpw5cf7558eECRPi2GOPjVtvvTXvWYVz8sknx+rVq+OZZ56JiIjf//738Zvf/CYWLFiQ87Ji27RpU2zZsmXA13hra2vMnTvX95/I4U3IUnnppZeir68vJk6cOODyiRMnxp///OecVhVftVqNJUuWxLx582LWrFl5zymcO++8M5544olYt25d3lMK7bnnnosVK1bE0qVL42tf+1qsW7cuLrroohg1alQsWrQo73mF8dWvfjV6enpi+vTp0djYGH19fXHFFVfEwoUL855WaFu2bImIeNPvP//6s7ezwoYFI6OjoyOeeuqp+M1vfpP3lMLp6uqKiy++OB544IEYPXp03nMKrVqtxpw5c+LKK6+MiIhjjz02nnrqqbjpppuERQ1+8pOfxI9+9KNYuXJlzJw5MzZs2BBLliyJtrY29yMjprA/Cjn00EOjsbExtm7dOuDyrVu3xqRJk3JaVWyLFy+OX/7yl/HrX/96RN7afn+3fv362LZtWxx33HHR1NQUTU1NsWbNmrjuuuuiqakp+vr68p5YGJMnT44ZM2YMuOw973lPbN68OadFxfTlL385vvrVr8bHP/7xOProo+NTn/pUXHLJJdHZ2Zn3tEL71/cY33/eXGHDYtSoUXH88cfH6tWr+y+rVquxevXqOOmkk3JcVjxZlsXixYtj1apV8atf/SqmTZuW96RCOv300+PJJ5+MDRs29J/mzJkTCxcujA0bNkRjY2PeEwtj3rx5b/iV52eeeSYOO+ywnBYV06uvvhoNDQMf5hsbG6Narea0aP8wbdq0mDRp0oDvPz09PfHoo4/6/hMF/1HI0qVLY9GiRTFnzpw48cQT49prr43e3t644IIL8p5WKB0dHbFy5cr4+c9/Hs3Nzf0/I2xtbY0xY8bkvK44mpub3/C6lIMOOijGjRvn9So1uuSSS+Lkk0+OK6+8Mj760Y/GY489FrfcckvccssteU8rlHPPPTeuuOKKmDp1asycOTN+97vfxTXXXBMXXnhh3tPq3o4dO2Ljxo395zdt2hQbNmyIsWPHxtSpU2PJkiXx7W9/O4466qiYNm1aXHrppdHW1hbnnXdefqPrRd6/lrK3rr/++mzq1KnZqFGjshNPPDFbu3Zt3pMKJyLe9HT77bfnPa3w/Lrp8P3iF7/IZs2alZXL5Wz69OnZLbfckvekwunp6ckuvvjibOrUqdno0aOzI444Ivv617+eVSqVvKfVvV//+tdv+ri4aNGiLMv+41dOL7300mzixIlZuVzOTj/99Ozpp5/Od3Sd8LbpAEAyhX2NBQBQf4QFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMv8ftizzR1e5mXcAAAAASUVORK5CYII=", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "data = await pr.read_fluorescence(\n", - " excitation_wavelength=485, emission_wavelength=528, focal_height=7.5\n", - ")\n", - "plt.imshow(data)" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "data = await pr.read_luminescence(focal_height=4.5)\n", - "plt.imshow(data)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Shaking" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.shake(\n", - " shake_type=SynergyH1Backend.ShakeType.LINEAR,\n", - " frequency=4 # linear frequency in mm, 1 <= frequency <= 6\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.stop_shaking()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Heating\n", - "\n", - "Synergy H1 supports heating but does not support active cooling." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.set_temperature(temperature=37) # Temperature in degrees C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.get_current_temperature() # Returns temperature in degrees C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.stop_heating_or_cooling() # Stop temperature control" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} \ No newline at end of file diff --git a/docs/user_guide/02_analytical/plate-reading/tecan-infinite.ipynb b/docs/user_guide/02_analytical/plate-reading/tecan-infinite.ipynb deleted file mode 100644 index f3e3a27cb06..00000000000 --- a/docs/user_guide/02_analytical/plate-reading/tecan-infinite.ipynb +++ /dev/null @@ -1,158 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Tecan Infinite 200 PRO\n", - "\n", - "The Tecan Infinite 200 PRO is a multimode microplate reader that supports absorbance, fluorescence, and luminescence measurements. This backend targets the Infinite \"M\" series (e.g., Infinite 200 PRO M Plex)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": "from pylabrobot.plate_reading import PlateReader\nfrom pylabrobot.plate_reading.tecan import ExperimentalTecanInfinite200ProBackend" - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": "pr = PlateReader(name=\"PR\", size_x=0, size_y=0, size_z=0, backend=ExperimentalTecanInfinite200ProBackend())\nawait pr.setup()" - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.open()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Before closing, assign a plate to the plate reader. This determines the well positions for measurements." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", - "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", - "pr.assign_child_resource(plate)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.close()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Absorbance\n", - "\n", - "Read absorbance at a specified wavelength (230-1000 nm)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "\n", - "data = await pr.read_absorbance(wavelength=450)\n", - "plt.imshow(data)\n", - "plt.colorbar(label=\"OD\")\n", - "plt.title(\"Absorbance at 450 nm\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": "## Fluorescence\n\nRead fluorescence with specified excitation and emission wavelengths (230-850 nm). The focal height is in millimeters." - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": "data = await pr.read_fluorescence(\n excitation_wavelength=485,\n emission_wavelength=528,\n focal_height=20.0\n)\nplt.imshow(data)\nplt.colorbar(label=\"RFU\")\nplt.title(\"Fluorescence (Ex: 485 nm, Em: 528 nm)\")" - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": "## Luminescence\n\nRead luminescence. The focal height is in millimeters." - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": "data = await pr.read_luminescence(focal_height=20.0)\nplt.imshow(data)\nplt.colorbar(label=\"RLU\")\nplt.title(\"Luminescence\")" - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Reading specific wells\n", - "\n", - "You can specify a subset of wells to read instead of the entire plate." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "wells = plate.get_items([\"A1\", \"A2\", \"B1\", \"B2\"])\n", - "data = await pr.read_absorbance(wavelength=450, wells=wells)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Cleanup" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.stop()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.10.0" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} \ No newline at end of file diff --git a/docs/user_guide/02_analytical/plate-reading/tecan-spark.ipynb b/docs/user_guide/02_analytical/plate-reading/tecan-spark.ipynb deleted file mode 100644 index 35a4d703c3b..00000000000 --- a/docs/user_guide/02_analytical/plate-reading/tecan-spark.ipynb +++ /dev/null @@ -1,210 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Tecan Spark 20M\n", - "\n", - "```{warning}\n", - "This backend is experimental. The API may change in future versions.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Physical)\n", - "\n", - "The Tecan Spark 20M connects via USB. Ensure the device is powered on and connected to your computer.\n", - "\n", - "You will need `pyusb` and `libusb` installed:\n", - "\n", - "```bash\n", - "pip install pylabrobot[usb]\n", - "```" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Programmatic)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.plate_reading import PlateReader, ExperimentalSparkBackend\n", - "from pylabrobot.resources import Coordinate\n", - "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n", - "\n", - "backend = ExperimentalSparkBackend()\n", - "pr = PlateReader(\n", - " name=\"spark\",\n", - " size_x=200,\n", - " size_y=200,\n", - " size_z=100,\n", - " backend=backend,\n", - ")\n", - "\n", - "plate = Cor_96_wellplate_360ul_Fb(name=\"test_plate\")\n", - "pr.assign_child_resource(plate, location=Coordinate.zero())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.setup()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Usage\n", - "\n", - "### Loading Tray" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.open()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Place your plate on the carrier, then close.\n", - "await pr.close()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "### Measuring Absorbance" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "abs_result = await pr.read_absorbance(wavelength=450)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "`abs_result` is a list of dicts containing wavelength, temperature, and a rows x cols data matrix." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "abs_result[0][\"data\"]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "### Measuring Fluorescence" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fluo_result = await pr.read_fluorescence(\n", - " excitation_wavelength=485,\n", - " emission_wavelength=535,\n", - " focal_height=20000,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fluo_result[0][\"data\"]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "### Measuring Luminescence" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# WIP: luminescence support is not yet implemented." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Teardown" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.stop()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.11.0" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/02_analytical/scales/img/mettler_toledo_wx_scale.png b/docs/user_guide/02_analytical/scales/img/mettler_toledo_wx_scale.png deleted file mode 100644 index 6f06293dad3..00000000000 Binary files a/docs/user_guide/02_analytical/scales/img/mettler_toledo_wx_scale.png and /dev/null differ diff --git a/docs/user_guide/02_analytical/scales/img/mt_electronic_unit.png b/docs/user_guide/02_analytical/scales/img/mt_electronic_unit.png deleted file mode 100644 index ffd0b839823..00000000000 Binary files a/docs/user_guide/02_analytical/scales/img/mt_electronic_unit.png and /dev/null differ diff --git a/docs/user_guide/02_analytical/scales/img/mt_load_cell.png b/docs/user_guide/02_analytical/scales/img/mt_load_cell.png deleted file mode 100644 index 273bcd2eab9..00000000000 Binary files a/docs/user_guide/02_analytical/scales/img/mt_load_cell.png and /dev/null differ diff --git a/docs/user_guide/02_analytical/scales/img/mt_terminal.png b/docs/user_guide/02_analytical/scales/img/mt_terminal.png deleted file mode 100644 index b674618854b..00000000000 Binary files a/docs/user_guide/02_analytical/scales/img/mt_terminal.png and /dev/null differ diff --git a/docs/user_guide/02_analytical/scales/img/scale_0_zero_example.png b/docs/user_guide/02_analytical/scales/img/scale_0_zero_example.png deleted file mode 100644 index 413789ef157..00000000000 Binary files a/docs/user_guide/02_analytical/scales/img/scale_0_zero_example.png and /dev/null differ diff --git a/docs/user_guide/02_analytical/scales/img/scale_1_tare_example.png b/docs/user_guide/02_analytical/scales/img/scale_1_tare_example.png deleted file mode 100644 index 39b29907171..00000000000 Binary files a/docs/user_guide/02_analytical/scales/img/scale_1_tare_example.png and /dev/null differ diff --git a/docs/user_guide/02_analytical/scales/img/scale_2_read_measurement_example.png b/docs/user_guide/02_analytical/scales/img/scale_2_read_measurement_example.png deleted file mode 100644 index 379b2cc35b1..00000000000 Binary files a/docs/user_guide/02_analytical/scales/img/scale_2_read_measurement_example.png and /dev/null differ diff --git a/docs/user_guide/02_analytical/scales/mettler-toledo-WXS205SDU.ipynb b/docs/user_guide/02_analytical/scales/mettler-toledo-WXS205SDU.ipynb deleted file mode 100644 index 483ee4079ce..00000000000 --- a/docs/user_guide/02_analytical/scales/mettler-toledo-WXS205SDU.ipynb +++ /dev/null @@ -1,328 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Mettler Toledo Precision Scales\n", - "\n", - "| Summary | Image |\n", - "|------------|--------|\n", - "|
  • OEM Link
  • Communication Protocol / Hardware: Serial / RS-232
  • Communication Level: Firmware (documentation shared by OEM)
  • Compatibility: This backend has been extensively tested on the WXS205SDU (but according to firmware documentation is applicable to other Mettler Toledo \"Automated Precision Weigh Modules\", including the WX and WMS series)
  • VID:PID: 0x0403:0x6001
  • Description: High-precision fine balance with various adapters available.
  • Load range: 0 - 220 g
  • Readability: 0.1 mg
|
![shaker](img/mettler_toledo_wx_scale.png)
Figure: Mettler Toledo WXS205SDU used for gravimetric liquid transfer verification
|" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Setup (Physical)\n", - "\n", - "The WXS205SDU scale system consists of 2 required units and 1 optional unit:\n", - "\n", - "### Machine Components\n", - "\n", - "| | **1. Load Cell** | **2. Electronic Unit** | **3. Terminal/Display** |\n", - "|-----------|-------|-------------|-------------|\n", - "| **Image** |
![load_cell](img/mt_load_cell.png) |
![electronic_unit](img/mt_electronic_unit.png) |
![terminal](img/mt_terminal.png) |\n", - "| **Description** | The weighing platform where samples are placed | The control and communication module | Optional: For manual reading of measurements |\n", - "\n", - "### Mettler Toledo Terminology\n", - "\n", - "| Configuration Name | Has Load Cell | Has Electronics Unit | Has Terminal/Display |\n", - "|---------------|---------------|-----------------|---------------------|\n", - "| **Balance** | ✓ | ✓ | ✓ |\n", - "| **Weigh Module** (or \"Bridge\") | ✓ | ✓ | ✗ |\n", - "\n", - "**Note:** When used with PyLabRobot, the terminal/display is optional since all control is done programmatically.\n", - "\n", - "### Connection\n", - "\n", - "The scale communicates via an RS-232 serial port.\n", - "\n", - "To connect it to your computer, you'll likely need a USB-to-serial adapter.\n", - "Any generic adapter using an FTDI chipset (typically ~$10) should work fine." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Setup (Programmatic)\n", - "\n", - "Import the necessary classes:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.scales import Scale\n", - "from pylabrobot.scales.mettler_toledo_backend import MettlerToledoWXS205SDUBackend\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Initialize the scale backend and create a scale instance.\n", - "You'll need to specify the serial port where your scale is connected:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "0.00148" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "backend = MettlerToledoWXS205SDUBackend(port=\"/dev/cu.usbserial-110\")\n", - "scale = Scale(name=\"scale\", backend=backend, size_x=0, size_y=0, size_z=0)\n", - "\n", - "await scale.setup()\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "```{Warning}\n", - "### Warm-up Time Required\n", - "\n", - "This scale requires a **warm-up period** after being powered on. Mettler Toledo documentation specifies 60-90 minutes, though in practice 30 minutes is often sufficient.\n", - "\n", - "If you attempt measurements before the scale has warmed up, you'll likely encounter an error: *\"Command understood but currently not executable (balance is currently executing another command)\"*.\n", - "\n", - "**Tip**: Sometimes power-cycling the scale (unplugging and replugging the power cord) can help resolve initialization issues.\n", - "```\n", - "\n", - "\n", - "```{Note}\n", - "This scale is the same model used in the Hamilton Liquid Verification Kit (LVK).\n", - "```" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Usage\n", - "\n", - "The scale implements the three core methods required for all PyLabRobot scales.\n", - "\n", - "They are presented here in typical workflow order:\n", - "\n", - "### `.zero()`\n", - "\n", - "Calibrates the scale to read zero when the platform is empty.\n", - "Unlike taring, this establishes the baseline \"empty\" reading without accounting for any container weight.\n", - "Use this at the start of a workflow or after removing all items from the platform." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await scale.zero(timeout=5)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "```{note}\n", - "See the [Scales documentation](./scales.rst) for details on the ``timeout`` parameter and when to use different timeout modes.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### `.tare()`\n", - "\n", - "Resets the scale reading to zero while accounting for the weight of a container already on the platform. Use this when you want to measure only the weight of material being added to a container.\n", - "\n", - "**Example workflow**:\n", - "Place an empty beaker on the scale → tare → dispense liquid → read only the liquid's weight." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await scale.tare(timeout=5)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The difference between load at `scale.zero()` and load at `scale.tare()` is stored in and can be retrieved from the scales's memory:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await scale.request_tare_weight()\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "\n", - "### `read_weight()`\n", - "\n", - "Retrieves the current weight measurement from the scale **in grams**." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "0.00148" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scale.read_weight(timeout=0)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "### Typical Workflow\n", - "\n", - "Here's a common pattern for gravimetric liquid transfer (i.e. aspiration AND dispensation) verification:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import asyncio\n", - "\n", - "# 1. Zero the scale\n", - "await scale.zero(timeout=\"stable\")\n", - "\n", - "# 2. Place container with liquid on scale\n", - "\n", - "# 3. Aspirate liquid from container (on scale)\n", - "# (your liquid handling code here)\n", - "\n", - "# 4. Tare the scale (ignore weight loss from aspiration)\n", - "await scale.tare(timeout=5)\n", - "\n", - "# 5. Dispense liquid back into same container (on scale)\n", - "# (your liquid handling code here)\n", - "\n", - "# 6. Brief pause to allow scale to settle\n", - "await asyncio.sleep(1) # Allow 1 second for settling after dispense\n", - "\n", - "# 7. Read the weight of dispensed liquid\n", - "weight_g = await scale.read_weight(timeout=5)\n", - "\n", - "# 8. Convert weight to volume\n", - "weight_mg = weight_g * 1000\n", - "liquid_density = 1.06 # mg/µL for 50% v/v glycerol at ~25°C, 1 atm\n", - "volume_uL = weight_mg / liquid_density\n", - "\n", - "print(f\"Dispensed {weight_mg:.2f} mg or ({volume_uL:.2f} µL)\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "### Performance Characterization\n", - "\n", - "#### Example: Measuring Read Time\n", - "\n", - "You can easily benchmark the scale's performance using standard Python timing:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "100.44 ms ± 6.78 ms\n" - ] - } - ], - "source": [ - "import time\n", - "import numpy as np\n", - "\n", - "times = []\n", - "for i in range(10):\n", - " t0 = time.monotonic_ns()\n", - " await scale.read_weight(timeout=\"stable\")\n", - " t1 = time.monotonic_ns()\n", - " times.append((t1 - t0) / 1e6)\n", - "\n", - "print(f\"{np.mean(times):.2f} ms ± {np.std(times):.2f} ms\")\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.12" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/user_guide/02_analytical/scales/scales.rst b/docs/user_guide/02_analytical/scales/scales.rst deleted file mode 100644 index e1a63147be4..00000000000 --- a/docs/user_guide/02_analytical/scales/scales.rst +++ /dev/null @@ -1,134 +0,0 @@ -Scales -====== - -Automated scales are essential precision instruments in laboratory automation, providing -gravimetric measurements for liquid handling verification, formulation, and analytical workflows. - -While conceptually simple, proper integration requires understanding how scales handle zeroing, -taring, and measurement stability. - -This section covers: - -- Core scale features: ``zero()``, ``tare()``, and ``read_weight()`` -- Understanding how scales calculate displayed weight -- Connecting to scales via serial interfaces -- Handling measurement stability and timeout parameters -- Device-specific configuration and limitations - - - ------------------------------------------- - -How Scales Calculate Weight ----------------------------- - -Understanding how scales calculate the displayed weight helps clarify the difference between -``zero()`` and ``tare()``: - -**Displayed Weight = (Current Sensor Reading - Zero Point) - Tare Weight** - -- **Zero Point**: The baseline sensor reading when you call ``zero()`` with an empty platform -- **Tare Weight**: The container weight stored in memory when you call ``tare()`` -- **Current Sensor Reading**: The actual load currently on the weighing platform - -**Important**: Neither ``zero()`` nor ``tare()`` restores the scale's capacity. If your scale -has a maximum capacity of 220g and you zero it with 5g already on the platform, you can only -add 215g more before reaching the limit. - ------------------------------------------- - -Core Scale Features -------------------- - -Every automated scale in PyLabRobot must implement at least three fundamental machine features (implemented as frontend methods): - -``zero()`` -~~~~~~~~~~ - -Calibrates the scale to read zero when nothing is on the weighing platform. -Unlike taring, this doesn't account for any container weight—it simply establishes the -baseline "empty" reading. -You typically zero a scale at the start of a workflow or when you've removed all items -from the platform and want to reset to true zero. - -.. image:: img/scale_0_zero_example.png - :alt: Zero operation example - :align: center - -``tare()`` -~~~~~~~~~~ - -Resets the scale's reading to zero while accounting for the weight of a container or vessel -already on the scale. -This is essential when you want to measure only the weight of material being added to a -container, ignoring the container's own weight. -For example, when dispensing liquid into a beaker, you would first place the beaker on -the scale, tare it, and then measure only the weight of any additional liquid added. - -.. image:: img/scale_1_tare_example.png - :alt: Tare operation example - :align: center - -``read_weight()`` -~~~~~~~~~~~~~~~~~ - -Retrieves the current weight measurement from the scale. -When you place an item on a scale or add material to a container, the scale doesn't instantly -settle on a final value - there's a brief period of oscillation as the measurement stabilizes. -This is due to physical factors like vibrations, air currents, or the mechanical settling of -the weighing mechanism. - -.. image:: img/scale_2_read_measurement_example.png - :alt: Read weight operation example - :align: center - ------------------------------------------- - -Understanding the ``timeout`` Parameter --------------------------------------------- - -All three core methods (``zero()``, ``tare()``, and ``read_weight()``) accept a ``timeout`` -parameter that controls how the scale handles measurement stability. - -**Available timeout modes:** - -- ``timeout="stable"`` - Wait for a stable reading - - The scale will wait indefinitely until the measurement stabilizes. Stability is detected - either by the scale's firmware (which monitors consecutive readings internally) or by - PyLabRobot polling repeatedly until fluctuations fall below a threshold. - - Use this when accuracy is critical: formulation, analytical chemistry, quality control. - -- ``timeout=0`` - Read immediately - - Returns the current value without waiting, even if still fluctuating. You might get - different values like 10.23g, 10.25g, 10.24g in quick succession. - - Use this for monitoring dynamic processes or when you need rapid feedback and can - tolerate small variations. - -- ``timeout=n`` (seconds) - Wait up to n seconds - - Attempts to get a stable reading within the specified time. If the reading stabilizes - before the timeout, it returns immediately. Otherwise, it returns the current value - after n seconds (which may still be unstable). - - Use this as a compromise between accuracy and speed, or to prevent indefinite waiting. - -**Example usage:** - -.. code-block:: python - - await scale.zero(timeout="stable") # Wait for stability - await scale.tare(timeout=5) # Wait max 5 seconds - weight_g = await scale.read_weight(timeout=0) # Read immediately - - ------------------------------------------- - -.. toctree:: - :maxdepth: 1 - :hidden: - - mettler-toledo-WXS205SDU diff --git a/docs/user_guide/_getting-started/plr-architecture.rst b/docs/user_guide/_getting-started/plr-architecture.rst deleted file mode 100644 index 2b642ff1ec9..00000000000 --- a/docs/user_guide/_getting-started/plr-architecture.rst +++ /dev/null @@ -1,67 +0,0 @@ -PLR Architecture -================ - -**"The Big Picture"** - - - -.. image:: ../img/2504_PLR_architecture.png - :alt: My Picture - :align: center - :width: 1000 - -------------------- - -Table of contents - -.. toctree:: - :maxdepth: 3 - - units - - -~~~~~~~~~~~~~~~~~~~~~~~~ -Integrating New Machines -~~~~~~~~~~~~~~~~~~~~~~~~ - - -.. **What do you want to get out of your new machine programmatically?** - -.. 1. Hardware-firmware access -.. 2. OEM software access - - -.. **Communication systems?** - -.. 1. Manufacturer API -.. 2. SiLA -.. 3. Firmware - -.. **Port connections?** - -.. 1. USB (Universal Serial Bus Type-A/B/C) -.. 2. RS232 -.. 3. Ethernet (RJ45, RJ11) -.. 4. WiFi (802.11a/b/g/n/ac/ax) -.. 5. Bluetooth (BLE, Classic) -.. 6. Serial (TTL, I2C, SPI) -.. 7. GPIO (General Purpose Input/Output) -.. 8. CAN (Controller Area Network) -.. 9. Modbus (RTU, TCP/IP) -.. 10. OPC UA (Open Platform Communications Unified Architecture) -.. 11. MQTT (Message Queuing Telemetry Transport) -.. 12. HTTP/HTTPS (Hypertext Transfer Protocol/Secure) -.. 13. WebSocket (Real-time communication protocol) -.. 14. RS485 (Serial Communication Standard) -.. 15. RS422 (Serial Communication Standard) -.. 16. FireWire (IEEE 1394) -.. 17. Thunderbolt (High-speed connection standard) -.. 18. HDMI (High-Definition Multimedia Interface) -.. 19. DisplayPort (Digital Display Interface) -.. 20. USB-C (Universal Serial Bus Type-C) -.. 21. SD Card (Secure Digital Card) - - - -For detailed information on how to integrate new machines into PLR ecosystem, -and contribute to the PLR project, please refer to `Contributor Guide`: diff --git a/docs/user_guide/agilent/index.md b/docs/user_guide/agilent/index.md new file mode 100644 index 00000000000..9e93d19e33c --- /dev/null +++ b/docs/user_guide/agilent/index.md @@ -0,0 +1,7 @@ +# Agilent + +TODO + +```{toctree} +:maxdepth: 1 +``` diff --git a/docs/user_guide/azenta/fluidx/intellixcap96/hello-world.ipynb b/docs/user_guide/azenta/fluidx/intellixcap96/hello-world.ipynb new file mode 100644 index 00000000000..904810bd71f --- /dev/null +++ b/docs/user_guide/azenta/fluidx/intellixcap96/hello-world.ipynb @@ -0,0 +1,403 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1c911a78", + "metadata": {}, + "source": "# FluidX IntelliXcap 96\n\nThe FluidX IntelliXcap 96 (an Azenta brand) is an automated screw-cap **decapper**. It unscrews, holds, and screws back on all 96 caps of a screw-cap tube rack in a single stroke. A plate mover loads the rack onto its one nest.\n\n## Resources\n\n- [Azenta IntelliXcap user manual](https://web.azenta.com/hubfs/azenta-files/resources/manuals-guides/319430-IXC-User-Manual.pdf)\n- [Azenta IntelliXcap 96 product page](https://www.azenta.com/products/intellixcap-automated-screw-cap-decapper-recapper-96-format)\n\n| Property | Value |\n| --- | --- |\n| Communication | Serial, STX/ETX-framed ASCII |\n| Serial settings | 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake |\n| Framing | each reply is wrapped in STX (`0x02`) .. ETX (`0x03`) |\n| Reply pattern | ACK (`0x06`), then `OK` echo, then a result frame |\n\nEvery command is a single character written followed by ETX. A motion command is accepted with an ACK and a `OK` echo; the status word then reads `StatusBUSY` while it moves. The terminal state depends on the operation: decap finishes at `StatusRECAP` while the head holds caps, whereas recap and waste finish at `StatusOK`. A refused or no-op command answers `CommandIgnore`.\n\n```{important}\nThe instrument only answers this protocol in **IntelliXcap mode**. Set setpoint 86 to `2` on the instrument touchscreen; there is no serial command for it.\n```" + }, + { + "cell_type": "markdown", + "id": "085d70c0", + "metadata": {}, + "source": "## Physical setup\n\nConnect the decapper's serial port to your computer through its USB-to-serial adapter and use the stable `by-id` path so the port survives re-enumeration, e.g. `/dev/serial/by-id/usb-FTDI_USB_Serial_Converter_XXXXXXXX-if00-port0`.\n\nTube volume is not a driver setting. The installed IntelliCartridge and its firmware profile define the supported tube height, cap geometry/thread, and motion settings. Use the cartridge specified for the exact tube family: two nominally 0.5 mL tube types may require different cartridges. Cartridge IDs 1–14 load their matching profiles automatically; extended cartridges require the corresponding stored profile to be selected on the instrument. The Python commands are otherwise identical for every supported tube type. See the [official Azenta IntelliXcap user manual](https://web.azenta.com/hubfs/azenta-files/resources/manuals-guides/319430-IXC-User-Manual.pdf) for cartridge/profile setup and compatibility guidance.\n\n```{important}\nIf the instrument comes up reporting `StatusBUSY` and ignores commands, it is locked out. The usual cause is an **engaged e-stop**; the safety guard/hood and other interlocks do the same. `setup()` reads the e-stop bit from the extended status and says whether it is actually engaged, then raises. If the e-stop reads as released, check the guard/hood and interlocks.\n\n`request_extended_status().estop_active` reports the same bit at any time.\n```" + }, + { + "cell_type": "markdown", + "id": "33641725", + "metadata": {}, + "source": [ + "## Connect\n\n", + "\n\n", + "`setup()` opens the serial port and reads the status. It raises if the device is not ready (e.g. e-stop)." + ] + }, + { + "cell_type": "code", + "id": "729f73e2", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.azenta.fluidx import FluidXIntelliXcap96\n\n", + "\n\n", + "decapper = FluidXIntelliXcap96(port=\"/dev/ttyUSB0\") # replace with your port\n\n", + "await decapper.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "e4a5895a", + "metadata": {}, + "source": "## Status\n\n`request_status()` returns the current status word: `StatusOK` (idle with no caps held), `StatusBUSY` (moving), `StatusRECAP` (decapped; caps held and ready to recap or waste), `StatusSLEEP` (standby), `StatusCAREJECT` (cartridge ejected), or `StatusMANUAL` (halted; needs inspection, then `reset_error()` or one of the manual recovery commands). Note the status word does **not** encode the tray position." + }, + { + "cell_type": "code", + "id": "00808280", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\"status:\", await decapper.request_status())" + ] + }, + { + "cell_type": "markdown", + "id": "9d0c74b1", + "metadata": {}, + "source": "### Error codes\n\n`StatusERROR` and `StatusMANUAL` only say that *something* went wrong. `request_error_code()` asks the instrument for the numeric code behind it, and returns `None` when nothing is latched.\n\nThe driver does this for you: whenever an operation fails, it reads the code back and raises a `FluidXError` carrying it in `error_code`, with the documented meaning in the message. `get_error_message()` looks a code up by hand, and `is_recoverable_error()` (also on the exception, as `.recoverable`) says whether homing will clear it:\n\n- **Recoverable** codes latch `StatusERROR`, which homing clears: 113/114 (decap), 117/118 (recap), 142 (cartridge eject), 143/144 (cartridge load).\n- **Every other** code halts the instrument in `StatusMANUAL`, which needs an operator to inspect it.\n\nThe code table comes from the error list in the [official Azenta IntelliXcap user manual](https://web.azenta.com/hubfs/azenta-files/resources/manuals-guides/319430-IXC-User-Manual.pdf)." + }, + { + "cell_type": "code", + "id": "3c55c578", + "source": "from pylabrobot.azenta.fluidx import get_error_message, is_recoverable_error\n\ncode = await decapper.request_error_code()\nif code is None:\n print(\"no error latched\")\nelse:\n print(code, get_error_message(code), \"recoverable:\", is_recoverable_error(code))", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "414a2f74", + "source": "## What the instrument can tell you about itself\n\nFour queries describe the machine and its consumable:\n\n- `request_firmware_versions()` -> unit, touchscreen and light curtain firmware. Two features depend on it: dry-run mode needs touchscreen V14 or above, and `waste()` is broken in V44 (the command list does not say which firmware that version refers to).\n- `request_cartridge_info()` -> the installed cartridge's profile, cycle count and serial. The serial always reads `\"00000000\"`; the firmware does not implement it.\n- `request_profile()` -> the active profile number and its decap/recap retry limits.\n- `request_extended_status()` -> the flags the status word leaves out, including whether caps are held on the pins, whether a cartridge is installed and whether the e-stop is engaged. `caps_on_pins()` is a shortcut for the first of those.\n\n```{note}\nThe command list names twelve extended-status flags but shows an eleven-character answer. `request_extended_status()` rejects any width other than twelve rather than guess which end is missing, because the most significant bit is `CAPS_ON_PINS`. If it raises on your instrument, the raw string is in the message.\n```", + "metadata": {} + }, + { + "cell_type": "code", + "id": "e15d3859", + "source": "print(\"firmware:\", await decapper.request_firmware_versions())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "a35b8210", + "source": "print(\"cartridge:\", await decapper.request_cartridge_info())\nprint(\"profile:\", await decapper.request_profile())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "a9137607", + "source": "print(\"extended status:\", await decapper.request_extended_status())\nprint(\"caps held:\", await decapper.caps_on_pins())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "6812c7f3", + "metadata": {}, + "source": [ + "## Tray\n", + "\n", + "Open and close the loading tray. Asking for a position the tray is already in is a harmless no-op. Tray motion does not change whether caps are held, so after decapping the terminal state remains `StatusRECAP` rather than returning to `StatusOK`." + ] + }, + { + "cell_type": "code", + "id": "b55ee6e6", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await decapper.open_tray()" + ] + }, + { + "cell_type": "code", + "id": "7b6fe71d", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await decapper.close_tray()" + ] + }, + { + "cell_type": "markdown", + "id": "de6913a2", + "source": "### Presenting the tray further out\n\nPast the load position the tray can travel out to an extended position, which is useful for handing decapped tubes to an operator or another instrument. The two ends are setpoints 3 (load) and 127 (extended), and the step size is setpoint 88; all three are configured on the instrument.\n\n- `extend_tray()` / `retract_tray()` move between the two ends. Extending requires the tray to be at the load position, so open the tray first.\n- `step_tray_out()` / `step_tray_in()` move by one step. A step that would leave the S3..S127 range fails rather than clipping.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "17591151", + "source": "# t fails unless the tray is already at the load position, so open it first.\nawait decapper.open_tray()\nawait decapper.extend_tray() # S3 -> S127, all the way out", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "8962b392", + "source": "await decapper.step_tray_in() # one S88 step back in", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "d57fd7ec", + "source": "await decapper.retract_tray() # all the way back to S3", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "310cb8dd", + "metadata": {}, + "source": [ + "## Home\n\n", + "\n\n", + "Home all axes." + ] + }, + { + "cell_type": "code", + "id": "349d6644", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await decapper.home()" + ] + }, + { + "cell_type": "markdown", + "id": "ec297b15", + "metadata": {}, + "source": "## Decap, recap, waste\n\nWith a rack of screw-cap tubes loaded on the nest, `decap()` unscrews and holds all 96 caps. After decapping, choose **one** terminal operation: `recap()` screws those caps back onto the tubes, while `waste()` releases them into a separately positioned cap carrier. `decap()` refuses if caps are already held (recap or waste first); `recap()` refuses if none are held.\n\nA fault (e.g. decapping with no rack loaded) can latch the device in `StatusError`, and the failing call raises a `FluidXError` carrying the instrument's error code and its documented meaning. Homing clears a latched `StatusError`. By default (`auto_recover=True`) the next operation you issue homes to clear it and then proceeds; pass `auto_recover=False` to have it raise instead. `StatusMANUAL` requires an explicit safety decision: inspect the rack and cap head, confirm that axis motion is safe, then call `reset_error()`. Hardware testing confirmed that it homes the machine from `StatusMANUAL` through `StatusBUSY` to `StatusOK`. Normal motion commands remain blocked until recovery completes.\n\nThe command list says the initialize sequence \"will not drop caps\", but it also documents a separate command for initializing without dropping them. Neither has been checked on hardware. With caps held, use `initialize_keeping_caps_on_pins()` (see *Manual recovery* below), which is the command documented for that case.\n\n```{warning}\n`waste()` is irreversible and does not detect whether a cap carrier is present. Before calling it, open the tray, remove the sample-tube rack, and position the correct cap carrier or collection vessel according to your instrument's procedure. On hardware, leaving the tube rack beneath the head caused the released caps to fall back onto the tube openings. They looked recapped but were not guaranteed to be threaded or torqued. See the [official Azenta IntelliXcap user manual](https://web.azenta.com/hubfs/azenta-files/resources/manuals-guides/319430-IXC-User-Manual.pdf), which defines waste as dropping caps into a carrier.\n\nFirmware V44 does not implement `waste()` at all — the command list does not say which of the three firmwares it means. On that version, load a store rack and use `decap()`/`recap()`, which select the store sequence themselves. `request_firmware_versions()` reports all three.\n```" + }, + { + "cell_type": "code", + "id": "6b3ddcb2", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# After inspecting the rack/head and confirming motion is safe:\n", + "await decapper.reset_error()" + ] + }, + { + "cell_type": "code", + "id": "cd3b20d4", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await decapper.decap() # unscrew and hold all 96 caps" + ] + }, + { + "cell_type": "code", + "id": "19704bfd", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Choose this path to retain the caps.\n", + "await decapper.recap() # screw and torque the held caps back on" + ] + }, + { + "cell_type": "code", + "id": "af381383", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Alternative to recap: with caps held, remove the tube rack and position\n", + "# the correct cap carrier first. Do not run this after recap.\n", + "await decapper.waste() # irreversibly release held caps into the carrier" + ] + }, + { + "cell_type": "markdown", + "id": "32aae9c6", + "source": "### Forcing another decap attempt\n\n**You only need this if you have turned light curtain error detection off.** With detection on, the instrument spots the tubes that failed to decap and retries the stroke by itself inside `decap()`, up to the profile's decap retry limit (`request_profile().decap_max_retry`). There is nothing left for you to force.\n\nWith detection off the instrument cannot see those tubes, so `decap()` returns successfully and leaves them capped. `retry_decap()` forces another stroke; `decap()` itself refuses, because caps are held. Call it as many times as you need.\n\n```{note}\nThe command list documents only that this command is used \"after a successful Decap\" and \"requires lightcurtain OFF\". That those are the same condition is inferred from the automatic-retry and error-detection commands rather than stated by the vendor.\n```", + "metadata": {} + }, + { + "cell_type": "code", + "id": "deb6dfc4", + "source": "await decapper.set_error_detection_enabled(False)\nawait decapper.retry_decap()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "5058ff42", + "source": "## Changing the cartridge\n\nThe IntelliCartridge defines which tubes the instrument can handle, so switching tube families means swapping it. `eject_cartridge()` puts the installed cartridge on the tray and `load_cartridge()` picks up the one resting there, returning the profile number the instrument loaded. Both read the extended status first, so calling them when the instrument is already in that state is a no-op.\n\nPhysically remove the ejected cartridge from the tray, and place the new one, between the two calls.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "1ca901ec", + "source": "# Empty the tray and make sure no caps are held first.\nawait decapper.eject_cartridge()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "fb66970d", + "source": "# Place the replacement cartridge on the tray, then pick it up.\nprint(\"loaded profile:\", await decapper.load_cartridge())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "e5924fd6", + "source": "# Zero the cycle counter, e.g. after fitting a rebuilt cartridge.\nawait decapper.reset_cartridge_counter()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "4e73401b", + "source": "## Settings\n\nThree flags change how the instrument behaves during a run.\n\n- `set_error_detection_enabled(False)` stops the light curtain from failing a decap/recap on error 135/136, so the stroke always finishes. It is also the prerequisite for `retry_decap()`. Detection is on at power-up.\n- `set_dry_run_enabled(True)` makes the instrument pause and wait for the operator on light curtain errors 114, 118, 135 and 136 instead of failing the operation. Needs touchscreen firmware V14 or above.\n- `set_safety_door_enabled(False)` disables the safety door, which leaves it open. Call it with `True` to re-enable; the door is also enabled at power-up.\n\n```{warning}\nEach of these weakens a safety or error check.\n```", + "metadata": {} + }, + { + "cell_type": "code", + "id": "6d7bc7b0", + "source": "await decapper.set_error_detection_enabled(False)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "fe87592b", + "source": "# Requires touchscreen firmware V14 or above.\nawait decapper.set_dry_run_enabled(True)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "63266af7", + "source": "# Opens the door and keeps it open; call with True to re-enable.\nawait decapper.set_safety_door_enabled(False)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "9a8f1df0", + "source": "## Manual recovery\n\nThese commands are only accepted while the instrument is halted in `StatusMANUAL`, and it stays there afterwards. They exist to get a stuck instrument back to a state you can inspect and clear.\n\n```{warning}\nAll of these move axes or open the safety door. Look inside the instrument and confirm that motion is safe before running any of them.\n```", + "metadata": {} + }, + { + "cell_type": "code", + "id": "2cce2756", + "source": "await decapper.open_safety_door()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "5201c0f1", + "source": "# Home the Z axis, keeping any held caps on the pins.\nawait decapper.head_up()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "724dea0a", + "source": "# Drops any caps still on the cap drivers, wherever the head is standing.\n# Clear the deck first; use waste() when you want to collect them.\nawait decapper.eject_caps()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "2b9e2326", + "source": "# Clears the error state and homes without dropping caps held on the pins.\n# Follow it with recap() to put the caps back on the tubes.\nawait decapper.initialize_keeping_caps_on_pins()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "6acf47ef", + "metadata": {}, + "source": [ + "## Standby\n\n", + "\n\n", + "Put the decapper to sleep with `standby()` and wake it with `ready()`." + ] + }, + { + "cell_type": "code", + "id": "6ce5ea4b", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await decapper.standby()" + ] + }, + { + "cell_type": "code", + "id": "7fad708f", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await decapper.ready()" + ] + }, + { + "cell_type": "markdown", + "id": "6d6c3888", + "metadata": {}, + "source": [ + "## Teardown\n\n", + "\n\n", + "`stop()` closes the serial connection." + ] + }, + { + "cell_type": "code", + "id": "a0d6806f", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await decapper.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/azenta/index.md b/docs/user_guide/azenta/index.md new file mode 100644 index 00000000000..ad4839bef3a --- /dev/null +++ b/docs/user_guide/azenta/index.md @@ -0,0 +1,7 @@ +# Azenta + +```{toctree} +:maxdepth: 1 + +fluidx/intellixcap96/hello-world +``` diff --git a/docs/user_guide/big_bear/index.md b/docs/user_guide/big_bear/index.md new file mode 100644 index 00000000000..05ec2633e36 --- /dev/null +++ b/docs/user_guide/big_bear/index.md @@ -0,0 +1,7 @@ +# BigBear + +```{toctree} +:maxdepth: 1 + +orbital-shaker/hello-world +``` diff --git a/docs/user_guide/big_bear/orbital-shaker/hello-world.ipynb b/docs/user_guide/big_bear/orbital-shaker/hello-world.ipynb new file mode 100644 index 00000000000..c9052fa0ea0 --- /dev/null +++ b/docs/user_guide/big_bear/orbital-shaker/hello-world.ipynb @@ -0,0 +1,133 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "bigbear-intro", + "source": "# BigBear Orbital Shaker\n\nThe BigBear orbital shaker is a plate shaker with a serial (RS-232 / ASCII) interface. A single serial line can drive up to 16 shaker positions -- called **nests** -- wired together in a *daisy chain*, each addressed independently.\n\n| Property | Value |\n|---|---|\n| Communication | Serial, ASCII line protocol |\n| Serial settings | 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake |\n| Line terminator | carriage return (`\\r`) |\n| Speed range | 60--3570 rpm |\n| Acceleration | 1--10 (device scale) |\n| Nests per chain | 1--16 |\n\n```{warning}\nThis driver has NOT been tested against hardware in PyLabRobot. The protocol,\nline terminator, and reply formats are reconstructed from the vendor software.\nIf you verify it on a shaker, please open a PR to remove the not-tested warning.\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "bigbear-daisy-md", + "source": "## Daisy chaining\n\nMultiple shaker units share one serial connection: the host talks to the first unit, which is wired to the second, and so on down the chain. Each unit is a **nest** with a 1-based address.\n\nEvery command is addressed with an `@` prefix, where `` is the nest number written in **hexadecimal** -- so nest 10 is `@A` and nest 16 is `@10`. A command reaches only the nest it names; the others ignore it. This lets one port run up to 16 shakers with independent speed, direction, and start/stop.\n\nAt `setup()` the driver:\n\n1. sends `U` to put the chain into daisy-chain (addressing) mode,\n2. sends `~` to ask how many units are present, and\n3. checks that the reported count matches the `num_shakers` you passed.\n\nIf the counts disagree (a unit is off, unplugged, or `num_shakers` is wrong) setup raises a `BigBearError`. Set `num_shakers` to the number of physical units on the chain.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "bigbear-setup-md", + "source": "## Physical setup\n\nConnect the shaker's serial port to your computer, typically through a USB-to-serial adapter. Chain any additional units in series and give the whole chain a single port.\n\nMake sure the shaker's serial parameters match the driver defaults (9600 baud, 8 data bits, no parity, 1 stop bit, no handshake).", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "bigbear-connect-md", + "source": "## Connect\n\n`setup()` opens the serial port, enters daisy-chain mode, and verifies the shaker count. It also logs the not-tested warning. Pass `num_shakers` to match your chain.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bigbear-connect-code", + "source": "from pylabrobot.big_bear import BigBearOrbitalShaker\n\nshaker = BigBearOrbitalShaker(port=\"/dev/ttyUSB0\", num_shakers=1) # replace with your port\nawait shaker.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "bigbear-shake-md", + "source": "## Start shaking\n\n`start_shaking()` sets the acceleration, speed, and direction for a nest and starts it. Shaking continues until you stop it. `device_id` selects which nest (default 1).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bigbear-shake-code", + "source": "await shaker.start_shaking(\n rpm=750,\n acceleration=3,\n sequence=\"CW\",\n device_id=1,\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "bigbear-stop-md", + "source": "## Stop shaking\n\nStop a single nest with `stop_shaking()`, or every nest on the chain with `stop_all()`.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bigbear-stop-code", + "source": "await shaker.stop_shaking(device_id=1)\n# or, for the whole chain:\n# await shaker.stop_all()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "bigbear-multi-md", + "source": "## Driving multiple nests\n\nWith a multi-unit chain, address each nest by its `device_id`. Each runs independently.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bigbear-multi-code", + "source": "# Example for a 3-nest chain (create with num_shakers=3)\n# await shaker.start_shaking(rpm=500, device_id=1)\n# await shaker.start_shaking(rpm=900, sequence=\"CCW\", device_id=2)\n# await shaker.start_shaking(rpm=1200, device_id=3)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "bigbear-home-md", + "source": "## Homing\n\nHome a single nest with `find_home()`, or every nest with `home_all()`.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bigbear-home-code", + "source": "await shaker.find_home(device_id=1)\n# or, for the whole chain:\n# await shaker.home_all()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "bigbear-info-md", + "source": "## Device info\n\nRead a nest's serial-number fields (`Y`, `X`, `Z`). The reply format is device-defined; the raw fields are returned joined.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bigbear-info-code", + "source": "print(\"Serial number:\", await shaker.get_serial_number(device_id=1))", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "bigbear-teardown-md", + "source": "## Teardown\n\n`stop()` stops every nest and closes the serial connection.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bigbear-teardown-code", + "source": "await shaker.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/brooks/index.md b/docs/user_guide/brooks/index.md new file mode 100644 index 00000000000..1915dba2772 --- /dev/null +++ b/docs/user_guide/brooks/index.md @@ -0,0 +1,7 @@ +# Brooks + +```{toctree} +:maxdepth: 1 + +precise_flex/hello-world +``` diff --git a/docs/user_guide/brooks/precise_flex/hello-world.ipynb b/docs/user_guide/brooks/precise_flex/hello-world.ipynb new file mode 100644 index 00000000000..850bcaa7d8d --- /dev/null +++ b/docs/user_guide/brooks/precise_flex/hello-world.ipynb @@ -0,0 +1,42 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "jac2m4qj22g", + "metadata": {}, + "source": [ + "# Brooks PreciseFlex PF400 / PF3400\n", + "\n", + "The PreciseFlex PF400 and PF3400 are SCARA robotic arms from Brooks Automation.\n", + "\n", + "The device communicates over Ethernet (TCP socket).\n", + "\n", + "| Model | PLR Name | Rail | Notes |\n", + "|---|---|---|---|\n", + "| PreciseFlex 400 | `PreciseFlex400` | optional | 4-axis SCARA + gripper |\n", + "| PreciseFlex 3400 | `PreciseFlex3400Backend` | optional | Extended-reach variant (backend only) |" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/byonoy/absorbance_96/hello-world.ipynb b/docs/user_guide/byonoy/absorbance_96/hello-world.ipynb new file mode 100644 index 00000000000..f48b88429d7 --- /dev/null +++ b/docs/user_guide/byonoy/absorbance_96/hello-world.ipynb @@ -0,0 +1,161 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "n0a5pl74u0o", + "metadata": {}, + "source": [ + "# Byonoy Absorbance 96\n", + "\n", + "The Absorbance 96 Automate (A96A) is a USB-HID plate reader from Byonoy that measures absorbance across a 96-well plate in a single flash.\n", + "\n", + "The hardware consists of three physical parts: a **base unit** (holds the plate), an **illumination unit** (light source, sits on top during measurement), and an optional **SBS adapter** for standard footprint integration. PLR models all three as resources so a robotic arm can move the illumination unit on and off the base.\n", + "\n", + "- **Communication**: USB HID (VID `0x16D0` / PID `0x1199`)\n", + "- **Communication level**: Firmware" + ] + }, + { + "cell_type": "markdown", + "id": "tupar6h068", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Use `byonoy_a96a` to create the full setup (detection unit + illumination unit). The detection unit is both a `Resource` (base with plate holder) and a `Device` (drives the backend over USB HID)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "w1m3gjaser", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.byonoy import byonoy_a96a\n", + "\n", + "reader, illumination_unit = byonoy_a96a(name=\"a96a\")\n", + "await reader.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "tby71rvde8s", + "metadata": {}, + "source": [ + "During `setup()`, the backend opens the USB HID connection and runs an initialization measurement to calibrate the sensor." + ] + }, + { + "cell_type": "markdown", + "id": "j2dquxyo5gm", + "metadata": {}, + "source": [ + "## Absorbance\n", + "\n", + "Before reading, assign a plate to the base unit's plate holder and make sure the illumination unit is removed from the base (the interlock will raise an error otherwise)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dugdvssa3zq", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "# Remove the illumination unit from the base so the plate can be loaded\n", + "reader.illumination_unit_holder.unassign_child_resource(illumination_unit)\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", + "reader.plate_holder.assign_child_resource(plate)" + ] + }, + { + "cell_type": "markdown", + "id": "re14dqp1uxb", + "metadata": {}, + "source": [ + "Read absorbance at a single wavelength. The wavelength must be one of the device's available wavelengths (queried automatically during `setup()`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bhi338chcfk", + "metadata": {}, + "outputs": [], + "source": [ + "results = await reader.absorbance.read_absorbance(plate, wavelength=450)" + ] + }, + { + "cell_type": "markdown", + "id": "qrtoibefkpl", + "metadata": {}, + "source": [ + "You can check which wavelengths are available on your unit:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6p6cl01jswv", + "metadata": {}, + "outputs": [], + "source": [ + "print(reader.driver.available_wavelengths)" + ] + }, + { + "cell_type": "markdown", + "id": "43bn89mateq", + "metadata": {}, + "source": [ + "## Resource layout\n", + "\n", + "The Absorbance 96 has an interlock: you cannot assign a plate to the base while the illumination unit is on top. In an automated workcell, use a robotic arm to move the illumination unit to a parking base before loading the plate.\n", + "\n", + "```\n", + "ByonoyAbsorbance96 (base + device)\n", + " +-- plate_holder (assign plates here)\n", + " +-- illumination_unit_holder (illumination unit sits here during measurement)\n", + "```\n", + "\n", + "A separate `byonoy_a96a_parking_unit` can be used as a second base (no backend) where the illumination unit rests while plates are swapped." + ] + }, + { + "cell_type": "markdown", + "id": "vt3457fkbz", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2bhu26jg5gt", + "metadata": {}, + "outputs": [], + "source": [ + "await reader.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/byonoy/index.md b/docs/user_guide/byonoy/index.md new file mode 100644 index 00000000000..53ad0851c69 --- /dev/null +++ b/docs/user_guide/byonoy/index.md @@ -0,0 +1,30 @@ +# Byonoy + +```{toctree} +:maxdepth: 1 + +absorbance_96/hello-world +luminescence_96/hello-world +luminescence_96/led_bar +``` + +## Absorbance 96 models + +| Model | PLR resource | Factory function | +|---|---|---| +| A96A full setup | `ByonoyAbsorbance96` + illumination unit | `byonoy_a96a` | +| Detection unit only | `ByonoyAbsorbance96` | `byonoy_a96a_detection_unit` | +| Illumination unit | `Resource` | `byonoy_a96a_illumination_unit` | +| Parking base (no backend) | `ByonoyAbsorbanceBaseUnit` | `byonoy_a96a_parking_unit` | +| SBS adapter | `ResourceHolder` | `byonoy_sbs_adapter` | + +## Luminescence 96 models + +| Model | PLR resource | Factory function | +|---|---|---| +| L96 full setup | `ByonoyLuminescenceBaseUnit` + `ByonoyLuminescence96` | `byonoy_l96` | +| L96A full setup (automate) | `ByonoyLuminescenceBaseUnit` + `ByonoyLuminescence96` | `byonoy_l96a` | +| L96 reader unit only | `ByonoyLuminescence96` | `byonoy_l96_reader_unit` | +| L96A reader unit only | `ByonoyLuminescence96` | `byonoy_l96a_reader_unit` | +| L96 base unit only | `ByonoyLuminescenceBaseUnit` | `byonoy_l96_base_unit` | +| L96A base unit only | `ByonoyLuminescenceBaseUnit` | `byonoy_l96a_base_unit` | diff --git a/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb b/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb new file mode 100644 index 00000000000..3d22e6c492c --- /dev/null +++ b/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb @@ -0,0 +1,450 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# Byonoy Luminescence 96 — lab guide\n", + "\n", + "Run a luminescence assay on the Byonoy L96 from PyLabRobot. Assumes the device is plugged in via USB and you've installed `pylabrobot` (with `hid` and `hidapi`).\n", + "\n", + "The L96 is a 96-well luminescence-only plate reader. It reads emitted light per well, no excitation source. Communicates over USB HID (vid `0x16D0`, pid `0x119B`)." + ] + }, + { + "cell_type": "markdown", + "id": "s1-md", + "metadata": {}, + "source": [ + "## 1. Connect\n", + "\n", + "`base` is a resource (a plate goes here); `reader` is both a resource and a device (the detector unit). After `setup()` the HID handle is open.\n", + "\n", + "> **One process at a time.** macOS / Windows / Linux all give exclusive HID access. If a previous Python session crashed without `stop()`, the next `setup()` will fail with \"device already open\". Replug the USB cable to force-release." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s1-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.byonoy import byonoy_l96 # or byonoy_l96a for the automate variant\n", + "\n", + "base, reader = byonoy_l96(name=\"l96\")\n", + "await reader.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "05b48622", + "metadata": {}, + "source": [ + "## Resource layout\n", + "\n", + "The reader unit is both a `Resource` and a `Device`. The base unit owns two child holders, and the interlock lives on the plate holder:\n", + "\n", + "```\n", + "ByonoyLuminescenceBaseUnit (base)\n", + " +-- plate_holder (assign plates here)\n", + " +-- reader_unit_holder (reader unit sits here during measurement)\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "s2-md", + "metadata": {}, + "source": [ + "## 2. Load a plate\n", + "\n", + "The reader-on-base interlock prevents you from assigning a plate while the detector is still on the holder — it forces a sane physical sequence.\n", + "\n", + "After running this cell, physically place the plate in the reader and place the detector back on top." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s2-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "base.reader_unit_holder.unassign_child_resource(reader) # take detector off\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", + "base.plate_holder.assign_child_resource(plate)" + ] + }, + { + "cell_type": "markdown", + "id": "s3-md", + "metadata": {}, + "source": [ + "## 3. Read — the basics\n", + "\n", + "`focal_height` is required by the abstract `Luminescence` capability but **ignored by the L96** — the device has a fixed optical configuration (the detector unit clamps onto the base; geometry is determined by plate + base + detector heights, not user-tunable). Pass `0` by convention.\n", + "\n", + "### Result shape\n", + "\n", + "`data` is plate row-major: `data[0]` = `[A1..A12]`, `data[1]` = `[B1..B12]`, ..., `data[7]` = `[H1..H12]`. So `data[2][5]` is well `C6`. Values are floats in **RLU (Relative Light Units) per integration period** — not per second. Doubling integration time roughly doubles signal *and* dark counts.\n", + "\n", + "### Background\n", + "\n", + "With nothing in the wells (and a dark environment), expect noise around ±50 RLU at SENSITIVE (2 s). Non-zero noise is dark-current spread; **negative values are normal** because the firmware applies a baseline subtraction.\n", + "\n", + "> **Light leakage.** The L96 is designed to be light-tight from above (the detector unit covers the plate) but the bottom housing isn't perfectly sealed. Reading on a *white* surface vs a *black* surface can change empty-well readings from ~50 to ~50,000+ RLU because reflected ambient light leaks in. For real assays use a black mat or a dark cabinet." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s3-code", + "metadata": {}, + "outputs": [], + "source": [ + "results = await reader.luminescence.read(plate=plate, focal_height=0)\n", + "data = results[0].data # 8 × 12 list[list[float]]\n", + "timestamp = results[0].timestamp # epoch seconds\n", + "\n", + "print(f\"timestamp={timestamp}\")\n", + "for row in data:\n", + " print(\" \" + \" \".join(f\"{v:8.2f}\" for v in row))" + ] + }, + { + "cell_type": "markdown", + "id": "s4-md", + "metadata": {}, + "source": [ + "## 4. Picking an integration mode\n", + "\n", + "Four modes, mapping to the byonoy_device_library presets:\n", + "\n", + "| Mode | Integration time | Use for |\n", + "|---|---|---|\n", + "| `RAPID` | 100 ms | Saturation checks, quick \"is it bright?\" |\n", + "| `SENSITIVE` | 2 s (default) | Most assays — luciferase, BRET, NanoBiT |\n", + "| `ULTRA_SENSITIVE` | 20 s | Very faint signals; low-copy reporters |\n", + "| `CUSTOM` | user-supplied | Your own duration |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s4-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.byonoy import ByonoyLuminescence96Backend\n", + "\n", + "# Preset\n", + "results = await reader.luminescence.read(\n", + " plate=plate,\n", + " focal_height=0,\n", + " backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n", + " mode=\"ultra_sensitive\",\n", + " ),\n", + ")\n", + "\n", + "# Custom (any duration in seconds) — auto-switches to CUSTOM mode\n", + "results = await reader.luminescence.read(\n", + " plate=plate,\n", + " focal_height=0,\n", + " backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n", + " integration_time=5.0,\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "s5-md", + "metadata": {}, + "source": [ + "## 5. Reading specific wells\n", + "\n", + "Pass a `wells=` list to `read()` — only those wells get real values back; everything else comes back as `None`. The result shape is still 8×12 (per the `LuminescenceResult` contract); unmeasured cells are `None` so you can distinguish \"didn't read\" from a legitimate `0.0` reading (baseline subtraction can yield ~0 or negative values).\n", + "\n", + "> **No speed-up.** The firmware always integrates the whole 96-sensor array. Reading one column with `SENSITIVE` takes the same wall-clock as reading the full plate (~28 s in our hardware test). The `wells` filter keeps your downstream tidy — it doesn't save time. If you want fast, use `RAPID` mode." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s5-code", + "metadata": {}, + "outputs": [], + "source": [ + "# Only column 1 (A1, B1, ..., H1)\n", + "col1_wells = [plate.get_well(f\"{r}1\") for r in \"ABCDEFGH\"]\n", + "\n", + "results = await reader.luminescence.read(\n", + " plate=plate,\n", + " focal_height=0,\n", + " wells=col1_wells,\n", + ")\n", + "# results[0].data[0][0] is A1 (float); results[0].data[0][1] is A2 (None)" + ] + }, + { + "cell_type": "markdown", + "id": "s6-md", + "metadata": {}, + "source": [ + "## 6. Timed read (delay before reading)\n", + "\n", + "For a substrate-injection assay where you want a fixed delay between adding reagent and reading. `await asyncio.sleep` doesn't block the event loop, and the reader stays connected." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s6-code", + "metadata": {}, + "outputs": [], + "source": [ + "import asyncio\n", + "\n", + "# ... pipette substrate into the plate ...\n", + "await asyncio.sleep(60) # 60 s incubation\n", + "results = await reader.luminescence.read(plate=plate, focal_height=0)" + ] + }, + { + "cell_type": "markdown", + "id": "s7-md", + "metadata": {}, + "source": [ + "## 7. Kinetic read (time series)\n", + "\n", + "Read the same plate every N seconds, collect a stack of matrices. With `SENSITIVE` (2 s) the wall-clock per read is around 3 s including overhead, so `interval_s` must be ≥ 3. With `ULTRA_SENSITIVE` (20 s) it's around 28 s — plan accordingly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s7-code", + "metadata": {}, + "outputs": [], + "source": [ + "import asyncio, time\n", + "import numpy as np\n", + "\n", + "frames = []\n", + "duration_s = 600 # 10 minutes total\n", + "interval_s = 30 # one read every 30 s\n", + "\n", + "t_start = time.time()\n", + "while time.time() - t_start < duration_s:\n", + " t_read = time.time()\n", + " results = await reader.luminescence.read(plate=plate, focal_height=0)\n", + " frames.append({\n", + " \"t\": t_read - t_start,\n", + " \"data\": results[0].data,\n", + " })\n", + " elapsed = time.time() - t_read\n", + " if elapsed < interval_s:\n", + " await asyncio.sleep(interval_s - elapsed)\n", + "\n", + "matrix_stack = np.array([f[\"data\"] for f in frames]) # (n_frames, 8, 12)\n", + "times = np.array([f[\"t\"] for f in frames])\n", + "print(f\"collected {len(frames)} frames over {duration_s} s\")\n", + "# Trace for well C6:\n", + "trace = matrix_stack[:, 2, 5]" + ] + }, + { + "cell_type": "markdown", + "id": "s8-md", + "metadata": {}, + "source": [ + "## 8. Stopping a long read\n", + "\n", + "If you need to bail out of an `ULTRA_SENSITIVE` read mid-flight (or any read takes longer than expected), `cancel()` raises a flag the read loop checks; bail-out is within ~2 s. After cancel the device stays initialised and immediately accepts new reads." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s8-code", + "metadata": {}, + "outputs": [], + "source": [ + "task = asyncio.create_task(\n", + " reader.luminescence.read(plate=plate, focal_height=0,\n", + " backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n", + " mode=\"ultra_sensitive\",\n", + " ),\n", + " )\n", + ")\n", + "await asyncio.sleep(1.0)\n", + "await reader.driver.cancel()\n", + "try:\n", + " await task\n", + "except asyncio.CancelledError:\n", + " print(\"aborted cleanly\")" + ] + }, + { + "cell_type": "markdown", + "id": "s9-md", + "metadata": {}, + "source": [ + "## 9. Device health & identity\n", + "\n", + "Useful at the start of a session, in error messages, or for run logging.\n", + "\n", + "> **`slot_state`**: `OCCUPIED` when a plate is loaded, `UNKNOWN` when nothing is in the reader (the firmware can't tell empty from missing). Don't treat `UNKNOWN` as an error.\n", + ">\n", + "> **`error_code`**: `0` is `NO_ERROR`. The Lum96 firmware doesn't publish a documented table for non-zero values, so non-zero codes surface as `errorCode=0xNN` (matching what Byonoy's own C library returns). For Abs96 / AbsOne backends, names like `ERROR_CALIB` / `AMBIENT_LIGHT` are decoded automatically." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s9-code", + "metadata": {}, + "outputs": [], + "source": [ + "status = await reader.driver.request_status()\n", + "env = await reader.driver.request_environment()\n", + "info = await reader.driver.request_device_info()\n", + "versions = await reader.driver.request_versions()\n", + "api = await reader.driver.request_api_version()\n", + "supported = await reader.driver.request_supported_reports()\n", + "\n", + "print(f\"{info.device_name} sn={info.serial_no} fw={info.firmware_version}\")\n", + "print(f\" uptime {status.uptime_s} s, T={env.temperature_c:.1f}°C, RH={env.humidity*100:.0f}%\")\n", + "print(f\" slot: {status.slot_state.name}, error: {reader.driver.describe_error_code(status.error_code)}\")\n", + "print(f\" api v{api}, fw production={versions.is_production}\")\n", + "print(f\" supported reports ({len(supported)}): \" + \", \".join(f\"0x{i:04X}\" for i in supported))" + ] + }, + { + "cell_type": "markdown", + "id": "s10-md", + "metadata": {}, + "source": [ + "## 10. Visual feedback (LED bar)\n", + "\n", + "The L96 has a 20-pixel addressable RGB front bar. Useful in a workcell to flag run state — solid colors for status, firmware-driven animations (`BREATHING`, `CYLON`, etc.) for \"busy\" indicators, or per-pixel control for progress bars and custom animations.\n", + "\n", + "See the dedicated [LED bar notebook](led_bar.ipynb) for the full surface and recipes (KITT scanner, gradients, etc.). Quick taste below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s10-code", + "metadata": {}, + "outputs": [], + "source": [ + "await reader.driver.set_led_color((0, 255, 0)) # solid green: ready\n", + "await reader.driver.set_led_color((0, 255, 0), \"breathing\", duration_ms=10000) # busy\n", + "await reader.driver.set_led_colors([(255, 0, 0)] * 10 + [(0, 0, 255)] * 10) # per-pixel split" + ] + }, + { + "cell_type": "markdown", + "id": "s11-md", + "metadata": {}, + "source": [ + "## 11. End-point luciferase recipe\n", + "\n", + "End-to-end workflow for a typical end-point luciferase assay." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s11-code", + "metadata": {}, + "outputs": [], + "source": [ + "import asyncio, time\n", + "import numpy as np\n", + "from pylabrobot.byonoy import byonoy_l96, ByonoyLuminescence96Backend\n", + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "# Connect\n", + "base, reader = byonoy_l96(name=\"assay\")\n", + "await reader.setup()\n", + "await reader.driver.set_led_color((255, 150, 0)) # amber: prep\n", + "\n", + "# Sanity check\n", + "status = await reader.driver.request_status()\n", + "info = await reader.driver.request_device_info()\n", + "print(f\"{info.device_name} sn={info.serial_no} — {status.slot_state.name}\")\n", + "assert status.error_code == 0\n", + "\n", + "# Load plate\n", + "base.reader_unit_holder.unassign_child_resource(reader)\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"assay_plate\")\n", + "base.plate_holder.assign_child_resource(plate)\n", + "# (operator places plate, places detector back on top)\n", + "\n", + "# Read — green while measuring\n", + "await reader.driver.set_led_color((0, 255, 0))\n", + "results = await reader.luminescence.read(\n", + " plate=plate,\n", + " focal_height=0,\n", + " backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n", + " mode=\"sensitive\",\n", + " ),\n", + ")\n", + "data = np.array(results[0].data) # 8 × 12\n", + "\n", + "# Save + tidy up\n", + "np.save(f\"luminescence_{int(time.time())}.npy\", data)\n", + "await reader.driver.set_led_color((0, 0, 0)) # off\n", + "await reader.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "s12-md", + "metadata": {}, + "source": [ + "## 12. Troubleshooting\n", + "\n", + "| Symptom | Likely cause | Fix |\n", + "|---|---|---|\n", + "| `setup()` raises \"device already open\" | Previous Python session left HID handle locked | Replug USB cable, or kill stale Python processes |\n", + "| All wells read very high (10⁴–10⁶) with no sample | Light leak through housing bottom | Use a dark mat or close the room |\n", + "| Strong gradient A→H with no sample | Directional light leak (one side leakier) | Same — dark mat |\n", + "| `slot_state=UNKNOWN` | No plate loaded | Expected — firmware cannot detect \"nothing\" definitively |\n", + "| Read takes 28 s for one well in SENSITIVE | Firmware always integrates 96 wells | Use `RAPID` for fast, accept the 28 s for SENSITIVE |\n", + "| `cancel()` returns immediately, read keeps going | No measurement in flight | `cancel()` auto-detects the in-flight trigger; no-op if there isn't one |\n", + "| Negative readings on empty wells | Firmware baseline subtraction | Expected — they should sit around zero |\n", + "| `focal_height` parameter has no effect | L96 has fixed optics | Expected — parameter is ABC contract, ignored on this device |" + ] + }, + { + "cell_type": "markdown", + "id": "s13-md", + "metadata": {}, + "source": [ + "## 13. Reference\n", + "\n", + "- **Hardware protocol**: HID 64-byte frames, vid `0x16D0`, pid `0x119B`. Report IDs decoded from Byonoy's `byonoyusbhid.h`. `routing_info=\\x80\\x40` requests a reply; `\\x00\\x00` is fire-and-forget." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/byonoy/luminescence_96/led_bar.ipynb b/docs/user_guide/byonoy/luminescence_96/led_bar.ipynb new file mode 100644 index 00000000000..5306306c5ea --- /dev/null +++ b/docs/user_guide/byonoy/luminescence_96/led_bar.ipynb @@ -0,0 +1,311 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# Byonoy L96 — LED bar\n", + "\n", + "The L96 has a 20-pixel addressable RGB front bar on the chassis. PyLabRobot exposes two methods to drive it:\n", + "\n", + "| Method | Use for |\n", + "|---|---|\n", + "| `set_led_color(color, effect)` | Single color across all 20 pixels, optionally animated by the firmware (`BREATHING`, `CYLON`, `RAINBOW`, ...) |\n", + "| `set_led_colors(colors)` | Per-pixel control — supply a list of up to 20 RGB triplets. Fast enough for real-time animation. |" + ] + }, + { + "cell_type": "markdown", + "id": "connect-md", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "We'll talk to the device's driver directly, so the bar can be driven without setting up a plate read." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "connect-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-17T02:44:03.229447Z", + "iopub.status.busy": "2026-05-17T02:44:03.229245Z", + "iopub.status.idle": "2026-05-17T02:44:03.307144Z", + "shell.execute_reply": "2026-05-17T02:44:03.306740Z" + } + }, + "outputs": [], + "source": [ + "from pylabrobot.byonoy import byonoy_l96\n", + "\n", + "base, reader = byonoy_l96(name=\"l96\")\n", + "await reader.setup()\n", + "drv = reader.driver" + ] + }, + { + "cell_type": "markdown", + "id": "solid-md", + "metadata": {}, + "source": [ + "## Solid colors\n", + "\n", + "The simplest call: one color, `SOLID` effect (the default). The firmware snaps the bar to that color." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "solid-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-17T02:44:03.308426Z", + "iopub.status.busy": "2026-05-17T02:44:03.308329Z", + "iopub.status.idle": "2026-05-17T02:44:06.321067Z", + "shell.execute_reply": "2026-05-17T02:44:06.319852Z" + } + }, + "outputs": [], + "source": [ + "import asyncio\n", + "\n", + "await drv.set_led_color((255, 0, 0)) # red\n", + "await asyncio.sleep(1)\n", + "await drv.set_led_color((0, 255, 0)) # green\n", + "await asyncio.sleep(1)\n", + "await drv.set_led_color((0, 0, 255)) # blue\n", + "await asyncio.sleep(1)\n", + "await drv.set_led_color((0, 0, 0)) # off" + ] + }, + { + "cell_type": "markdown", + "id": "effects-md", + "metadata": {}, + "source": [ + "## Built-in effects\n", + "\n", + "The firmware can animate the base color for you. Pass `duration_ms` to set how long the effect runs before reverting to firmware control. Use `force=True` to override an unexpired previous duration.\n", + "\n", + "| Effect | Behavior |\n", + "|---|---|\n", + "| `SOLID` | Snap to color (default) |\n", + "| `BREATHING` | Pulse brightness |\n", + "| `BLINKING` | Flash on/off |\n", + "| `CYLON` | Bouncing dot across the bar |\n", + "| `RAINBOW` | Cycle through hues (ignores supplied color) |\n", + "| `PROGRESS` | Fill progressively, driven by `effect_state` (0–255) |" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "effects-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-17T02:44:06.324515Z", + "iopub.status.busy": "2026-05-17T02:44:06.324197Z", + "iopub.status.idle": "2026-05-17T02:44:16.339410Z", + "shell.execute_reply": "2026-05-17T02:44:16.337534Z" + } + }, + "outputs": [], + "source": [ + "await drv.set_led_color((0, 255, 0), \"breathing\", duration_ms=6000)\n", + "await asyncio.sleep(6)\n", + "\n", + "await drv.set_led_color((255, 0, 255), \"cylon\", duration_ms=4000, force=True)\n", + "await asyncio.sleep(4)\n", + "\n", + "await drv.set_led_color((0, 0, 0)) # back to off" + ] + }, + { + "cell_type": "markdown", + "id": "pixels-md", + "metadata": {}, + "source": [ + "## Per-pixel control\n", + "\n", + "`set_led_colors(colors)` takes a list of up to 20 `(r, g, b)` triplets, one per pixel (left to right). Shorter lists are zero-padded; longer ones are truncated." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "pixels-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-17T02:44:16.344037Z", + "iopub.status.busy": "2026-05-17T02:44:16.343628Z", + "iopub.status.idle": "2026-05-17T02:44:23.366202Z", + "shell.execute_reply": "2026-05-17T02:44:23.364702Z" + } + }, + "outputs": [], + "source": [ + "# Left half red, right half blue\n", + "await drv.set_led_colors([(255, 0, 0)] * 10 + [(0, 0, 255)] * 10)\n", + "await asyncio.sleep(2)\n", + "\n", + "# Rainbow gradient across the bar\n", + "import colorsys\n", + "def hue(i, n=20):\n", + " r, g, b = colorsys.hsv_to_rgb(i / n, 1, 1)\n", + " return (int(r * 255), int(g * 255), int(b * 255))\n", + "\n", + "await drv.set_led_colors([hue(i) for i in range(20)])\n", + "await asyncio.sleep(3)\n", + "\n", + "# Every other pixel\n", + "await drv.set_led_colors([(0, 255, 0) if i % 2 == 0 else (0, 0, 0) for i in range(20)])\n", + "await asyncio.sleep(2)\n", + "\n", + "await drv.set_led_colors([(0, 0, 0)] * 20) # off" + ] + }, + { + "cell_type": "markdown", + "id": "scanner-md", + "metadata": {}, + "source": [ + "## Animation recipe: KITT scanner\n", + "\n", + "A back-and-forth scanner with a fading trail — the kind of thing you'd want as a \"robot is busy\" indicator. Frame rate is set by how fast you call `set_led_colors`; ~30 fps is comfortable." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "scanner-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-17T02:44:23.369015Z", + "iopub.status.busy": "2026-05-17T02:44:23.368873Z", + "iopub.status.idle": "2026-05-17T02:44:30.548475Z", + "shell.execute_reply": "2026-05-17T02:44:30.547235Z" + } + }, + "outputs": [], + "source": [ + "import asyncio\n", + "\n", + "N = 20\n", + "trail = 4 # length of the fading trail\n", + "decay = 0.45 # brightness ratio per trail step\n", + "step_s = 0.06 # ~16 fps; lower for faster\n", + "\n", + "def frame(head):\n", + " px = [(0, 0, 0)] * N\n", + " for k in range(trail + 1):\n", + " v = int(255 * (decay ** k))\n", + " if 0 <= head - k < N:\n", + " px[head - k] = (v, 0, 0) # red trail\n", + " return px\n", + "\n", + "positions = list(range(N)) + list(range(N - 2, 0, -1)) # 0..19..1\n", + "for _ in range(3): # 3 ping-pong cycles\n", + " for head in positions:\n", + " await drv.set_led_colors(frame(head))\n", + " await asyncio.sleep(step_s)\n", + "\n", + "await drv.set_led_colors([(0, 0, 0)] * 20)" + ] + }, + { + "cell_type": "markdown", + "id": "walking-md", + "metadata": {}, + "source": [ + "## Animation recipe: walking dot\n", + "\n", + "Simplest possible animation — one bright pixel marches across the bar. Useful as a progress indicator with a known step count." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "walking-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-17T02:44:30.551351Z", + "iopub.status.busy": "2026-05-17T02:44:30.551132Z", + "iopub.status.idle": "2026-05-17T02:44:33.613534Z", + "shell.execute_reply": "2026-05-17T02:44:33.612076Z" + } + }, + "outputs": [], + "source": [ + "for pos in range(20):\n", + " px = [(0, 0, 0)] * 20\n", + " px[pos] = (0, 255, 0)\n", + " await drv.set_led_colors(px)\n", + " await asyncio.sleep(0.15)\n", + "\n", + "await drv.set_led_colors([(0, 0, 0)] * 20)" + ] + }, + { + "cell_type": "markdown", + "id": "teardown-md", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "teardown-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-17T02:44:33.616454Z", + "iopub.status.busy": "2026-05-17T02:44:33.616208Z", + "iopub.status.idle": "2026-05-17T02:44:33.621351Z", + "shell.execute_reply": "2026-05-17T02:44:33.620597Z" + } + }, + "outputs": [], + "source": [ + "await drv.set_led_color((0, 0, 0)) # ensure bar is off\n", + "await reader.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "reference", + "metadata": {}, + "source": [ + "## Reference\n", + "\n", + "- `set_led_color(color, effect, *, duration_ms=0, force=False, low_power=False)` — single uniform color, optional firmware-driven effect.\n", + "- `set_led_colors(colors)` — list of up to 20 `(r, g, b)` triplets, one per pixel. Pads with black; truncates if longer.\n", + "- Both methods live on `reader.driver`. Source: `pylabrobot/byonoy/driver.py`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/curiox/curiox-ht2000/hello-world.ipynb b/docs/user_guide/curiox/curiox-ht2000/hello-world.ipynb new file mode 100644 index 00000000000..05b7bf1f674 --- /dev/null +++ b/docs/user_guide/curiox/curiox-ht2000/hello-world.ipynb @@ -0,0 +1,160 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ht2000-intro", + "source": "# Curiox Laminar Wash HT2000\n\nThe [Curiox Laminar Wash HT2000](https://www.curiox.com/product/legacy-ht2000/) is a benchtop station that runs Curiox's Laminar Wash process on DropArray plates. It has a serial interface and runs wash and prime routines under host control. (This is a legacy system, succeeded by the HT2100.)\n\n| Property | Value |\n|---|---|\n| Communication | Serial, binary-framed ASCII payloads |\n| Serial settings | 115200 baud, 8 data bits, no parity, 1 stop bit, no handshake |\n| Frame | `FF FF FF FF FF 01` + length + payload + 16-bit checksum + `FF` |\n| Wash repeats | 1--19 |\n| Initial volume | 0--99 (device units) |\n| Flow rate | 2--20 (device units) |\n| Channel | 0--5 or 11--15 |\n\n```{warning}\nThis driver has NOT been tested against hardware in PyLabRobot. The protocol,\nframe format, and reply layouts are reconstructed from the vendor software. If\nyou verify it on an HT2000, please open a PR to remove the not-tested warning.\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "ht2000-protocol-md", + "source": "## How it talks\n\nEach command is a short ASCII payload (for example `210` for a standard wash) wrapped in a fixed binary frame: a six-byte preamble, the payload length, the payload, a 16-bit checksum, and a trailer byte. Replies are fixed length and carry a single status digit -- `0` ready, `1` busy, `2` error, `3` stopped. Every action other than the status poll and the report returns an 11-byte acknowledgement.\n\nYou do not build frames yourself; the driver methods below do it for you.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "ht2000-setup-md", + "source": "## Physical setup\n\nConnect the HT2000's serial port to your computer, typically through a USB-to-serial adapter. Make sure the port parameters match the driver defaults (115200 baud, 8 data bits, no parity, 1 stop bit, no handshake).", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "ht2000-connect-md", + "source": "## Connect\n\n`setup()` opens the serial port and polls the instrument once. If the HT2000 reports an error state it raises a `CurioxHT2000Error`. It also logs the not-tested warning.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ht2000-connect-code", + "source": "from pylabrobot.curiox import CurioxHT2000\n\nwasher = CurioxHT2000(port=\"/dev/ttyUSB0\") # replace with your port\nawait washer.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ht2000-status-md", + "source": "## Check status\n\n`ping()` returns `(status, mode, error)` for a quick health check. `enquire_report()` returns a fuller `StatusReport` including tray position and whether a plate is loaded.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ht2000-status-code", + "source": "status, mode, error = await washer.ping()\nprint(status, mode, error)\n\nreport = await washer.enquire_report()\nprint(report)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ht2000-tray-md", + "source": "## Load a plate\n\nLoading a plate is two steps with a physical action in between: move the tray out, place the plate, then move it back in. Both moves are idempotent, so they are safe to call without tracking the tray's state.\n\nFirst, move the tray out:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ht2000-tray-code", + "source": "await washer.move_tray_out()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "e3cb7517", + "source": "Place the DropArray plate on the tray, then move it back in:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "32052f54", + "source": "await washer.move_tray_in()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ht2000-prime-md", + "source": "## Prime the fluidics\n\nBefore washing, prime the fluid path. `prime()` runs a routine and waits for it to settle. Choose the routine by name: `\"standard\"`, `\"head\"`, `\"pump\"`, or `\"short\"`.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ht2000-prime-code", + "source": "await washer.prime(\"standard\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ht2000-wash-md", + "source": "## Run a wash\n\n`wash()` uploads the wash parameters and runs a cycle. It switches the instrument to operation mode if needed, applies the parameters, starts the wash, and then polls until the cycle completes (the instrument finishes washing and returns to ready). It raises if the plate is missing, the instrument stops, or the cycle does not complete within `max_operation_duration`.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ht2000-wash-code", + "source": "await washer.wash(\n wash_number=3, # number of wash repeats (1..19)\n initial_volume=50, # device units (0..99)\n flow_rate=5, # device units (2..20)\n channel=0, # 0..5 or 11..15\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ht2000-drain-md", + "source": "## Drain\n\nDrain the aspirator and the spill tray between runs.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ht2000-drain-code", + "source": "await washer.drain_aspirator()\nawait washer.drain_spill_tray()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "06958d64", + "source": "## Home\n\nRecover the home position if the instrument needs re-seating.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "9004ecad", + "source": "await washer.home()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ht2000-disconnect-md", + "source": "## Disconnect\n\n`stop()` closes the serial connection.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ht2000-disconnect-code", + "source": "await washer.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/curiox/index.md b/docs/user_guide/curiox/index.md new file mode 100644 index 00000000000..5ad2460f1c5 --- /dev/null +++ b/docs/user_guide/curiox/index.md @@ -0,0 +1,7 @@ +# Curiox + +```{toctree} +:maxdepth: 1 + +curiox-ht2000/hello-world +``` diff --git a/docs/user_guide/_getting-started/installation.md b/docs/user_guide/getting-started/installation.md similarity index 89% rename from docs/user_guide/_getting-started/installation.md rename to docs/user_guide/getting-started/installation.md index 425dfa0a767..a50bc5a638a 100644 --- a/docs/user_guide/_getting-started/installation.md +++ b/docs/user_guide/getting-started/installation.md @@ -49,9 +49,9 @@ Different machines use different communication modes. Replace `[usb]` with one o | `hid` | hid | HID devices: e.g. Inheco Incubator/Shaker (HID mode) | | `modbus` | pymodbus | Modbus devices: e.g. Agrow Pump Array | | `opentrons` | opentrons-http-api-client | e.g. Opentrons backend | -| `microscopy` | numpy (1.26), opencv-python | e.g. Cytation imager | +| `cytation-microscopy` | numpy (1.26), opencv-python | Cytation imager | | `sila` | zeroconf, grpcio | SiLA devices | -| `pico` | microscopy + sila | ImageXpress Pico microscope | +| `pico` | opencv-python, numpy, sila | ImageXpress Pico microscope | | `dev` | All of the above + testing/linting tools | Development | Or install all dependencies: @@ -60,7 +60,7 @@ Or install all dependencies: pip install 'pylabrobot[all]' ``` -Microscopy is not included in the `all` group because it requires an older version of numpy. If you want to use microscopy features, you need to install those dependencies separately through `pip install "pylabrobot[microscopy]"`. +Cytation microscopy is not included in the `all` group because it requires an older version of numpy. If you want to use Cytation imaging features, install those dependencies separately through `pip install "pylabrobot[cytation-microscopy]"`. ### From source @@ -175,8 +175,9 @@ If you are still having trouble, please reach out on [discuss.pylabrobot.org](ht In order to use imaging on the Cytation, you need to: -1. Install python 3.10 -2. Download Spinnaker SDK and install (including Python) [https://www.teledynevisionsolutions.com/products/spinnaker-sdk/](https://www.teledynevisionsolutions.com/products/spinnaker-sdk/) -3. Install numpy==1.26 (this is an older version) +1. Install the Aravis system library: + - macOS: `brew install aravis` + - Linux: `sudo apt-get install libaravis-dev gobject-introspection` +2. Install the cytation-microscopy dependencies: `pip install "pylabrobot[cytation-microscopy]"` (this pulls in PyGObject, numpy, and opencv-python) -If you just want to do plate reading, heating, shaknig, etc. you don't need to follow these specific steps. +If you just want to do plate reading, heating, shaking, etc. you don't need to follow these specific steps. diff --git a/docs/user_guide/_getting-started/rpi.md b/docs/user_guide/getting-started/rpi.md similarity index 100% rename from docs/user_guide/_getting-started/rpi.md rename to docs/user_guide/getting-started/rpi.md diff --git a/docs/user_guide/_getting-started/units.md b/docs/user_guide/getting-started/units.md similarity index 100% rename from docs/user_guide/_getting-started/units.md rename to docs/user_guide/getting-started/units.md diff --git a/docs/user_guide/hamilton/index.md b/docs/user_guide/hamilton/index.md new file mode 100644 index 00000000000..b3d980e3e2c --- /dev/null +++ b/docs/user_guide/hamilton/index.md @@ -0,0 +1,7 @@ +# Hamilton + +```{toctree} +:maxdepth: 1 + +star/index +``` diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/debug.md b/docs/user_guide/hamilton/star/debug.md similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/debug.md rename to docs/user_guide/hamilton/star/debug.md diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/adjusting-iswap-gripper-parrallelity.md b/docs/user_guide/hamilton/star/hardware/adjusting-iswap-gripper-parrallelity.md similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/adjusting-iswap-gripper-parrallelity.md rename to docs/user_guide/hamilton/star/hardware/adjusting-iswap-gripper-parrallelity.md diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/adjusting-iswap.md b/docs/user_guide/hamilton/star/hardware/adjusting-iswap.md similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/adjusting-iswap.md rename to docs/user_guide/hamilton/star/hardware/adjusting-iswap.md diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/adjusting-robot.md b/docs/user_guide/hamilton/star/hardware/adjusting-robot.md similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/adjusting-robot.md rename to docs/user_guide/hamilton/star/hardware/adjusting-robot.md diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap-gripper/before-after.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-iswap-gripper/before-after.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap-gripper/before-after.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-iswap-gripper/before-after.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap-gripper/rotate-slider-bearing.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-iswap-gripper/rotate-slider-bearing.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap-gripper/rotate-slider-bearing.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-iswap-gripper/rotate-slider-bearing.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/adjust-iswap-over-labware.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-iswap/adjust-iswap-over-labware.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/adjust-iswap-over-labware.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-iswap/adjust-iswap-over-labware.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/cylinder-tool-removed.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-iswap/cylinder-tool-removed.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/cylinder-tool-removed.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-iswap/cylinder-tool-removed.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/cylindrical-tool.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-iswap/cylindrical-tool.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/cylindrical-tool.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-iswap/cylindrical-tool.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/gripper-probe-install.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-iswap/gripper-probe-install.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/gripper-probe-install.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-iswap/gripper-probe-install.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/ground-probe.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-iswap/ground-probe.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/ground-probe.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-iswap/ground-probe.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/iswap-board-connect.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-iswap/iswap-board-connect.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/iswap-board-connect.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-iswap/iswap-board-connect.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/iswap-calibration-assembly.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-iswap/iswap-calibration-assembly.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/iswap-calibration-assembly.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-iswap/iswap-calibration-assembly.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/iswap-tool-install.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-iswap/iswap-tool-install.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/iswap-tool-install.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-iswap/iswap-tool-install.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/pin-tool.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-iswap/pin-tool.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-iswap/pin-tool.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-iswap/pin-tool.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-arm-z.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-arm-z.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-arm-z.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-arm-z.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-pip-0.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-pip-0.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-pip-0.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-pip-0.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-pip-1.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-pip-1.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-pip-1.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-pip-1.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-pip-2.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-pip-2.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-pip-2.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-pip-2.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-pip-3.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-pip-3.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-pip-3.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-pip-3.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-pip-4.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-pip-4.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-pip-4.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-pip-4.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-pip-script-0.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-pip-script-0.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-pip-script-0.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-pip-script-0.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-pip-script-1.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-pip-script-1.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/adjust-pip-script-1.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-robot/adjust-pip-script-1.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/channel-calibration-tool.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-robot/channel-calibration-tool.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/channel-calibration-tool.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-robot/channel-calibration-tool.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/check-x-arm-diff-script-0.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-robot/check-x-arm-diff-script-0.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/check-x-arm-diff-script-0.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-robot/check-x-arm-diff-script-0.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/check-x-arm-diff-script-1.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-robot/check-x-arm-diff-script-1.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/check-x-arm-diff-script-1.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-robot/check-x-arm-diff-script-1.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/check-x-arm-diff.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-robot/check-x-arm-diff.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/check-x-arm-diff.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-robot/check-x-arm-diff.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/rotate-x-arm-around-z.jpg b/docs/user_guide/hamilton/star/hardware/img/adjust-robot/rotate-x-arm-around-z.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/adjust-robot/rotate-x-arm-around-z.jpg rename to docs/user_guide/hamilton/star/hardware/img/adjust-robot/rotate-x-arm-around-z.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/replace-iswap/after-remove.jpg b/docs/user_guide/hamilton/star/hardware/img/replace-iswap/after-remove.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/replace-iswap/after-remove.jpg rename to docs/user_guide/hamilton/star/hardware/img/replace-iswap/after-remove.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/replace-iswap/ffc.jpg b/docs/user_guide/hamilton/star/hardware/img/replace-iswap/ffc.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/replace-iswap/ffc.jpg rename to docs/user_guide/hamilton/star/hardware/img/replace-iswap/ffc.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/replace-iswap/main-screws.jpg b/docs/user_guide/hamilton/star/hardware/img/replace-iswap/main-screws.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/replace-iswap/main-screws.jpg rename to docs/user_guide/hamilton/star/hardware/img/replace-iswap/main-screws.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/replace-iswap/side-screws.jpg b/docs/user_guide/hamilton/star/hardware/img/replace-iswap/side-screws.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/img/replace-iswap/side-screws.jpg rename to docs/user_guide/hamilton/star/hardware/img/replace-iswap/side-screws.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/index.md b/docs/user_guide/hamilton/star/hardware/index.md similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/index.md rename to docs/user_guide/hamilton/star/hardware/index.md diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/replacing-iswap.md b/docs/user_guide/hamilton/star/hardware/replacing-iswap.md similarity index 80% rename from docs/user_guide/00_liquid-handling/hamilton-star/hardware/replacing-iswap.md rename to docs/user_guide/hamilton/star/hardware/replacing-iswap.md index 6f900171c8c..94536a15d5e 100644 --- a/docs/user_guide/00_liquid-handling/hamilton-star/hardware/replacing-iswap.md +++ b/docs/user_guide/hamilton/star/hardware/replacing-iswap.md @@ -51,51 +51,52 @@ Note: Once physically installed on the system it is recommended that you level t This code is most easily run in a Jupyter notebook so that you can send the other commands whenever you are ready. ```python -from pylabrobot.liquid_handling import LiquidHandler, STARBackend -from pylabrobot.resources import STARDeck -star_backend = STARBackend() -lh = LiquidHandler(backend=star_backend, deck=STARDeck()) -await lh.setup() +from pylabrobot.hamilton.liquid_handlers.star import STAR + +star = STAR() +await star.setup() ``` 11. **!!While supporting the iSWAP in the Z axis!!** release the Z axis brake on the iSWAP. ```python input("Confirm the deck is clear and press Enter to continue...") -await star_backend.position_components_for_free_iswap_y_range() -await star_backend.move_iswap_y(300) -x = lh.deck.rails_to_location(deck.num_rails/2).x -await star_backend.move_iswap_x(x) +await star.pip.backend.position_components_for_free_iswap_y_range() +await star.driver.iswap.move_y(y=300) +x = star.deck.rails_to_location(star.deck.num_rails / 2).x +await star.driver.iswap.move_x(x=x) ``` hold the iSWAP arm in place while you do this, as it will fall if you don't: ```python -await star_backend.iswap_dangerous_release_break() # firmware command "R0BA" +await star.driver.iswap.dangerous_release_break() # firmware command "R0BA" ``` 12. Lower the iSWAP until the gripper fingers are just above the deck surface (2-5mm) but still above the little "submarines". 13. Reengage the Z axis brake ```python -await star_backend.iswap_reengage_break() # firmware command "R0BO" +await star.driver.iswap.reengage_break() # firmware command "R0BO" ``` 14. Orient the iSWAP with the main arm to the right with the gripper facing you and rotate the iSWAP about the X axis by adjusting the screws until the gripper fingers are equidistant from the deck. ```python -await star_backend.iswap_rotate( - rotation_drive=STARBackend.RotationDriveOrientation.RIGHT, - grip_direction=GripDirection.BACK +from pylabrobot.hamilton.liquid_handlers.star.iswap import iSWAPBackend + +await star.driver.iswap.rotate( + rotation_drive=iSWAPBackend.RotationDriveOrientation.RIGHT, + grip_direction="back", ) ``` -15. Rotate the main iSWAP are 180 degrees to the left and rotate the gripper hand around until it is facing you. Repeat the X axis rotation until the gripper fingers are equidistant from the deck. +15. Rotate the main iSWAP arm 180 degrees to the left and rotate the gripper hand around until it is facing you. Repeat the X axis rotation until the gripper fingers are equidistant from the deck. ```python -await star_backend.iswap_rotate( - rotation_drive=STARBackend.RotationDriveOrientation.LEFT, - grip_direction=GripDirection.BACK +await star.driver.iswap.rotate( + rotation_drive=iSWAPBackend.RotationDriveOrientation.LEFT, + grip_direction="back", ) ``` @@ -103,7 +104,7 @@ await star_backend.iswap_rotate( 17. Make sure there is no chance of Z axis collision and initialize the Z axis of the iSWAP. ```python -await star_backend.iswap_initialize_z_axis() # firmware command "R0ZI" +await star.driver.iswap.initialize_z_axis() # firmware command "R0ZI" ``` 18. Check/Reteach all iSWAP locations in your programs. diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/img/96head/quadrants.png b/docs/user_guide/hamilton/star/img/96head/quadrants.png similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/img/96head/quadrants.png rename to docs/user_guide/hamilton/star/img/96head/quadrants.png diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/img/autoload/hamilton_autoload_correct_1d_barcode_height.png b/docs/user_guide/hamilton/star/img/autoload/hamilton_autoload_correct_1d_barcode_height.png similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/img/autoload/hamilton_autoload_correct_1d_barcode_height.png rename to docs/user_guide/hamilton/star/img/autoload/hamilton_autoload_correct_1d_barcode_height.png diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/img/autoload/hamilton_autoload_overview.png b/docs/user_guide/hamilton/star/img/autoload/hamilton_autoload_overview.png similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/img/autoload/hamilton_autoload_overview.png rename to docs/user_guide/hamilton/star/img/autoload/hamilton_autoload_overview.png diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/img/autoload/hamilton_autoload_state_transfer.png b/docs/user_guide/hamilton/star/img/autoload/hamilton_autoload_state_transfer.png similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/img/autoload/hamilton_autoload_state_transfer.png rename to docs/user_guide/hamilton/star/img/autoload/hamilton_autoload_state_transfer.png diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/img/autoload/hamilton_star_autoload.png b/docs/user_guide/hamilton/star/img/autoload/hamilton_star_autoload.png similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/img/autoload/hamilton_star_autoload.png rename to docs/user_guide/hamilton/star/img/autoload/hamilton_star_autoload.png diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/img/core-grippers/core-gripper-types.jpg b/docs/user_guide/hamilton/star/img/core-grippers/core-gripper-types.jpg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/img/core-grippers/core-gripper-types.jpg rename to docs/user_guide/hamilton/star/img/core-grippers/core-gripper-types.jpg diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/iswap_positions.png b/docs/user_guide/hamilton/star/img/iswap_positions.png similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/iswap_positions.png rename to docs/user_guide/hamilton/star/img/iswap_positions.png diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/img/pierce_foil.gif b/docs/user_guide/hamilton/star/img/pierce_foil.gif similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/img/pierce_foil.gif rename to docs/user_guide/hamilton/star/img/pierce_foil.gif diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/img/step_off_foil.gif b/docs/user_guide/hamilton/star/img/step_off_foil.gif similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/img/step_off_foil.gif rename to docs/user_guide/hamilton/star/img/step_off_foil.gif diff --git a/docs/user_guide/00_liquid-handling/hamilton-star/img/surface_following/surface_following_distance.svg b/docs/user_guide/hamilton/star/img/surface_following/surface_following_distance.svg similarity index 100% rename from docs/user_guide/00_liquid-handling/hamilton-star/img/surface_following/surface_following_distance.svg rename to docs/user_guide/hamilton/star/img/surface_following/surface_following_distance.svg diff --git a/docs/user_guide/hamilton/star/index.md b/docs/user_guide/hamilton/star/index.md new file mode 100644 index 00000000000..9e85375a1b6 --- /dev/null +++ b/docs/user_guide/hamilton/star/index.md @@ -0,0 +1,8 @@ +# Hamilton STAR + +```{toctree} +:maxdepth: 1 + +debug +hardware/index +``` diff --git a/docs/user_guide/high_res/index.md b/docs/user_guide/high_res/index.md new file mode 100644 index 00000000000..c2d180c7347 --- /dev/null +++ b/docs/user_guide/high_res/index.md @@ -0,0 +1,7 @@ +# HighRes + +```{toctree} +:maxdepth: 1 + +lid-valet/hello-world +``` diff --git a/docs/user_guide/high_res/lid-valet/hello-world.ipynb b/docs/user_guide/high_res/lid-valet/hello-world.ipynb new file mode 100644 index 00000000000..0b3c83e1aba --- /dev/null +++ b/docs/user_guide/high_res/lid-valet/hello-world.ipynb @@ -0,0 +1,291 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# HighRes LidValet\n", + "\n", + "The HighRes Biosolutions **LidValet** is a benchtop delidder. Each of its nests (the\n", + "controller calls them *hotels*) holds a vacuum suction cup above a plate position: a mover\n", + "presents a lidded plate to a nest, the LidValet lifts the lid off into the cup and holds it\n", + "there, and later lowers it back onto the plate.\n", + "\n", + "| | |\n", + "|---|---|\n", + "| Communication | TCP socket |\n", + "| Default address | `192.168.127.60:1000` (printed on the back of the device) |\n", + "| Protocol | newline-terminated ASCII, `ACK!` / `OK!` / `ERROR!` |\n", + "| Nests | read from the device (`ACTIVE_HOTELS`) |\n", + "| Nest states | `open`, `has_lid`, `busy`, `error`, `unknown` |\n", + "\n", + "[OEM link](https://www.highres.com/lab-instruments/lid-management)" + ] + }, + { + "cell_type": "markdown", + "id": "physical", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "The LidValet is a network device: connect it to the host with an Ethernet cable, either\n", + "directly or through a switch. It does **not** run a DHCP client, so give the host an address\n", + "on the device's subnet, for example:\n", + "\n", + "```\n", + "sudo ip addr add 192.168.127.1/24 dev \n", + "```\n", + "\n", + "The address on the label is the one to talk to. A unit that has been sitting powered but\n", + "idle can hold link while answering nothing at all \u2014 if it is silent, power-cycle it and it\n", + "will announce itself on boot." + ] + }, + { + "cell_type": "markdown", + "id": "connect", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "`setup()` opens the socket, reads the nest count off the device, refuses to start if a lid\n", + "is stuck in a suction cup, and resets every nest. Pass `host` and `port` for your machine \u2014\n", + "the nest count is not something you configure, it comes from the device itself." + ] + }, + { + "cell_type": "code", + "id": "connect-code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.high_res import HighResLidValet\n", + "\n", + "valet = HighResLidValet(host=\"192.168.127.60\", port=1000)\n", + "await valet.setup()\n", + "\n", + "print(valet.num_nests)" + ] + }, + { + "cell_type": "markdown", + "id": "status", + "metadata": {}, + "source": [ + "## Nest state\n", + "\n", + "`request_state()` returns one nest's state; `request_all_states()` returns every nest in a\n", + "single round trip. `request_has_lid()` and `request_is_busy()` are convenience wrappers.\n", + "\n", + "A nest reports `error` when its last operation failed \u2014 it stays that way until the nest is\n", + "reset." + ] + }, + { + "cell_type": "code", + "id": "status-code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(await valet.request_state(1))\n", + "print(await valet.request_all_states())" + ] + }, + { + "cell_type": "markdown", + "id": "delid", + "metadata": {}, + "source": [ + "## Delid a plate\n", + "\n", + "With a lidded plate on a nest, `delid()` lifts the lid into that nest's suction cup. The cup\n", + "must be empty; the call raises if it is already holding a lid or if the nest is busy." + ] + }, + { + "cell_type": "code", + "id": "delid-code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await valet.delid(nest=1)" + ] + }, + { + "cell_type": "markdown", + "id": "lid", + "metadata": {}, + "source": [ + "## Re-lid the plate\n", + "\n", + "`lid()` lowers the held lid back onto the plate. The cup must be holding a lid. It retries a\n", + "few times to ride out a transient busy state, matching the device server's own behaviour." + ] + }, + { + "cell_type": "code", + "id": "lid-code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await valet.lid(nest=1)" + ] + }, + { + "cell_type": "markdown", + "id": "reset", + "metadata": {}, + "source": [ + "## Reset\n", + "\n", + "`reset()` homes a nest and clears its error state. Called with no argument it resets every\n", + "nest in one command, which is also what `setup()` does." + ] + }, + { + "cell_type": "code", + "id": "reset-code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await valet.reset(nest=1)\n", + "await valet.reset()" + ] + }, + { + "cell_type": "markdown", + "id": "motion", + "metadata": {}, + "source": [ + "## Homing and lift motion\n", + "\n", + "`home()` homes the whole system. `wave()` drops and raises the lifts a given number of times\n", + "\u2014 useful for confirming the machine is alive and that the pneumatics are connected. Both\n", + "block until the motion is finished." + ] + }, + { + "cell_type": "code", + "id": "motion-code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await valet.home()\n", + "await valet.wave(cycles=3)" + ] + }, + { + "cell_type": "markdown", + "id": "pneumatics", + "metadata": {}, + "source": [ + "## Vacuum and purge\n", + "\n", + "The suction cups are driven by solenoid outputs, which `delid()` and `lid()` operate for\n", + "you. `set_vacuum()` and `set_purge()` drive them directly, which is mainly useful when\n", + "diagnosing a cup that will not pick up.\n", + "\n", + "These return immediately: the controller switches the output and does not wait, and on a\n", + "machine with no vacuum sensor fitted it cannot confirm suction was achieved." + ] + }, + { + "cell_type": "code", + "id": "pneumatics-code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await valet.set_vacuum(nest=1, on=True)\n", + "await valet.set_vacuum(nest=1, on=False)" + ] + }, + { + "cell_type": "markdown", + "id": "diagnostics", + "metadata": {}, + "source": [ + "## Diagnostics\n", + "\n", + "The controller keeps an error stack and a command history, and reports its own versions.\n", + "When something fails, `request_errors()` is the first place to look \u2014 the device's own message\n", + "is more specific than the exception text." + ] + }, + { + "cell_type": "code", + "id": "diagnostics-code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(await valet.request_version())\n", + "print(await valet.request_errors(5))" + ] + }, + { + "cell_type": "markdown", + "id": "settings", + "metadata": {}, + "source": [ + "## Settings\n", + "\n", + "The device exposes its full calibration and configuration. `request_settings()` returns it as a\n", + "typed, frozen object, one attribute per device setting." + ] + }, + { + "cell_type": "code", + "id": "settings-code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "settings = await valet.request_settings()\n", + "print(settings.serial_number, settings.active_hotels)" + ] + }, + { + "cell_type": "markdown", + "id": "teardown", + "metadata": {}, + "source": [ + "## Disconnect\n", + "\n", + "`stop()` asks the server to drop this client and then closes the socket, so the controller\n", + "frees the connection slot rather than waiting for it to time out." + ] + }, + { + "cell_type": "code", + "id": "teardown-code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await valet.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index 8eb3b01e98f..ac1ec0aec45 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -7,12 +7,11 @@ :caption: Getting started :hidden: -_getting-started/installation -How PLR Works <_getting-started/plr-architecture> -_getting-started/rpi +getting-started/installation +getting-started/rpi +getting-started/units ``` - ```{toctree} :maxdepth: 1 :caption: Machines @@ -21,8 +20,30 @@ _getting-started/rpi machines definitions 00_liquid-handling/_liquid-handling -01_material-handling/_material-handling -02_analytical/_analytical +``` + +```{toctree} +:maxdepth: 1 +:caption: Manufacturers +:hidden: + +agilent/index +azenta/index +big_bear/index +brooks/index +byonoy/index +curiox/index +hamilton/index +high_res/index +inheco/index +kbioscience/index +kbiosystems/index +mettler_toledo/index +molecular_devices/index +qinstruments/index +sartorius/index +thermo_fisher/index +ufactory/index ``` ```{toctree} @@ -58,95 +79,4 @@ The guide is divided into three parts: - **Machine-Agnostic Features** – Discover powerful tools that work across all devices, like the visualizer, trackers, reusable protocol patterns, validation tools, and error handling. -Check out [the list of supported machines](/user_guide/machines). - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Machines
├── Liquid Handling
│ ├── Pipetting Robots
│ ├── Plate Washers
│ └── Reagent Dispensers
├── ⚙️ Material Handling
│ ├── Transport Systems
│ │ ├── Conveyors
│ │ ├── Robotic Arms
│ │ └── Smart Storage (e.g. carousels)
│ ├── Consumable Manipulation
│ │ ├── Cappers & Decappers
│ │ └── Sealers & Peelers
│ └── Identification
│ └── Barcode Labellers And Readers
├── Environmental Control
│ ├── Temperature And Motion Control
│ │ ├── Automated Freezers/Fridges
│ │ ├── Automated Incubators
│ │ ├── Heated Cooled Blocks
│ │ ├── Incubated Shakers
│ │ ├── Shakers
│ │ ├── Thermal Shakers
│ │ └── Thermocyclers
│ └── Airflow & Contamination Control
│ ├── Air Circulation Fans
│ ├── HEPA Filtration Modules
│ └── UV-C Decontamination Units
├── Sample Preparation And Processing
│ ├── Automated Centrifuges
│ ├── Chromatography Systems
│ ├── Colony Pickers
│ └── Tissue Homogenizers
└── 🔬 Analytical And Detection
├── Balances / Scales
├── Optical Detection
│ ├── Automated Microscopes
│ ├── Flow Cytometers
│ ├── Plate Readers
│ └── Spectrophotometers
└── pH Meters
- -
+Check out [the list of supported machines](/user_guide/machines.md). diff --git a/docs/user_guide/inheco/cpac/hello-world.ipynb b/docs/user_guide/inheco/cpac/hello-world.ipynb new file mode 100644 index 00000000000..7012ec83a3c --- /dev/null +++ b/docs/user_guide/inheco/cpac/hello-world.ipynb @@ -0,0 +1,127 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Inheco CPAC\n", + "\n", + "The Inheco CPAC (Cold Plate Air Cooled) is a Peltier-based temperature controller for microplates. It supports heating and active cooling.\n", + "\n", + "| Variant | PLR Name | Part Numbers | Temperature Range |\n", + "|---|---|---|---|\n", + "| CPAC Ultraflat | `inheco_cpac_ultraflat` | 7000166, 7000190, 7000165 | +4 to +70 °C |\n", + "| CPAC Ultraflat HT 2TEC | -- | 7000166, 7000165 | +4 to +110 °C |\n", + "| CPAC Microplate | -- | 7000179 | +4 to +70 °C |\n", + "| CPAC Microplate HT 2TEC | -- | 7000163 | +4 to +110 °C |\n", + "\n", + "The CPAC connects to an Inheco TEC Control Box (STC or MTC) via cable. The control box connects to the computer via USB-B. An MTC can control up to 6 devices in parallel.\n", + "\n", + "See the [CPAC manual](https://www.inheco.com/data/pdf/cpac-manual-1019-0826-30.pdf) for hardware setup." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.inheco import InhecoTECControlBox, inheco_cpac_ultraflat\n", + "\n", + "control_box = InhecoTECControlBox()\n", + "await control_box.setup()\n", + "\n", + "cpac = inheco_cpac_ultraflat(name=\"cpac\", control_box=control_box, index=1)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Temperature control" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await cpac.set_temperature(37.0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "current = await cpac.request_current_temperature()\n", + "print(f\"{current:.1f} \\u00b0C\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await cpac.stop_temperature_control()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Multiple devices\n", + "\n", + "With an MTC control box, use different `index` values (1--6) to address multiple CPACs:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "cpac2 = inheco_cpac_ultraflat(name=\"cpac2\", control_box=control_box, index=2)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await control_box.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/user_guide/inheco/incubator_shaker/hello-world.ipynb b/docs/user_guide/inheco/incubator_shaker/hello-world.ipynb new file mode 100644 index 00000000000..fa08b5f1725 --- /dev/null +++ b/docs/user_guide/inheco/incubator_shaker/hello-world.ipynb @@ -0,0 +1,340 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Inheco Incubator Shaker\n", + "\n", + "The Inheco Incubator Shaker is a family of enclosed incubator devices with optional shaking, used for plate storage, temperature control, and orbital mixing.\n", + "\n", + "Multiple units can be stacked (up to 5 \"power credits\" per stack) and controlled through a single USB connection. All 4 variants share the same serial firmware and a single backend covers the entire family.\n", + "\n", + "| Model | PLR Name | Shaking | Plate Format | Power Credits |\n", + "|---|---|---|---|---|\n", + "| Incubator MP | `incubator_mp` | no | Microplate | 1.0 |\n", + "| Incubator Shaker MP | `incubator_shaker_mp` | yes | Microplate | 1.6 |\n", + "| Incubator DWP | `incubator_dwp` | no | Deepwell Plate | 1.25 |\n", + "| Incubator Shaker DWP | `incubator_shaker_dwp` | yes | Deepwell Plate | 2.5 |\n", + "\n", + "See the [Inheco Incubator Shaker product page](https://www.inheco.com/incubator-shaker.html) for hardware specifications. Connect via USB-A/B (VID:PID `0403:6001`). Each stack requires one USB cable and one power cable to the bottom-most unit; units above connect to the unit below via 15-pin SUB-D connectors." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Set the DIP switch on the back of the bottom-most unit to a unique 4-bit address (0--15). This address identifies the stack." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.legacy.storage.inheco import (\n", + " IncubatorShakerStack,\n", + " InhecoIncubatorShakerStackBackend,\n", + ")\n", + "\n", + "backend = InhecoIncubatorShakerStackBackend(dip_switch_id=2)\n", + "stack = IncubatorShakerStack(backend=backend)\n", + "await stack.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The stack auto-discovers all connected units during `setup()`. Pass `verbose=True` to see serial port, DIP switch verification, and unit composition." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Addressing units\n", + "\n", + "Access individual units by index. The stack reports how many units are connected:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "stack.num_units # e.g. 2" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "unit_0 = stack[0]\n", + "unit_1 = stack[1]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Loading tray\n", + "\n", + "Each unit has a motorized loading tray (drawer) for plate access:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await stack[0].open()\n", + "# ... load or unload plate ...\n", + "await stack[0].close()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Check plate presence (reflection-based sensor)\n", + "await stack[0].request_plate_in_incubator() # returns True/False" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Temperature control" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await stack[0].start_temperature_control(37)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "current = await stack[0].get_temperature()\n", + "print(f\"{current:.1f} °C\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The device has three independent temperature sensors (`\"main\"`, `\"dif\"`, `\"boost\"`). You can also read a geometric `\"mean\"` of all three:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await stack[0].get_temperature(sensor=\"mean\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Block until temperature is reached\n", + "await stack[0].wait_for_temperature(sensor=\"mean\", tolerance=0.2)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await stack[0].stop_temperature_control()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Shaking\n", + "\n", + "Shaker models (MP Shaker and DWP Shaker) support programmable shaking patterns." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Orbital shaking at 800 rpm (default pattern)\n", + "await stack[0].shake(rpm=800)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await stack[0].stop_shaking()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Predefined shaking patterns:\n", + "\n", + "| Pattern | Description |\n", + "|---|---|\n", + "| `orbital` | Circular shaking (default) |\n", + "| `elliptical` | Elliptical motion |\n", + "| `figure_eight` | Figure-eight (Lissajous) motion |\n", + "| `linear_x` | Linear motion along X |\n", + "| `linear_y` | Linear motion along Y |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await stack[0].shake(pattern=\"figure_eight\", rpm=400)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await stack[0].stop_shaking()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Stack-level commands\n", + "\n", + "The stack frontend provides master commands that apply to all units at once:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Open/close all loading trays\n", + "await stack.open_all()\n", + "await stack.close_all()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Temperature control for all units\n", + "await stack.start_all_temperature_control(target_temperature=37)\n", + "await stack.get_all_temperatures() # {0: 37.1, 1: 36.8}\n", + "await stack.stop_all_temperature_control()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Query states\n", + "await stack.request_loading_tray_states() # {0: 'closed', 1: 'closed'}\n", + "await stack.request_temperature_control_states() # {0: False, 1: False}\n", + "await stack.request_shaking_states() # {0: False, 1: False}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Multiple stacks\n", + "\n", + "When using more than one physical stack, pass the serial port explicitly to avoid ambiguity:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "backend_a = InhecoIncubatorShakerStackBackend(dip_switch_id=2, port=\"/dev/cu.usbserial-130\")\n", + "stack_a = IncubatorShakerStack(backend=backend_a)\n", + "await stack_a.setup()\n", + "\n", + "backend_b = InhecoIncubatorShakerStackBackend(dip_switch_id=7, port=\"/dev/cu.usbserial-42\")\n", + "stack_b = IncubatorShakerStack(backend=backend_b)\n", + "await stack_b.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await stack.stop()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This stops all temperature control and shaking before disconnecting from the stack." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/user_guide/inheco/index.md b/docs/user_guide/inheco/index.md new file mode 100644 index 00000000000..a1058509700 --- /dev/null +++ b/docs/user_guide/inheco/index.md @@ -0,0 +1,11 @@ +# Inheco + +```{toctree} +:maxdepth: 1 + +cpac/hello-world +incubator_shaker/hello-world +odtc/hello-world +scila/hello-world +thermoshake/hello-world +``` diff --git a/docs/user_guide/inheco/odtc/hello-world.ipynb b/docs/user_guide/inheco/odtc/hello-world.ipynb new file mode 100644 index 00000000000..31f61bcf55e --- /dev/null +++ b/docs/user_guide/inheco/odtc/hello-world.ipynb @@ -0,0 +1,36 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "fa0retc47os", + "metadata": {}, + "source": [ + "# Inheco ODTC\n", + "\n", + "The Inheco ODTC (On Deck Thermal Cycler) is a thermocycler designed for automated PCR workflows. It features:\n", + "\n", + "- Precise temperature control (4 -- 99 °C, up to 4.4 °C/s ramp rate)\n", + "- Heated lid to prevent condensation\n", + "- Motorized door for automated plate handling\n", + "- 96-well plate format\n", + "\n", + "The ODTC communicates over Ethernet using the SiLA 2 protocol. You will need the IP address of the device and network connectivity between your computer and the ODTC.\n", + "\n", + "See the [Inheco ODTC product page](https://www.inheco.com/odtc.html) for hardware details." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/inheco/scila/hello-world.ipynb b/docs/user_guide/inheco/scila/hello-world.ipynb new file mode 100644 index 00000000000..d101a8c1c16 --- /dev/null +++ b/docs/user_guide/inheco/scila/hello-world.ipynb @@ -0,0 +1,264 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "t0q0pzftygm", + "metadata": {}, + "source": [ + "# Inheco SCILA\n", + "\n", + "The SCILA is an automated CO₂-controlled incubator from Inheco with 4 independently accessible drawers for SBS-format plates. It communicates over Ethernet using the SiLA 2 protocol.\n", + "\n", + "| Summary | Image |\n", + "|------------|--------|\n", + "|
  • OEM Link
  • Communication: SiLA 2 (SOAP/HTTP) over Ethernet
  • 4 independent drawers for SBS-format plates
  • Temperature control (single zone, all drawers)
  • CO₂ and H₂O valve monitoring
  • Humidification reservoir level monitoring
  • Only one drawer can be open at a time
|
![scila](img/inheco_scila.png)
Inheco SCILA
|" + ] + }, + { + "cell_type": "markdown", + "id": "qnrt30ol6yc", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "The SCILA communicates over Ethernet using the SiLA 2 protocol. To connect, you need:\n", + "1. The IP address of the SCILA on your network.\n", + "2. (Optional) The IP address of your client machine -- auto-detected if omitted.\n", + "3. (Optional) `gas_mixer_connected=False` if you are operating the SCILA without an external CO₂ gas mixer attached. This silences the non-fatal CO₂-flow warning that the device emits during drawer open/close in that configuration. Defaults to `True`.\n", + "\n", + "The backend starts a local HTTP server to receive asynchronous responses from the SCILA.\n", + "\n", + "If you don't know the SCILA's IP, the bundled SiLA discovery tool will find any SiLA 1 device on the local subnet:\n", + "\n", + "```bash\n", + "python -m pylabrobot.io.sila.discovery --interface \n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "z5fcugljbwk", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.inheco.scila import SCILA\n", + "\n", + "scila = SCILA(name=\"scila\", scila_ip=\"169.254.1.117\") # replace with your IP\n", + "await scila.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "b5tlrcjqnaw", + "metadata": {}, + "source": [ + "## Status Requests\n", + "\n", + "Query the overall device status (`\"idle\"`, `\"standBy\"`, `\"inError\"`, `\"startup\"`, ...):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41szb6vodyd", + "metadata": {}, + "outputs": [], + "source": [ + "await scila.request_status()" + ] + }, + { + "cell_type": "markdown", + "id": "mubpx1eq1gb", + "metadata": {}, + "source": [ + "Water level in the built-in humidification reservoir (e.g. `\"High\"`, `\"Low\"`, `\"Empty\"`):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mropytllj29", + "metadata": {}, + "outputs": [], + "source": [ + "await scila.request_liquid_level()" + ] + }, + { + "cell_type": "markdown", + "id": "ssaxq21iau", + "metadata": {}, + "source": [ + "Drawer status for all 4 drawers, or a single drawer:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1c3ph2ocvsy", + "metadata": {}, + "outputs": [], + "source": [ + "await scila.request_drawer_statuses()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ku94ko4ros", + "metadata": {}, + "outputs": [], + "source": [ + "await scila.request_drawer_status(1)" + ] + }, + { + "cell_type": "markdown", + "id": "p7i6hqwkt1", + "metadata": {}, + "source": [ + "CO₂ and H₂O valve status:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "akbcybg86xv", + "metadata": {}, + "outputs": [], + "source": [ + "await scila.request_valve_status()" + ] + }, + { + "cell_type": "markdown", + "id": "g6s5bqaxtwc", + "metadata": {}, + "source": [ + "CO₂ flow status:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sqo8u77mw5c", + "metadata": {}, + "outputs": [], + "source": [ + "await scila.request_co2_flow_status()" + ] + }, + { + "cell_type": "markdown", + "id": "j4158a7ie6l", + "metadata": {}, + "source": [ + "## Drawer Control\n", + "\n", + "Only one drawer can be open at a time. Opening a second drawer while one is already open will raise an error." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7k64plxbq", + "metadata": {}, + "outputs": [], + "source": [ + "await scila.drawers[2].open()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2z6ayudjc48", + "metadata": {}, + "outputs": [], + "source": [ + "await scila.drawers[2].close()" + ] + }, + { + "cell_type": "markdown", + "id": "eb1yorphqlv", + "metadata": {}, + "source": [ + "## Temperature Control\n", + "\n", + "The SCILA has a single temperature zone shared across all 4 drawers." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "o3x6m4pn4k", + "metadata": {}, + "outputs": [], + "source": [ + "current = await scila.request_current_temperature()\n", + "print(f\"{current:.1f} °C\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6wx6jnosfzv", + "metadata": {}, + "outputs": [], + "source": [ + "await scila.set_temperature(37.0)" + ] + }, + { + "cell_type": "markdown", + "id": "4j9wjfu9t2e", + "metadata": {}, + "source": [ + "Stop temperature control:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "wcmkz8nnt7b", + "metadata": {}, + "outputs": [], + "source": [ + "await scila.deactivate()" + ] + }, + { + "cell_type": "markdown", + "id": "ryrjtlm67v", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ebejgue4wyn", + "metadata": {}, + "outputs": [], + "source": [ + "await scila.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/01_material-handling/storage/inheco/img/inheco_scila.png b/docs/user_guide/inheco/scila/img/inheco_scila.png similarity index 100% rename from docs/user_guide/01_material-handling/storage/inheco/img/inheco_scila.png rename to docs/user_guide/inheco/scila/img/inheco_scila.png diff --git a/docs/user_guide/inheco/thermoshake/hello-world.ipynb b/docs/user_guide/inheco/thermoshake/hello-world.ipynb new file mode 100644 index 00000000000..c7bfca7d827 --- /dev/null +++ b/docs/user_guide/inheco/thermoshake/hello-world.ipynb @@ -0,0 +1,107 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9tc0hvg3nb", + "metadata": {}, + "source": [ + "# Inheco ThermoShake\n", + "\n", + "The Inheco ThermoShake is a combined heater-cooler-shaker.\n", + "\n", + "All variants share the same serial firmware and are controlled through an Inheco TEC Control Box via HID.\n", + "\n", + "| Model | PLR Name | Cat. No. | Shaking (rpm) | Footprint (mm) | Status |\n", + "|---|---|---|---|---|---|\n", + "| ThermoShake RM | `inheco_thermoshake_rm` | 7100144 | 100--2000 | 147 x 104 x 116 | PLR-tested |\n", + "| ThermoShake | `inheco_thermoshake` | 7100146 | 100--2000 | 147 x 104 x 118 | PLR-untested |\n", + "| ThermoShake AC | `inheco_thermoshake_ac` | 7100160/61 | 300--3000 | 147 x 104 x 115.9 | PLR-untested |\n", + "\n", + "See the [ThermoShake user manual](https://www.inheco.com/data/pdf/thermoshake-manual-1013-1049-33.pdf) for hardware setup. Connect the ThermoShake to an Inheco TEC Control Box via HID (install `pip install pylabrobot[hid]`)." + ] + }, + { + "cell_type": "markdown", + "id": "iyjhvd0ewmi", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "jdb9stxlizk", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.inheco import InhecoTECControlBox, inheco_thermoshake\n", + "\n", + "control_box = InhecoTECControlBox()\n", + "await control_box.setup()\n", + "\n", + "ts = inheco_thermoshake(name=\"ts\", control_box=control_box, index=1)" + ] + }, + { + "cell_type": "markdown", + "id": "zftm4fgc8uj", + "metadata": {}, + "source": [ + "The `index` parameter selects which slot on the TEC Control Box the ThermoShake is connected to (1--8). If you have multiple Inheco devices on the same control box, give each a unique index." + ] + }, + { + "cell_type": "markdown", + "id": "d09ekquz3y7", + "metadata": {}, + "source": [ + "## Multiple devices\n", + "\n", + "When using multiple Inheco devices on one control box, create each with a unique `index`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3wnpwaknynj", + "metadata": {}, + "outputs": [], + "source": [ + "ts1 = inheco_thermoshake(name=\"ts1\", control_box=control_box, index=1)\n", + "ts2 = inheco_thermoshake(name=\"ts2\", control_box=control_box, index=2)" + ] + }, + { + "cell_type": "markdown", + "id": "vvjbyllpl7", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18p0yuw8kd3", + "metadata": {}, + "outputs": [], + "source": [ + "await control_box.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/kbioscience/index.md b/docs/user_guide/kbioscience/index.md new file mode 100644 index 00000000000..670898460a2 --- /dev/null +++ b/docs/user_guide/kbioscience/index.md @@ -0,0 +1,7 @@ +# KBioscience + +```{toctree} +:maxdepth: 1 + +kube/hello-world +``` diff --git a/docs/user_guide/kbioscience/kube/hello-world.ipynb b/docs/user_guide/kbioscience/kube/hello-world.ipynb new file mode 100644 index 00000000000..a645d9984be --- /dev/null +++ b/docs/user_guide/kbioscience/kube/hello-world.ipynb @@ -0,0 +1,113 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "kube-intro", + "source": "# KBioscience KUBE\n\nThe KUBE is a thermal plate sealer for microplates from KBioscience, controlled over an RS-232 serial interface.\n\n| Property | Value |\n|---|---|\n| Communication | Serial / RS-232, ASCII command protocol |\n| Serial settings | 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake |\n| Terminator | CR (`\\r`) |\n\n```{warning}\nThis driver has NOT been tested against hardware in PyLabRobot. If you verify\nit on a sealer, please open a PR to remove the not-tested warning.\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "kube-setup-md", + "source": "## Physical setup\n\nConnect the sealer's RS-232 port to your computer, typically through a USB-to-serial adapter. Make sure the sealer's serial parameters match the driver defaults (9600 baud, 8 data bits, no parity, 1 stop bit, no handshake). A unit running the older ALPS300 protocol is not supported and is rejected at setup.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "kube-connect-md", + "source": "## Connect\n\n`setup()` opens the serial port, waits for the device to be ready, reads the firmware version and product name, sets the plate orientation, and pre-heats to the preheating temperature. It also logs the not-tested warning.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "kube-connect-code", + "source": "from pylabrobot.kbioscience import KBioscienceKUBE\n\nsealer = KBioscienceKUBE(port=\"/dev/ttyUSB0\") # replace with your port\nawait sealer.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "kube-seal-md", + "source": "## Seal a plate\n\n`seal()` applies the time and temperature, waits for the heater to reach the setpoint, seals the plate, then returns to the idle temperature. Sealing time is 0.5–9.9 s; temperature is 5–199 °C.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "kube-seal-code", + "source": "await sealer.seal(temperature=170, duration=2.5)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "kube-preset-md", + "source": "## Seal with a stored preset\n\nInstead of a user-defined time and temperature, seal with one of the six presets stored on the instrument.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "kube-preset-code", + "source": "from pylabrobot.kbioscience import SealPreset\n\nawait sealer.seal(preset=SealPreset.ONE)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "kube-temp-md", + "source": "## Temperature\n\nSet the sealing temperature setpoint and read the current heater temperature.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "kube-temp-code", + "source": "await sealer.set_temperature(160)\nprint(\"Current temperature:\", await sealer.request_temperature(), \"C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "kube-status-md", + "source": "## Status\n\nRead the status byte as a set of flags.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "kube-status-code", + "source": "print(\"Status:\", await sealer.request_status())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "kube-teardown-md", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "kube-teardown-code", + "source": "await sealer.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/kbiosystems/index.md b/docs/user_guide/kbiosystems/index.md new file mode 100644 index 00000000000..328a9c58abb --- /dev/null +++ b/docs/user_guide/kbiosystems/index.md @@ -0,0 +1,9 @@ +# KBiosystems + +```{toctree} +:maxdepth: 1 + +ultraseal-epro/hello-world +ultraseal-pro/hello-world +ultraseal-xt-pro/hello-world +``` diff --git a/docs/user_guide/kbiosystems/ultraseal-epro/hello-world.ipynb b/docs/user_guide/kbiosystems/ultraseal-epro/hello-world.ipynb new file mode 100644 index 00000000000..4c9ef993019 --- /dev/null +++ b/docs/user_guide/kbiosystems/ultraseal-epro/hello-world.ipynb @@ -0,0 +1,99 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ultraseal-epro-intro", + "source": "# KBiosystems Ultraseal ePRO\n\nThe Ultraseal ePRO (formerly the eSeal) is a heat sealer for microplates from KBiosystems, controlled over an RS-232 serial interface.\n\n| Property | Value |\n|---|---|\n| [OEM link](https://www.kbiosystems.com/product/ultraseal-epro) | |\n| Communication | Serial / RS-232, ASCII command protocol |\n| Serial settings | 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake |\n| Terminator | CR (`\\r`) |\n\n```{warning}\nThis driver has NOT been tested against hardware in PyLabRobot. If you verify\nit on a sealer, please open a PR to remove the not-tested warning.\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "ultraseal-epro-setup-md", + "source": "## Physical setup\n\nConnect the sealer's RS-232 port to your computer, typically through a USB-to-serial adapter. Make sure the sealer's serial parameters match the driver defaults (9600 baud, 8 data bits, no parity, 1 stop bit, no handshake).", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "ultraseal-epro-connect-md", + "source": "## Connect\n\n`setup()` opens the serial port, waits for the device to be ready, reads the firmware version, and pre-heats to the preheating temperature. It also logs the not-tested warning.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-epro-connect-code", + "source": "from pylabrobot.kbiosystems import KBiosystemsUltrasealEPRO\n\nsealer = KBiosystemsUltrasealEPRO(port=\"/dev/ttyUSB0\") # replace with your port\nawait sealer.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-epro-seal-md", + "source": "## Seal a plate\n\n`seal()` applies the time and temperature, waits for the heater to reach the setpoint, seals the plate, then returns to the idle temperature. Sealing time is 0.5–9.9 s; temperature is 5–199 °C.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-epro-seal-code", + "source": "await sealer.seal(temperature=170, duration=2.5)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-epro-temp-md", + "source": "## Temperature\n\nSet the sealing temperature setpoint and read the current heater temperature.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-epro-temp-code", + "source": "await sealer.set_temperature(160)\nprint(\"Current temperature:\", await sealer.request_temperature(), \"C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-epro-status-md", + "source": "## Status\n\nRead the status byte as a set of flags.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-epro-status-code", + "source": "print(\"Status:\", await sealer.request_status())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-epro-teardown-md", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-epro-teardown-code", + "source": "await sealer.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/kbiosystems/ultraseal-pro/hello-world.ipynb b/docs/user_guide/kbiosystems/ultraseal-pro/hello-world.ipynb new file mode 100644 index 00000000000..16e72dca893 --- /dev/null +++ b/docs/user_guide/kbiosystems/ultraseal-pro/hello-world.ipynb @@ -0,0 +1,98 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ultraseal-pro-intro", + "source": "# KBiosystems Ultraseal PRO (formerly WASP)\n\nThe Ultraseal PRO - formerly sold as the WASP - is an inline, pneumatically driven heat sealer for microplates from KBiosystems, controlled over an RS-232 serial interface. A plate is placed on a shuttle that extends from the front of the unit; the seal command draws the shuttle in, seals, and returns it automatically. The Ultraseal PRO advances and cuts its own film internally and has no foil-length, force, or distance parameters. It requires a compressed air supply.\n\n| Property | Value |\n|---|---|\n| [OEM link](https://kbiosystems.com/product/ultraseal-pro) (formerly the WASP) | |\n| Communication | Serial / RS-232, ASCII command protocol |\n| Serial settings | 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake |\n| Terminator | CR (`\\r`) |\n| Temperature | 25-200 °C |\n| Sealing time | 0.5-9.9 s |", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "ultraseal-pro-setup-md", + "source": "## Physical setup\n\nConnect the sealer's RS-232 port to your computer, typically through a USB-to-serial adapter, and connect the compressed air supply. Make sure the sealer's serial parameters match the driver defaults (9600 baud, 8 data bits, no parity, 1 stop bit, no handshake).", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "ultraseal-pro-connect-md", + "source": "## Connect\n\n`setup()` opens the serial port, waits for the device to be ready, and pre-heats to the preheating temperature.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-pro-connect-code", + "source": "from pylabrobot.kbiosystems import KBiosystemsUltrasealPRO\n\nsealer = KBiosystemsUltrasealPRO(port=\"/dev/ttyUSB0\") # replace with your port\nawait sealer.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-pro-seal-md", + "source": "## Seal a plate\n\nPlace a plate on the extended shuttle. `seal()` applies the time and temperature, waits for the heater to reach the setpoint, then draws the shuttle in, seals the plate, and returns the shuttle to the start position automatically before returning to the idle temperature. Sealing time is 0.5-9.9 s; temperature is 25-200 °C.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-pro-seal-code", + "source": "await sealer.seal(temperature=170, duration=2.5)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-pro-temp-md", + "source": "## Temperature\n\nSet the sealing temperature setpoint and read the current heater temperature.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-pro-temp-code", + "source": "await sealer.set_temperature(160)\nprint(\"Current temperature:\", await sealer.request_temperature(), \"C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-pro-status-md", + "source": "## Status\n\nRead the status byte as a set of flags. On the Ultraseal PRO this includes `LowAir`, which indicates the compressed air supply is insufficient, and `ParkMode`, which indicates the shuttle is parked inside.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-pro-status-code", + "source": "print(\"Status:\", await sealer.request_status())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-pro-teardown-md", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-pro-teardown-code", + "source": "await sealer.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/kbiosystems/ultraseal-xt-pro/hello-world.ipynb b/docs/user_guide/kbiosystems/ultraseal-xt-pro/hello-world.ipynb new file mode 100644 index 00000000000..7f22ec09e54 --- /dev/null +++ b/docs/user_guide/kbiosystems/ultraseal-xt-pro/hello-world.ipynb @@ -0,0 +1,112 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ultraseal-xt-pro-intro", + "source": "# KBiosystems Ultraseal XT Pro\n\nThe Ultraseal XT Pro is an inline, pneumatically driven heat sealer for microplates from KBiosystems, controlled over an RS-232 serial interface. It advances and cuts its own film internally and has no foil-length, force, or distance parameters. It requires a compressed air supply.\n\n| Property | Value |\n|---|---|\n| [OEM link](https://www.kbiosystems.com/product/ultraseal-xt-pro) | |\n| Communication | Serial / RS-232, ASCII command protocol |\n| Serial settings | 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake |\n| Terminator | CR (`\\r`) |\n| Temperature | 25-199 °C |\n| Sealing time | 0.5-9.9 s |\n\n```{warning}\nThis driver has NOT been tested against hardware in PyLabRobot. If you verify\nit on a sealer, please open a PR to remove the not-tested warning.\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "ultraseal-xt-pro-setup-md", + "source": "## Physical setup\n\nConnect the sealer's RS-232 port to your computer, typically through a USB-to-serial adapter, and connect the compressed air supply. Make sure the sealer's serial parameters match the driver defaults (9600 baud, 8 data bits, no parity, 1 stop bit, no handshake).", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "ultraseal-xt-pro-connect-md", + "source": "## Connect\n\n`setup()` opens the serial port, waits for the device to be ready, and pre-heats to the preheating temperature. It also logs the not-tested warning.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-xt-pro-connect-code", + "source": "from pylabrobot.kbiosystems import KBiosystemsUltrasealXTPro\n\nsealer = KBiosystemsUltrasealXTPro(port=\"/dev/ttyUSB0\") # replace with your port\nawait sealer.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-xt-pro-shuttle-md", + "source": "## Present and retract a plate\n\nThe XT Pro seals a plate held on its shuttle. Move the shuttle out to let a mover place a plate, then move it back in before sealing.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-xt-pro-shuttle-code", + "source": "await sealer.move_shuttle_out() # present the nest for loading\n# ... a mover places a plate ...\nawait sealer.move_shuttle_in() # retract for sealing", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-xt-pro-seal-md", + "source": "## Seal a plate\n\n`seal()` applies the time and temperature, waits for the heater to reach the setpoint, seals the plate, then returns to the idle temperature. Sealing time is 0.5-9.9 s; temperature is 25-199 °C.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-xt-pro-seal-code", + "source": "await sealer.seal(temperature=170, duration=2.5)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-xt-pro-temp-md", + "source": "## Temperature\n\nSet the sealing temperature setpoint and read the current heater temperature.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-xt-pro-temp-code", + "source": "await sealer.set_temperature(160)\nprint(\"Current temperature:\", await sealer.request_temperature(), \"C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-xt-pro-status-md", + "source": "## Status\n\nRead the status byte as a set of flags. On the XT Pro this includes `LowAir`, which indicates the compressed air supply is insufficient.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-xt-pro-status-code", + "source": "print(\"Status:\", await sealer.request_status())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ultraseal-xt-pro-teardown-md", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ultraseal-xt-pro-teardown-code", + "source": "await sealer.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/machine-agnostic-features/using-the-visualizer.ipynb b/docs/user_guide/machine-agnostic-features/using-the-visualizer.ipynb index 3706e8a1b29..09112122e57 100644 --- a/docs/user_guide/machine-agnostic-features/using-the-visualizer.ipynb +++ b/docs/user_guide/machine-agnostic-features/using-the-visualizer.ipynb @@ -20,7 +20,7 @@ "source": [ "## Setting up a connection with the robot\n", "\n", - "As described in the [basic liquid handling tutorial](../00_liquid-handling/hamilton-star/basic), we will use the {class}`~pylabrobot.liquid_handling.liquid_handler.LiquidHandler` class to control the robot. This time, however, instead of using the Hamilton {class}`~pylabrobot.liquid_handling.backends.hamilton.STAR_backend.STAR` backend, we are using the software-only {class}`~pylabrobot.liquid_handling.backends.chatterbox.ChatterboxBackend` backend. This means that liquid handling will work exactly the same, but commands are simply printed out to the console instead of being sent to a physical robot. We are still using the same deck." + "TODO" ] }, { @@ -329,8 +329,6 @@ "source": [ "## Liquid handling\n", "\n", - "Once the layout is complete, you can run the same commands as described in the [basic liquid handling tutorial](../00_liquid-handling/hamilton-star/basic).\n", - "\n", "It is important that both tip tracking and volume tracking are enabled globally, so that the visualizer can keep track of the state of the tips and the volumes of the liquids." ] }, diff --git a/docs/user_guide/machine-agnostic-features/writing-robot-agnostic-protocols.md b/docs/user_guide/machine-agnostic-features/writing-robot-agnostic-protocols.md index 6d5c1de2eac..a42b6a5eb0f 100644 --- a/docs/user_guide/machine-agnostic-features/writing-robot-agnostic-protocols.md +++ b/docs/user_guide/machine-agnostic-features/writing-robot-agnostic-protocols.md @@ -36,13 +36,13 @@ lh.pick_up_tip(tip_rack["A1"]) ## Strictness checking -Strictness checking is a feature that allows you to specify how strictly you want the {class}`LiquidHandler ` to enforce the protocol. The following levels are available: +Strictness checking is a feature that allows you to specify how strictly you want the {class}`LiquidHandler ` to enforce the protocol. The following levels are available: -- {attr}`STRICT `: The {class}`LiquidHandler ` will raise an exception if you are doing something that is not legal on the robot. -- {attr}`WARN `: The default. The {class}`LiquidHandler ` will warn you if you are doing something that is not recommended, but will not stop you from doing it. -- {attr}`IGNORE `: The {class}`LiquidHandler ` will silently log on the debug level if you are doing something that is not legal on the robot. +- {attr}`STRICT `: The {class}`LiquidHandler ` will raise an exception if you are doing something that is not legal on the robot. +- {attr}`WARN `: The default. The {class}`LiquidHandler ` will warn you if you are doing something that is not recommended, but will not stop you from doing it. +- {attr}`IGNORE `: The {class}`LiquidHandler ` will silently log on the debug level if you are doing something that is not legal on the robot. -You can set the strictness level for the entire protocol using {func}`pylabrobot.liquid_handling.strictness.set_strictness`. +You can set the strictness level for the entire protocol using {func}`pylabrobot.legacy.liquid_handling.strictness.set_strictness`. ```py from pylabrobot.liquid_handling import Strictness, set_strictness diff --git a/docs/user_guide/machines.md b/docs/user_guide/machines.md index 1f762c9b1a1..1373f3827bd 100644 --- a/docs/user_guide/machines.md +++ b/docs/user_guide/machines.md @@ -1,245 +1,3 @@ # Supported Machines -```{raw} html - -``` - - -## Liquid Handling - -### Liquid Handling Workstations - -| Manufacturer | Machine | Features | PLR-Support | Links | -|--------------|---------|----------|-------------|--------| -| Hamilton | STAR(let) | arm | Full | {doc}`PLR <00_liquid-handling/hamilton-star/_hamilton-star>` / [OEM](https://www.hamiltoncompany.com/microlab-star) | -| Hamilton | Vantage | arm | Mostly | {doc}`PLR <00_liquid-handling/hamilton-vantage/_hamilton-vantage>` / [OEM](https://www.hamiltoncompany.com/microlab-vantage) | -| Hamilton | Prep | | WIP | [OEM](https://www.hamiltoncompany.com/microlab-prep) | -| Hamilton | Nimbus | arm | Mostly | [OEM](https://www.hamiltoncompany.com/microlab-nimbus) | -| Tecan | EVO | arm | Basic | {doc}`PLR <00_liquid-handling/tecan-evo/_tecan-evo>` / [OEM](https://lifesciences.tecan.com/freedom-evo-platform) | -| Opentrons | OT-2 | | Mostly | {doc}`PLR <00_liquid-handling/opentrons/ot2/hello-world>` / [OEM](https://opentrons.com/products/ot-2-robot) | - -### Pumps - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Cole Parmer | Masterflex L/S 07522-20 07522-30 07551-20 07551-30 07575-30 07575-40 | Full | {doc}`PLR <00_liquid-handling/pumps/cole-parmer-masterflex>` / [OEM](https://www.masterflex.nl/assets/uploads/2017/09/07551-xx.pdf) | -| Agrowtek | Pump Array | Full | [OEM](https://www.agrowtek.com/index.php/products/dosing_systems/dosing-pumps/agrowdose-adi-digital-persitaltic-dosing-pumps-detail) | - -### Plate Washers - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Agilent (BioTek) | EL406 | Mostly | {doc}`PLR <00_liquid-handling/plate-washing/biotek-el406>` / [OEM](https://www.agilent.com/en/product/microplate-instrumentation/microplate-washers-dispensers/biotek-el406-washer-dispenser-795212) | -| Agilent (BioTek) | EL405 | Mostly | [OEM](https://www.agilent.com/en/product/microplate-instrumentation/microplate-washers-dispensers/biotek-elx405-select-deep-well-microplate-washer-1623186) | - -### Bulk Dispensers - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Thermo Fisher Scientific | Multidrop Combi | Mostly (v1b1) | [OEM](https://www.thermofisher.com/order/catalog/product/5840300) | -| Formulatrix | Mantis | WIP | [OEM](https://formulatrix.com/liquid-handling-systems/mantis-liquid-handler/) | - ---- - -## Material Handling - -### Arms - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Brooks Automation | PreciseFlex PF400 | Full | {doc}`PLR <01_material-handling/arms/c_scara/precise-flex-pf400/hello-world>` / [OEM](https://www.brooks.com/laboratory-automation/collaborative-robots/preciseflex-400/) | -| Brooks Automation | PreciseFlex PF3400 | Full | {doc}`PLR <01_material-handling/arms/c_scara/precise-flex-pf400/hello-world>` / [OEM](https://www.brooks.com/laboratory-automation/collaborative-robots/preciseflex-400/) | -| PAA | KX2 | Mostly | | -| UFactory | xArm 6 | Basics (v1b1) | [OEM](https://www.ufactory.cc/xarm-collaborative-robot/) | - -### Centrifuges - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Agilent | VSpin | Mostly | {doc}`PLR <01_material-handling/centrifuge/agilent_vspin>` / [OEM](https://www.agilent.com/en/product/automated-liquid-handling/automated-microplate-management/microplate-centrifuge) | -| Agilent | VSpin Access2 Loader | Full | [PLR](01_material-handling/centrifuge/agilent_vspin.ipynb#loader) / [OEM](https://www.agilent.com/en/product/automated-liquid-handling/automated-microplate-management/microplate-centrifuge) | -| HighRes Biosolutions | MicroSpin | Mostly | {doc}`PLR <01_material-handling/centrifuge/highres_microspin>` / [OEM](https://highresbio.com/microspin/) | -| Hettich | SBS 300 R | WIP | [OEM](https://www.hettweb.com/products/sbs300robotic/) | -| Hettich | ROTANTA 460 Robotic | WIP | [OEM](https://www.hettichlab.com/) | -| Hettich | Other Generation 2 robotic centrifuges | WIP | [OEM](https://www.hettichlab.com/) | - -### Fans - -| Manufacturer | Machine | Features | PLR-Support | Links | -|--------------|---------|----------|-------------|--------| -| Hamilton | HEPA Fan | air filtration | Full | {doc}`PLR <01_material-handling/fans/fans>` | - -### Heater Shakers - -| Manufacturer | Machine | Features | PLR-Support | Links | -|--------------|---------|----------|-------------|--------| -| Inheco | Thermoshake RM | heating | Full | {doc}`PLR <01_material-handling/heating_shaking/inheco>` / [OEM](https://www.inheco.com/thermoshake-classic.html) | -| Inheco | Thermoshake | heatingactive cooling | Full | {doc}`PLR <01_material-handling/heating_shaking/inheco>` / [OEM](https://www.inheco.com/thermoshake.html) | -| Inheco | Thermoshake AC | heatingactive cooling | Full | {doc}`PLR <01_material-handling/heating_shaking/inheco>` / [OEM](https://www.inheco.com/thermoshake-ac.html) | -| Opentrons | Thermoshake | heating | Full | [OEM](https://opentrons.com/products/heater-shaker-module) | -| Hamilton | Heater Shaker | heating | Full | {doc}`PLR <01_material-handling/heating_shaking/hamilton>` / [OEM](https://www.hamiltoncompany.com/temperature-control/hamilton-heater-shaker) | -| QInstruments | BioShake | heatingactive cooling | Full | {doc}`PLR <01_material-handling/heating_shaking/qinstruments>` / [OEM](https://www.qinstruments.com/automation/) | - -### Storage - -| Manufacturer | Machine | Features | PLR-Support | Links | -|--------------|---------|----------|-------------|--------| -| Thermo Fisher Scientific | Cytomat 6000 | heating | Full | {doc}`PLR <01_material-handling/storage/cytomat>` / [OEM](https://assets.thermofisher.com/TFS-Assets/CMD/brochures/br-90468-cytomat-2-c-lin-br90468-en.pdf) | -| Thermo Fisher Scientific | Cytomat 6002 | heating | Full | {doc}`PLR <01_material-handling/storage/cytomat>` / [OEM](https://www.thermofisher.com/order/catalog/product/50075279) | -| Thermo Fisher Scientific | Cytomat 2 C_50 | heating | Full | {doc}`PLR <01_material-handling/storage/cytomat>` / OEM? | -| Thermo Fisher Scientific | Cytomat 2 C425 | heatingactive cooling | Full | {doc}`PLR <01_material-handling/storage/cytomat>` / [OEM](https://www.thermofisher.com/order/catalog/product/51033032) | -| Thermo Fisher Scientific | Cytomat 2 C450_SHAKE | heatingshaking | Full | {doc}`PLR <01_material-handling/storage/cytomat>` / [OEM](https://www.thermofisher.com/order/catalog/product/51033035) | -| Thermo Fisher Scientific | Cytomat 5C | heating | Full | {doc}`PLR <01_material-handling/storage/cytomat>` / [OEM](https://www.thermofisher.com/order/catalog/product/51031526) | -| Thermo/Liconic | Heraeus Cytomat | heating | Full | {doc}`PLR <01_material-handling/storage/cytomat>` / OEM? | -| Inheco | Incubator Shaker (MTP/DWP) | heatingshaking | Mostly | [OEM](https://www.inheco.com/incubator-shaker.html) | -| Inheco | SCILA | heatingshaking | Mostly | [OEM](https://www.inheco.com/scila.html) | -| Liconic | STX series | heatingactive cooling | Mostly | {doc}`PLR <01_material-handling/storage/liconic>` / [OEM](https://www.liconic.com/stx.html) | - -### Peelers - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Azenta Life Sciences | XPeel | Full | [OEM](https://www.azenta.com/products/automated-plate-seal-remover-formerly-xpeel) | - -### Sealers - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Azenta Life Sciences | a4S | Full | {doc}`PLR <01_material-handling/sealers/a4s>` / [OEM](https://www.azenta.com/products/automated-roll-heat-sealer-formerly-a4s) | - -### Thermocyclers - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Opentrons | Thermocycler | Full | [OEM](https://opentrons.com/products/thermocycler-module-1) | -| Thermo Fisher Scientific | ATC | Full | [OEM](https://www.thermofisher.com/us/en/home/life-science/pcr/thermal-cyclers-realtime-instruments/thermal-cyclers/automated-thermal-cycler-atc.html) | -| Thermo Fisher Scientific | ProFlex | Full | [OEM](https://www.thermofisher.com/us/en/home/life-science/pcr/thermal-cyclers-realtime-instruments/thermal-cyclers/proflex-pcr-system.html) | -| Inheco | ODTC | Mostly | [OEM](https://www.inheco.com/odtc.html) | - -### Temperature Controllers - -| Manufacturer | Machine | Features | PLR-Support | Links | -|--------------|---------|----------|-------------|--------| -| Opentrons | Temperature Module | heatingactive cooling | Mostly | {doc}`PLR <01_material-handling/temperature-controllers/ot-temperature-controller>` / [OEM](https://opentrons.com/products/temperature-module-gen2) | -| Inheco | CPAC | heatingactive cooling | Full | [OEM](https://www.inheco.com/cpac.html) | -| Hamilton | Heater/Cooler | heatingactive cooling | Full (v1b1) | [OEM](https://www.hamiltoncompany.com/temperature-control/hamilton-heater-cooler) | - -### Tilting - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Hamilton | Tilt Module | Full | {doc}`PLR <01_material-handling/tilting>` / [OEM](https://www.hamiltoncompany.com/other-robotics/188061) | - ---- - -## Analytical Machines - -### Plate Readers - -| Manufacturer | Machine | Features | PLR-Support | Links | -|--------------|---------|----------|-------------|--------| -| BMG Labtech | CLARIOstar (Plus) | absorbancefluorescenceluminescence | Full | {doc}`PLR <02_analytical/plate-reading/bmg-clariostar>` / [OEM](https://www.bmglabtech.com/en/clariostar-plus/) | -| Agilent (BioTek) | Cytation 1 | absorbancefluorescenceluminescencemicroscopy | Full | {doc}`PLR <02_analytical/plate-reading/cytation>` / [OEM](https://www.agilent.com/en/product/cell-analysis/cell-imaging-microscopy/cell-imaging-multimode-readers/biotek-cytation-1-cell-imaging-multimode-reader-1623200) | -| Agilent (BioTek) | Cytation 5 | absorbancefluorescenceluminescencemicroscopy | Full | {doc}`PLR <02_analytical/plate-reading/cytation>` / [OEM](https://www.agilent.com/en/product/cell-analysis/cell-imaging-microscopy/cell-imaging-multimode-readers/biotek-cytation-5-cell-imaging-multimode-reader-1623202) | -| Agilent (BioTek) | Synergy H1 | absorbancefluorescenceluminescence | Full | {doc}`PLR <02_analytical/plate-reading/synergyh1>` / [OEM](https://www.agilent.com/en/product/microplate-instrumentation/microplate-readers/multimode-microplate-readers/biotek-synergy-h1-multimode-reader-1623193) | -| Agilent (BioTek) | Synergy HT | absorbancefluorescenceluminescence | Full | [OEM](https://www.agilent.com/en/product/microplate-instrumentation/microplate-readers/multimode-microplate-readers/biotek-synergy-ht-multi-mode-reader-1623194) | -| Byonoy | Absorbance 96 Automate | absorbance | Full | {doc}`PLR <02_analytical/plate-reading/byonoy/absorbance>` / [OEM](https://byonoy.com/absorbance-automate/) | -| Byonoy | Luminescence 96 | luminescence | Full | {doc}`PLR <02_analytical/plate-reading/byonoy/luminescence>` / [OEM](https://byonoy.com/luminescence-96/) | -| Byonoy | Luminescence 96 Automate | luminescence | Full | {doc}`PLR <02_analytical/plate-reading/byonoy/luminescence>` / [OEM](https://byonoy.com/luminescence-96-automate/) | -| Molecular Devices | SpectraMax M5e | absorbancefluorescence time-resolved fluorescencefluorescence polarization | Full | [OEM](https://www.moleculardevices.com/products/microplate-readers/multi-mode-readers/spectramax-m-series-readers) | -| Molecular Devices | SpectraMax 384plus | absorbance | Full | [OEM](https://www.moleculardevices.com/products/microplate-readers/absorbance-readers/spectramax-abs-plate-readers) | -| Molecular Devices | ImageXpress Pico | microscopy | Basics | {doc}`PLR <02_analytical/plate-reading/pico>` / [OEM](https://www.moleculardevices.com/products/cellular-imaging-systems/high-content-imaging/imagexpress-pico) | -| Molecular Devices | ImageXpress Micro | microscopy | WIP | [OEM](https://www.moleculardevices.com/products/cellular-imaging-systems/high-content-imaging/imagexpress-micro-4) | -| Molecular Devices | ImageXpress Nano | microscopy | WIP | [OEM](https://www.moleculardevices.com/products/cellular-imaging-systems/high-content-imaging/imagexpress-nano) | -| Tecan | Infinite 200 PRO | absorbancefluorescenceluminescence | Mostly | {doc}`PLR <02_analytical/plate-reading/tecan-infinite>` / [OEM](https://lifesciences.tecan.com/plate_readers/infinite_200_pro) | -| Tecan | Spark 20M | absorbancefluorescence | Mostly | {doc}`PLR <02_analytical/plate-reading/tecan-spark>` / [OEM](https://lifesciences.tecan.com/multimode-plate-reader) | - - -### Flow Cytometers - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Beckman Coulter | CytoFLEX S | WIP | [OEM](https://www.beckman.com/flow-cytometry/research-flow-cytometers/cytoflex-s) | - -### qPCR Machines - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Thermo Fisher Scientific | QuantStudio 5 | WIP | [OEM](https://www.thermofisher.com/order/catalog/product/A34322) | - -### Scales - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Mettler Toledo | WXS205SDU | Full | {doc}`PLR <02_analytical/scales/mettler-toledo-WXS205SDU>` / [OEM](https://www.mt.com/us/en/home/products/Industrial_Weighing_Solutions/high-precision-weigh-sensors/weigh-module-wxs205sdu-15-11121008.html) | - -### Barcode Scanners - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Keyence | Barcode Scanner | Full | [OEM](https://www.keyence.com/products/barcode/) | - ---- - -## Understanding the Tables - -Classifying lab automation equipment can be challenging. -There are many reasons for this, including (but not limited to): -- many machines have overlapping capabilities (the TFS Cytomat 2 C470 can be a fridge, heated chamber, oven, smart storage/plate hotel and shaker all in one machine!), -- different user groups require varying levels of software integration but tend to only refer to the capabilities they use in that moment (e.g. what is a shaker to one group might be a heater to another), -- balancing naming/classification based on user intuition/historic legacy and first principles, (e.g. a thermocycler is just a speedy heater/cooler but scientists are used to the term thermocycler) -- there is no widely accepted naming standard. - -PyLabRobot does not solve these human classification issues. -But to provide *some* structure PyLabRobot classifies machines into three purposefully broad categories: - -- **Liquid handling**: Machines directly manipulating liquids. -- **Material handling**: Machines handling materials other than liquids. -- **Analytical**: Machines primarily responsible for performing measurements. - -**Table Columns Explained:** - -- **Features**: Core capabilities provided by the machine. -- **PLR-Support**: Indicates PyLabRobot integration status: - - **WIP**: Work in progress. - - **Basics**: Core functionalities available. - Code integrated into `pylabrobot:main`. Documentation pages in `docs.pylabrobot.org`. - - **Mostly**: Most capabilities available, but some known commands still missing. - - **Full**: Comprehensive capabilities (≥90%) fully supported, extensive documentation available. - -Note: PyLabRobot aims to provide access to all hardware-firmware capabilities available on integrated equipment, even beyond OEM software. -This allows users to *choose* the machine functionalities they require. +TODO diff --git a/docs/user_guide/mettler_toledo/index.md b/docs/user_guide/mettler_toledo/index.md new file mode 100644 index 00000000000..da3c481fa1e --- /dev/null +++ b/docs/user_guide/mettler_toledo/index.md @@ -0,0 +1,7 @@ +# Mettler Toledo + +```{toctree} +:maxdepth: 1 + +wxs205sdu/hello-world +``` diff --git a/docs/user_guide/mettler_toledo/wxs205sdu/hello-world.ipynb b/docs/user_guide/mettler_toledo/wxs205sdu/hello-world.ipynb new file mode 100644 index 00000000000..d2d84fac473 --- /dev/null +++ b/docs/user_guide/mettler_toledo/wxs205sdu/hello-world.ipynb @@ -0,0 +1,187 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "5pmhklv4dll", + "metadata": {}, + "source": [ + "# Mettler Toledo WXS205SDU\n", + "\n", + "The WXS205SDU is a high-precision automated weigh module from Mettler Toledo, commonly used for gravimetric liquid transfer verification (e.g. in the Hamilton Liquid Verification Kit).\n", + "\n", + "| Property | Value |\n", + "|---|---|\n", + "| [OEM Link](https://www.mt.com/gb/en/home/products/Industrial_Weighing_Solutions/high-precision-weigh-sensors/weigh-module-wxs205sdu-15-11121008.html) | |\n", + "| Communication | Serial / RS-232 |\n", + "| VID:PID | `0x0403:0x6001` |\n", + "| Load range | 0 -- 220 g |\n", + "| Readability | 0.1 mg |\n", + "\n", + "The backend has been tested on the WXS205SDU but, per Mettler Toledo firmware documentation, should be applicable to other \"Automated Precision Weigh Modules\" in the WX and WMS series." + ] + }, + { + "cell_type": "markdown", + "id": "8v1qlztfpn", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "The system consists of two required units and one optional unit:\n", + "\n", + "| Component | Required | Description |\n", + "|---|---|---|\n", + "| Load Cell | yes | The weighing platform where samples are placed |\n", + "| Electronic Unit | yes | The control and communication module |\n", + "| Terminal/Display | no | For manual reading; not needed when using PyLabRobot |\n", + "\n", + "Connect the electronic unit to your computer via the RS-232 serial port. You will likely need a USB-to-serial adapter (any generic FTDI-based adapter should work).\n", + "\n", + "```{warning}\n", + "The scale requires a warm-up period after being powered on. Mettler Toledo specifies 60--90 minutes, though 30 minutes is often sufficient in practice. If you attempt measurements before warm-up, you may see: *\"Command understood but currently not executable\"*.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "1mzumf8u2bu", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "djeqvngxd3c", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.mettler_toledo import MettlerToledoWXS205SDU\n", + "\n", + "scale = MettlerToledoWXS205SDU(port=\"/dev/cu.usbserial-110\") # replace with your port\n", + "await scale.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "opa273q0bvc", + "metadata": {}, + "source": [ + "## Weighing\n", + "\n", + "The scale exposes `zero()`, `tare()`, and `read_weight()`.\n", + "\n", + "### Zero\n", + "\n", + "Calibrates the scale to read zero with an empty platform. Use at the start of a workflow." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dqc8j70q1m", + "metadata": {}, + "outputs": [], + "source": [ + "await scale.zero()" + ] + }, + { + "cell_type": "markdown", + "id": "jtaypw6mx8", + "metadata": {}, + "source": [ + "### Tare\n", + "\n", + "Resets the displayed weight to zero while accounting for a container already on the platform. Place your container, then tare, so subsequent readings reflect only the added material." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "k0b0bx9qaqq", + "metadata": {}, + "outputs": [], + "source": [ + "await scale.tare()" + ] + }, + { + "cell_type": "markdown", + "id": "i8ewtl6bitj", + "metadata": {}, + "source": [ + "### Read weight\n", + "\n", + "Returns the current weight in grams." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "77so0rlhk39", + "metadata": {}, + "outputs": [], + "source": [ + "weight = await scale.read_weight()\n", + "print(f\"Weight: {weight} g\")" + ] + }, + { + "cell_type": "markdown", + "id": "p4p4hpeg11", + "metadata": {}, + "source": [ + "### Further methods\n", + "\n", + "- `request_tare_weight()` -- retrieve the stored tare value\n", + "- `request_serial_number()` -- read the scale's serial number\n", + "- `clear_tare()` -- clear the stored tare weight\n", + "- `zero(timeout=...)` / `tare(timeout=...)` / `read_weight(timeout=...)` -- pass a timeout mode (`\"stable\"`, `0`, or seconds)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7ha7yrkc069", + "metadata": {}, + "outputs": [], + "source": [ + "tare_value = await scale.request_tare_weight()\n", + "print(f\"Stored tare: {tare_value} g\")" + ] + }, + { + "cell_type": "markdown", + "id": "oq3ck6rntr", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "q7m594v11eb", + "metadata": {}, + "outputs": [], + "source": [ + "await scale.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/molecular_devices/imageXpress/pico.ipynb b/docs/user_guide/molecular_devices/imageXpress/pico.ipynb new file mode 100644 index 00000000000..54abf215a96 --- /dev/null +++ b/docs/user_guide/molecular_devices/imageXpress/pico.ipynb @@ -0,0 +1,89 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ImageXpress Pico\n", + "\n", + "The Molecular Devices ImageXpress Pico is an automated microscope that communicates over gRPC/SiLA 2. It supports brightfield and fluorescence imaging with multiple objectives and filter cubes.\n", + "\n", + "| Model | PLR Name | Capabilities |\n", + "|---|---|---|\n", + "| ImageXpress Pico | `Pico` | Microscopy (brightfield, DAPI, GFP, RFP, Texas Red, Cy5) |\n", + "\n", + "**Requirements:** `grpcio`, `numpy`, and optionally `Pillow` for TIFF decoding. Install with `pip install grpcio numpy Pillow`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Create the `Pico` device with the instrument's network address. Use the `objectives` and `filter_cubes` arguments to declare which optics are installed at each turret/wheel position -- the driver will configure the instrument on setup." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.molecular_devices.imageXpress.pico import Pico, Objective, ImagingMode\n", + "\n", + "pico = Pico(\n", + " host=\"192.168.1.100\", # replace with your instrument's IP\n", + " objectives={\n", + " 0: Objective.O_4X_PL_FL,\n", + " 1: Objective.O_10X_PL_FL,\n", + " 2: Objective.O_20X_PL_FL,\n", + " },\n", + " filter_cubes={\n", + " 0: ImagingMode.BRIGHTFIELD,\n", + " 1: ImagingMode.DAPI,\n", + " 2: ImagingMode.GFP,\n", + " },\n", + ")\n", + "await pico.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Supported objectives and imaging modes\n", + "\n", + "| Objective | PLR Enum |\n", + "|---|---|\n", + "| N PLAN 2.5x/0.07 | `Objective.O_2_5X_N_PLAN` |\n", + "| PL FLUOTAR 4x/0.13 | `Objective.O_4X_PL_FL` |\n", + "| PL FLUOTAR 10x/0.30 | `Objective.O_10X_PL_FL` |\n", + "| PL FLUOTAR 20x/0.40 | `Objective.O_20X_PL_FL` |\n", + "| PL FLUOTAR 40x/0.60 | `Objective.O_40X_PL_FL` |\n", + "\n", + "| Imaging Mode | PLR Enum |\n", + "|---|---|\n", + "| Brightfield | `ImagingMode.BRIGHTFIELD` |\n", + "| DAPI | `ImagingMode.DAPI` |\n", + "| GFP | `ImagingMode.GFP` |\n", + "| RFP | `ImagingMode.RFP` |\n", + "| Texas Red | `ImagingMode.TEXAS_RED` |\n", + "| Cy5 | `ImagingMode.CY5` |" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/molecular_devices/index.md b/docs/user_guide/molecular_devices/index.md new file mode 100644 index 00000000000..6cc6140cc38 --- /dev/null +++ b/docs/user_guide/molecular_devices/index.md @@ -0,0 +1,8 @@ +# Molecular Devices + +```{toctree} +:maxdepth: 1 + +spectramax/hello-world +imageXpress/pico +``` diff --git a/docs/user_guide/molecular_devices/spectramax/hello-world.ipynb b/docs/user_guide/molecular_devices/spectramax/hello-world.ipynb new file mode 100644 index 00000000000..d3ef1e4ee88 --- /dev/null +++ b/docs/user_guide/molecular_devices/spectramax/hello-world.ipynb @@ -0,0 +1,274 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Molecular Devices SpectraMax\n", + "\n", + "The SpectraMax family of plate readers from Molecular Devices communicate over RS-232 serial. Both models share the same serial protocol ({class}`~pylabrobot.molecular_devices.spectramax.base.MolecularDevicesPlateReader`), but differ in what they can read.\n", + "\n", + "| Model | PLR Name | Absorbance | Fluorescence | Luminescence | Temperature Control |\n", + "|---|---|---|---|---|---|\n", + "| SpectraMax M5 | `SpectraMaxM5` | yes | yes | yes | yes |\n", + "| SpectraMax 384 Plus | `SpectraMax384Plus` | yes | no | no | yes |\n", + "\n", + "Connect the reader to your computer via an RS-232 serial cable (or USB-to-serial adapter). Note the serial port name (e.g. `COM3` on Windows, `/dev/ttyUSB0` on Linux)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.molecular_devices.spectramax import SpectraMaxM5\n", + "\n", + "reader = SpectraMaxM5(name=\"spectramax\", port=\"/dev/ttyUSB0\") # replace with your port\n", + "await reader.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For the SpectraMax 384 Plus:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# from pylabrobot.molecular_devices.spectramax import SpectraMax384Plus\n", + "#\n", + "# reader = SpectraMax384Plus(name=\"spectramax\", port=\"/dev/ttyUSB0\")\n", + "# await reader.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Assign a plate to the reader's loading tray:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", + "reader.loading_tray.assign_child_resource(plate)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Absorbance\n", + "\n", + "Both the M5 and 384 Plus support absorbance reads. Pass the plate and the wells to read, plus the wavelength in nm." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = await reader.read_absorbance(plate, plate.get_wells(), wavelength=450)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Reads are configured with keyword arguments, e.g. speed reads and calibration mode:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = await reader.read_absorbance(\n", + " plate,\n", + " plate.get_wells(),\n", + " wavelength=450,\n", + " speed_read=True,\n", + " calibrate=\"once\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Fluorescence (M5 only)\n", + "\n", + "The SpectraMax M5 supports fluorescence reads." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = await reader.read_fluorescence(\n", + " plate,\n", + " plate.get_wells(),\n", + " excitation_wavelength=485,\n", + " emission_wavelength=520,\n", + " focal_height=0.0,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "PMT gain accepts one of `\"auto\"`, `\"high\"`, `\"medium\"`, `\"low\"`, or a raw integer value:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = await reader.read_fluorescence(\n", + " plate,\n", + " plate.get_wells(),\n", + " excitation_wavelength=485,\n", + " emission_wavelength=520,\n", + " focal_height=0.0,\n", + " pmt_gain=\"high\",\n", + " read_from_bottom=True,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Luminescence (M5 only)\n", + "\n", + "The SpectraMax M5 supports luminescence reads. `emission_wavelengths` is required." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = await reader.read_luminescence(\n", + " plate,\n", + " plate.get_wells(),\n", + " focal_height=0.0,\n", + " emission_wavelengths=[460],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Temperature control\n", + "\n", + "Both models can hold a temperature between 0 and 45 C." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await reader.set_temperature(37.0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "current = await reader.request_current_temperature()\n", + "print(f\"{current:.1f} \\u00b0C\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await reader.deactivate()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tray control\n", + "\n", + "Open and close the plate tray:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await reader.open()\n", + "# load plate\n", + "await reader.close()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await reader.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/qinstruments/bioshake/hello-world.ipynb b/docs/user_guide/qinstruments/bioshake/hello-world.ipynb new file mode 100644 index 00000000000..84773207eb7 --- /dev/null +++ b/docs/user_guide/qinstruments/bioshake/hello-world.ipynb @@ -0,0 +1,64 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# QInstruments BioShake\n", + "\n", + "The BioShake is a family of heater-cooler-shaker devices from QInstruments. Depending on the model, it supports shaking and temperature control (heating and/or cooling).\n", + "\n", + "All models share the same serial firmware, so a single driver covers the entire family. Use a **model-specific factory function** to create instances -- it pre-fills dimensions and enables only the capabilities your hardware supports.\n", + "\n", + "| Model | PLR Name | Shaking (rpm) | Plate Lock | Heating | Active Cooling |\n", + "|---|---|---|---|---|---|\n", + "| BioShake Q1 | `BioShakeQ1` | 200--3000 | yes | yes | yes |\n", + "| BioShake Q2 | `BioShakeQ2` | 200--3000 | yes | yes | yes |\n", + "| BioShake 3000 | `BioShake3000` | 200--3000 | no | no | no |\n", + "| BioShake 3000 elm | `BioShake3000Elm` | 200--3000 | yes | no | no |\n", + "| BioShake 3000 elm DWP | `BioShake3000ElmDWP` | 200--3000 | yes | no | no |\n", + "| BioShake D30 elm | `BioShakeD30Elm` | 200--2000 | yes | no | no |\n", + "| BioShake 5000 elm | `BioShake5000Elm` | 200--5000 | yes | no | no |\n", + "| BioShake 3000-T | `BioShake3000T` | 200--3000 | no | yes | no |\n", + "| BioShake 3000-T elm | `BioShake3000TElm` | 200--3000 | yes | yes | no |\n", + "| BioShake D30-T elm | `BioShakeD30TElm` | 200--2000 | yes | yes | no |\n", + "| Heatplate | `Heatplate` | -- | no | yes | no |\n", + "| ColdPlate | `ColdPlate` | -- | no | yes | yes |\n", + "\n", + "See the [BioShake integration manual](https://www.qinstruments.com/fileadmin/Article/All/integration-manual-en-1-8-0.pdf) for hardware setup. Connect via RS232 or USB-A with a 24 VDC power supply." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.qinstruments import BioShakeQ1\n", + "\n", + "bs = BioShakeQ1(name=\"bioshake\", port=\"COM1\") # replace with your port\n", + "await bs.setup()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/user_guide/qinstruments/index.md b/docs/user_guide/qinstruments/index.md new file mode 100644 index 00000000000..47dc059b4ef --- /dev/null +++ b/docs/user_guide/qinstruments/index.md @@ -0,0 +1,7 @@ +# QInstruments + +```{toctree} +:maxdepth: 1 + +bioshake/hello-world +``` diff --git a/docs/user_guide/sartorius/entris2/hello-world.ipynb b/docs/user_guide/sartorius/entris2/hello-world.ipynb new file mode 100644 index 00000000000..e3397acbc3a --- /dev/null +++ b/docs/user_guide/sartorius/entris2/hello-world.ipynb @@ -0,0 +1,113 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "entris2-intro", + "source": "# Sartorius Entris II\n\nThe Entris II is a line of laboratory balances from Sartorius with an RS-232 serial interface (SBI protocol).\n\n| Property | Value |\n|---|---|\n| [OEM link](https://www.sartorius.com/en/products/weighing/laboratory-balances/entris-ii) | |\n| [Interface spec](https://www.sartorius.hr/media/dypfvdsn/entris-ii-technical-note-en-sartorius.pdf) | ([archived](https://archive.vn/NU6DW)) |\n| Communication | Serial / RS-232, SBI protocol |\n| Serial settings | 9600 baud, 8 data bits, odd parity, 1 stop bit |\n\n```{warning}\nThis driver has NOT been tested against hardware in PyLabRobot. The protocol\nfollows the Sartorius interface spec above. If you verify it on a balance,\nplease open a PR to remove the not-tested warning.\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "entris2-setup-md", + "source": "## Physical setup\n\nConnect the balance's RS-232 port to your computer, typically through a USB-to-serial adapter (any generic FTDI-based adapter works).\n\nMake sure the balance's serial parameters match the driver defaults (9600 baud, 8 data bits, odd parity, 1 stop bit). These are set on the balance in the setup menu under the interface/SBI settings.\n\n```{note}\nThe balance's factory default is 7-bit ASCII with hardware (CTS/RTS) handshake. If the balance does not respond, check that its data-bits and handshake settings match the driver (8 data bits, no handshake).\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "entris2-connect-md", + "source": "## Connect\n\n`setup()` opens the serial port and reads the serial number. It also logs the not-tested warning.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "entris2-connect-code", + "source": "from pylabrobot.sartorius import SartoriusEntris2\n\nscale = SartoriusEntris2(port=\"/dev/ttyUSB0\") # replace with your port\nawait scale.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "entris2-read-md", + "source": "## Measure the weight\n\n`measure_weight()` returns the current value in grams (`P` print command).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "entris2-read-code", + "source": "weight = await scale.measure_weight()\nprint(f\"Weight: {weight} g\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "entris2-zero-md", + "source": "## Zero\n\nZero the balance with an empty pan (`V`, Key ZERO). Use at the start of a workflow.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "entris2-zero-code", + "source": "await scale.zero()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "entris2-tare-md", + "source": "## Tare\n\nTare the balance to subtract a container already on the pan (`T`, Zero/Tara command). Place your container, tare, then subsequent reads reflect only the added material.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "entris2-tare-code", + "source": "await scale.tare()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "entris2-info-md", + "source": "## Device info\n\nQuery the model and serial number (`x1_` and `x2_`).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "entris2-info-code", + "source": "print(\"Model:\", await scale.get_model())\nprint(\"Serial number:\", await scale.get_serial_number())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "entris2-teardown-md", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "entris2-teardown-code", + "source": "await scale.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/sartorius/index.md b/docs/user_guide/sartorius/index.md new file mode 100644 index 00000000000..95685bd08a6 --- /dev/null +++ b/docs/user_guide/sartorius/index.md @@ -0,0 +1,7 @@ +# Sartorius + +```{toctree} +:maxdepth: 1 + +entris2/hello-world +``` diff --git a/docs/user_guide/thermo_fisher/alps/alps300/hello-world.ipynb b/docs/user_guide/thermo_fisher/alps/alps300/hello-world.ipynb new file mode 100644 index 00000000000..8d54f34a9db --- /dev/null +++ b/docs/user_guide/thermo_fisher/alps/alps300/hello-world.ipynb @@ -0,0 +1,99 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "alps300-intro", + "source": "# Thermo Scientific ALPS 300\n\nThe ALPS 300 is a heat sealer for microplates from Thermo Scientific, controlled over an RS-232 serial interface.\n\n| Property | Value |\n|---|---|\n| Communication | Serial / RS-232, ASCII command protocol |\n| Serial settings | 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake |\n| Terminator | CR (`\\r`) |\n\nThe ALPS 300, 3000, and 5000 share a command core but use different status flags and error codes, so each has its own driver class.\n\n```{warning}\nThis driver has NOT been tested against hardware in PyLabRobot. If you verify\nit on a sealer, please open a PR to remove the not-tested warning.\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "alps300-setup-md", + "source": "## Physical setup\n\nConnect the sealer's RS-232 port to your computer, typically through a USB-to-serial adapter. Make sure the sealer's serial parameters match the driver defaults (9600 baud, 8 data bits, no parity, 1 stop bit, no handshake).", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "alps300-connect-md", + "source": "## Connect\n\n`setup()` opens the serial port, waits for the device to be ready, and pre-heats to the preheating temperature. It also logs the not-tested warning.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps300-connect-code", + "source": "from pylabrobot.thermo_fisher.alps import ThermoScientificALPS300\n\nsealer = ThermoScientificALPS300(port=\"/dev/ttyUSB0\") # replace with your port\nawait sealer.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "alps300-seal-md", + "source": "## Seal a plate\n\n`seal()` applies the time and temperature, waits for the heater to reach the setpoint, then seals the plate. Sealing time is 0.5–9.9 s; temperature is 5–199 °C.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps300-seal-code", + "source": "await sealer.seal(temperature=170, duration=2.5)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "alps300-temp-md", + "source": "## Temperature\n\nSet the sealing temperature setpoint and read the current heater temperature.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps300-temp-code", + "source": "await sealer.set_temperature(160)\nprint(\"Current temperature:\", await sealer.request_temperature(), \"C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "alps300-status-md", + "source": "## Status\n\nRead the status byte as a set of flags.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps300-status-code", + "source": "print(\"Status:\", await sealer.request_status())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "alps300-teardown-md", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps300-teardown-code", + "source": "await sealer.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/thermo_fisher/alps/alps3000/hello-world.ipynb b/docs/user_guide/thermo_fisher/alps/alps3000/hello-world.ipynb new file mode 100644 index 00000000000..52fe536d1c2 --- /dev/null +++ b/docs/user_guide/thermo_fisher/alps/alps3000/hello-world.ipynb @@ -0,0 +1,99 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "alps3000-intro", + "source": "# Thermo Scientific ALPS 3000\n\nThe ALPS 3000 is a heat sealer for microplates from Thermo Scientific, controlled over an RS-232 serial interface.\n\nProduct page: \n\n| Property | Value |\n|---|---|\n| Communication | Serial / RS-232, ASCII command protocol |\n| Serial settings | 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake |\n| Terminator | CR (`\\r`) |\n\nThe ALPS 300, 3000, and 5000 share a command core but use different status flags and error codes, so each has its own driver class.\n\n```{warning}\nThis driver has NOT been tested against hardware in PyLabRobot. If you verify\nit on a sealer, please open a PR to remove the not-tested warning.\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "alps3000-setup-md", + "source": "## Physical setup\n\nConnect the sealer's RS-232 port to your computer, typically through a USB-to-serial adapter. Make sure the sealer's serial parameters match the driver defaults (9600 baud, 8 data bits, no parity, 1 stop bit, no handshake).", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "alps3000-connect-md", + "source": "## Connect\n\n`setup()` opens the serial port, waits for the device to be ready, and pre-heats to the preheating temperature. It also logs the not-tested warning.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps3000-connect-code", + "source": "from pylabrobot.thermo_fisher.alps import ThermoScientificALPS3000\n\nsealer = ThermoScientificALPS3000(port=\"/dev/ttyUSB0\") # replace with your port\nawait sealer.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "alps3000-seal-md", + "source": "## Seal a plate\n\n`seal()` applies the time and temperature, waits for the heater to reach the setpoint, then seals the plate. Sealing time is 0.5–9.9 s; temperature is 5–199 °C.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps3000-seal-code", + "source": "await sealer.seal(temperature=170, duration=2.5)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "alps3000-temp-md", + "source": "## Temperature\n\nSet the sealing temperature setpoint and read the current heater temperature.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps3000-temp-code", + "source": "await sealer.set_temperature(160)\nprint(\"Current temperature:\", await sealer.request_temperature(), \"C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "alps3000-status-md", + "source": "## Status\n\nRead the status byte as a set of flags.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps3000-status-code", + "source": "print(\"Status:\", await sealer.request_status())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "alps3000-teardown-md", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps3000-teardown-code", + "source": "await sealer.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/thermo_fisher/alps/alps5000/hello-world.ipynb b/docs/user_guide/thermo_fisher/alps/alps5000/hello-world.ipynb new file mode 100644 index 00000000000..fb3b391c40d --- /dev/null +++ b/docs/user_guide/thermo_fisher/alps/alps5000/hello-world.ipynb @@ -0,0 +1,141 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "alps5000-intro", + "source": "# Thermo Scientific ALPS 5000\n\nThe ALPS 5000 is a heat sealer for microplates from Thermo Scientific, controlled over an RS-232 serial interface. It extends the shared ALPS protocol with initialisation, force-sensor control, foil length, sealing distance, and shuttle movement.\n\nProduct page: \n\n| Property | Value |\n|---|---|\n| Communication | Serial / RS-232, ASCII command protocol |\n| Serial settings | 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake |\n| Terminator | CR (`\\r`) |\n\nThe ALPS 300, 3000, and 5000 share a command core but use different status flags and error codes, so each has its own driver class.\n\n```{warning}\nThis driver has NOT been tested against hardware in PyLabRobot. If you verify\nit on a sealer, please open a PR to remove the not-tested warning.\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "alps5000-setup-md", + "source": "## Physical setup\n\nConnect the sealer's RS-232 port to your computer, typically through a USB-to-serial adapter. Make sure the sealer's serial parameters match the driver defaults (9600 baud, 8 data bits, no parity, 1 stop bit, no handshake).", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "alps5000-connect-md", + "source": "## Connect\n\n`setup()` opens the serial port, runs the instrument initialisation routine, waits for the device to be ready, and pre-heats to the preheating temperature. It also logs the not-tested warning.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps5000-connect-code", + "source": "from pylabrobot.thermo_fisher.alps import ThermoScientificALPS5000\n\nsealer = ThermoScientificALPS5000(port=\"/dev/ttyUSB0\") # replace with your port\nawait sealer.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "alps5000-seal-md", + "source": "## Seal a plate\n\n`seal()` applies the time and temperature, optionally enables the force sensor, waits for the heater to reach the setpoint, then seals the plate. Sealing time is 0.5–9.9 s; temperature is 5–199 °C.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps5000-seal-code", + "source": "await sealer.seal(temperature=170, duration=2.5, use_force_sensor=True)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "alps5000-force-md", + "source": "## Force and force sensor\n\nSet the sealing force (5–50) and enable or disable the force sensor independently of a seal.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps5000-force-code", + "source": "await sealer.set_force(30)\nawait sealer.enable_force_sensor(True)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "alps5000-foil-md", + "source": "## Foil length and sealing distance\n\nSet the foil length (119–128) and the sealing distance (10–50).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps5000-foil-code", + "source": "await sealer.set_foil_length(124)\nawait sealer.set_sealing_distance(20)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "alps5000-shuttle-md", + "source": "## Shuttle\n\nMove the shuttle in and out.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps5000-shuttle-code", + "source": "await sealer.shuttle_out()\nawait sealer.shuttle_in()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "alps5000-temp-md", + "source": "## Temperature\n\nSet the sealing temperature setpoint and read the current heater temperature.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps5000-temp-code", + "source": "await sealer.set_temperature(160)\nprint(\"Current temperature:\", await sealer.request_temperature(), \"C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "alps5000-status-md", + "source": "## Status\n\nRead the status byte as a set of flags.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps5000-status-code", + "source": "print(\"Status:\", await sealer.request_status())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "alps5000-teardown-md", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "alps5000-teardown-code", + "source": "await sealer.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/thermo_fisher/alps/index.md b/docs/user_guide/thermo_fisher/alps/index.md new file mode 100644 index 00000000000..01d02fe9344 --- /dev/null +++ b/docs/user_guide/thermo_fisher/alps/index.md @@ -0,0 +1,13 @@ +# ALPS Heat Sealers + +The Thermo Scientific ALPS family of heat sealers share a common serial command +core but differ in their status flags, error tables, and available commands. Each +model has its own driver. + +```{toctree} +:maxdepth: 1 + +alps300/hello-world +alps3000/hello-world +alps5000/hello-world +``` diff --git a/docs/user_guide/thermo_fisher/index.md b/docs/user_guide/thermo_fisher/index.md new file mode 100644 index 00000000000..4c887655bbb --- /dev/null +++ b/docs/user_guide/thermo_fisher/index.md @@ -0,0 +1,7 @@ +# Thermo Fisher + +```{toctree} +:maxdepth: 1 + +alps/index +``` diff --git a/docs/user_guide/ufactory/index.md b/docs/user_guide/ufactory/index.md new file mode 100644 index 00000000000..0939be94e7c --- /dev/null +++ b/docs/user_guide/ufactory/index.md @@ -0,0 +1,7 @@ +# UFACTORY + +```{toctree} +:maxdepth: 1 + +xarm6/hello-world +``` diff --git a/docs/user_guide/ufactory/xarm6/hello-world.ipynb b/docs/user_guide/ufactory/xarm6/hello-world.ipynb new file mode 100644 index 00000000000..cb87d592918 --- /dev/null +++ b/docs/user_guide/ufactory/xarm6/hello-world.ipynb @@ -0,0 +1,370 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "xarm6-intro", + "metadata": {}, + "source": [ + "# UFACTORY xArm 6\n", + "\n", + "The [UFACTORY xArm 6](https://www.ufactory.cc/xarm-collaborative-robot/) is a 6-axis articulated robotic arm. PyLabRobot supports it with the xArm bio-gripper. It supports:\n", + "\n", + "The device communicates over Ethernet using the [xarm-python-sdk](https://pypi.org/project/xarm-python-sdk/). Install the optional dependency group with `pip install PyLabRobot[xarm]`.\n", + "\n", + "| Model | PLR Name | Notes |\n", + "|---|---|---|\n", + "| xArm 6 | `XArm6` | 6-DOF articulated arm + bio-gripper |" + ] + }, + { + "cell_type": "markdown", + "id": "xarm6-setup-header", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Construct `XArm6` with the controller IP (and optional TCP offset/load), then call `setup()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "xarm6-setup-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.ufactory.xarm6 import XArm6\n", + "\n", + "xarm = XArm6(\n", + " ip=\"192.168.1.220\",\n", + " tcp_offset=(0, 0, 0, 0, 0, 0), # adjust for your gripper mount (x, y, z, roll, pitch, yaw)\n", + ")\n", + "await xarm.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "xarm6-setup-note", + "metadata": {}, + "source": [ + "`setup()` connects to the controller, clears any pending errors, enables motion, and initializes the bio-gripper. To skip the gripper init, pass `skip_gripper_init=True` to `setup()`." + ] + }, + { + "cell_type": "markdown", + "id": "xarm6-arm-header", + "metadata": {}, + "source": [ + "## Gripper control\n", + "\n", + "Gripper widths are in millimeters. The bio-gripper range is 71 mm (fully closed) to 150 mm (fully open); override with `gripper_min_mm` / `gripper_max_mm` on the constructor." + ] + }, + { + "cell_type": "markdown", + "id": "xarm6-gripper-header", + "metadata": {}, + "source": [ + "Open and close by moving to the ends of the range:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "xarm6-gripper-code", + "metadata": {}, + "outputs": [], + "source": [ + "await xarm.move_gripper(width=xarm.max_gripper_width)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a730d6d4", + "metadata": {}, + "outputs": [], + "source": [ + "await xarm.move_gripper(width=xarm.min_gripper_width)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f46f6337", + "metadata": {}, + "outputs": [], + "source": [ + "await xarm.is_gripper_closed()" + ] + }, + { + "cell_type": "markdown", + "id": "xarm6-cartesian-header", + "metadata": {}, + "source": [ + "### Cartesian movement\n", + "\n", + "Move the arm to a Cartesian location with a full 3D rotation. `speed` (mm/s) and `mvacc` (mm/s^2) override the defaults." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "xarm6-cartesian-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import Coordinate\n", + "from pylabrobot.resources.rotation import Rotation\n", + "\n", + "await xarm.move_to_location(\n", + " location=Coordinate(x=300, y=0, z=200),\n", + " rotation=Rotation(x=180, y=0, z=0), # roll, pitch, yaw (degrees)\n", + " speed=150,\n", + " mvacc=2500,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "xarm6-joint-header", + "metadata": {}, + "source": [ + "### Joint movement\n", + "\n", + "Move in joint space using the {class}`~pylabrobot.ufactory.xarm6.joints.XArm6Axis` enum. `speed` is in deg/s and `mvacc` in deg/s^2. Axes you omit keep their current angle.\n", + "\n", + "```{warning}\n", + "Moving to arbitrary joint positions can cause the arm to collide with its base, the gripper, or nearby equipment. Verify coordinates carefully in a safe workspace first.\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "xarm6-joint-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.ufactory.xarm6 import XArm6Axis\n", + "\n", + "position = {\n", + " XArm6Axis.J1: 0.0,\n", + " XArm6Axis.J2: -30.0,\n", + " XArm6Axis.J3: -60.0,\n", + " XArm6Axis.J4: 0.0,\n", + " XArm6Axis.J5: 90.0,\n", + " XArm6Axis.J6: 0.0,\n", + "}\n", + "await xarm.move_to_joint_position(position, speed=40)" + ] + }, + { + "cell_type": "markdown", + "id": "xarm6-query-header", + "metadata": {}, + "source": [ + "### Querying position\n", + "\n", + "Get the current joint angles or Cartesian end-effector pose:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "xarm6-query-joints", + "metadata": {}, + "outputs": [], + "source": [ + "await xarm.request_joint_position()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "xarm6-query-cart", + "metadata": {}, + "outputs": [], + "source": [ + "await xarm.request_gripper_pose()" + ] + }, + { + "cell_type": "markdown", + "id": "xarm6-pick-place-header", + "metadata": {}, + "source": [ + "### Pick and place\n", + "\n", + "`pick_up_at_location` moves the arm to the target pose and closes the gripper to `resource_width`. `drop_at_location` moves to the target and fully opens the gripper. Approach and retract motions are the caller's responsibility — wrap these calls with your own pre- and post-moves as needed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "xarm6-pick-place-code", + "metadata": {}, + "outputs": [], + "source": [ + "pick = Coordinate(x=300, y=100, z=50)\n", + "place = Coordinate(x=300, y=-100, z=50)\n", + "above = Coordinate(x=0, y=0, z=80)\n", + "down = Rotation(x=180, y=0, z=0)" + ] + }, + { + "cell_type": "markdown", + "id": "8965a469", + "metadata": {}, + "source": [ + "Approach the pick position from 80 mm above, descend, close the gripper, and retract:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "66890ff1", + "metadata": {}, + "outputs": [], + "source": [ + "await xarm.move_to_location(pick + above, down)\n", + "await xarm.pick_up_at_location(location=pick, rotation=down, resource_width=80)\n", + "await xarm.move_to_location(pick + above, down)" + ] + }, + { + "cell_type": "markdown", + "id": "07782575", + "metadata": {}, + "source": [ + "Travel to the place position, descend, release, and retract:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "143c9c74", + "metadata": {}, + "outputs": [], + "source": [ + "await xarm.move_to_location(place + above, down)\n", + "await xarm.drop_at_location(location=place, rotation=down, resource_width=80)\n", + "await xarm.move_to_location(place + above, down)" + ] + }, + { + "cell_type": "markdown", + "id": "xarm6-freedrive-header", + "metadata": {}, + "source": [ + "### Freedrive (teaching mode)\n", + "\n", + "Enter freedrive mode to manually position the arm by hand, then read coordinates for use in your protocol. The xArm SDK frees all axes simultaneously; the `free_axes` argument is accepted for API compatibility but ignored." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "xarm6-freedrive-code", + "metadata": {}, + "outputs": [], + "source": [ + "await xarm.start_freedrive_mode(free_axes=[0])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d3d63cd0", + "metadata": {}, + "outputs": [], + "source": [ + "# Manually guide the arm to the desired pose, then read it:\n", + "taught = await xarm.request_gripper_pose()\n", + "print(taught)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c4c843d4", + "metadata": {}, + "outputs": [], + "source": [ + "await xarm.stop_freedrive_mode()" + ] + }, + { + "cell_type": "markdown", + "id": "xarm6-misc-header", + "metadata": {}, + "source": [ + "### Park, halt, and error recovery\n", + "\n", + "Park the arm (SDK home by default, or a user-supplied `park_location`), emergency-stop all motion, or clear an error state:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "xarm6-misc-code", + "metadata": {}, + "outputs": [], + "source": [ + "await xarm.park()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bec2f90d", + "metadata": {}, + "outputs": [], + "source": [ + "await xarm.halt()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a2dae38b", + "metadata": {}, + "outputs": [], + "source": [ + "await xarm.clear_errors()" + ] + }, + { + "cell_type": "markdown", + "id": "xarm6-teardown-header", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "xarm6-teardown-code", + "metadata": {}, + "outputs": [], + "source": [ + "await xarm.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pylabrobot/agilent/__init__.py b/pylabrobot/agilent/__init__.py new file mode 100644 index 00000000000..d864e669b7a --- /dev/null +++ b/pylabrobot/agilent/__init__.py @@ -0,0 +1,8 @@ +from .biotek import ( + EL406, + Cytation1, + Cytation5, + CytationImagingConfig, + SynergyH1, +) +from .vspin import Access2, Access2Driver, VSpin diff --git a/pylabrobot/agilent/biotek/__init__.py b/pylabrobot/agilent/biotek/__init__.py new file mode 100644 index 00000000000..abd709b1806 --- /dev/null +++ b/pylabrobot/agilent/biotek/__init__.py @@ -0,0 +1,3 @@ +from .cytation import Cytation1, Cytation5, CytationImagingConfig +from .el406 import EL406 +from .synergy import SynergyH1 diff --git a/pylabrobot/agilent/biotek/biotek_tests.py b/pylabrobot/agilent/biotek/biotek_tests.py new file mode 100644 index 00000000000..b84cec21856 --- /dev/null +++ b/pylabrobot/agilent/biotek/biotek_tests.py @@ -0,0 +1,436 @@ +# mypy: disable-error-code = attr-defined + + +import math +import unittest +import unittest.mock +from datetime import datetime +from typing import Iterator + +import pytest + +pytest.importorskip("pylibftdi") + +from pylabrobot.agilent.biotek.plate_reader_base import BioTekPlateReaderDriver +from pylabrobot.resources import CellVis_24_wellplate_3600uL_Fb, CellVis_96_wellplate_350uL_Fb + + +def _byte_iter(s: str) -> Iterator[bytes]: + for c in s: + yield c.encode() + + +class TestCytation5Backend(unittest.IsolatedAsyncioTestCase): + """Tests for the Cytation5Backend.""" + + async def asyncSetUp(self): + self.backend = BioTekPlateReaderDriver(timeout=0.1) + self.backend.io = unittest.mock.MagicMock() + self.backend.io.setup = unittest.mock.AsyncMock() + self.backend.io.stop = unittest.mock.AsyncMock() + self.backend.io.read = unittest.mock.AsyncMock() + self.backend.io.write = unittest.mock.AsyncMock() + self.backend.io.usb_reset = unittest.mock.AsyncMock() + self.backend.io.usb_purge_rx_buffer = unittest.mock.AsyncMock() + self.backend.io.usb_purge_tx_buffer = unittest.mock.AsyncMock() + self.backend.io.set_latency_timer = unittest.mock.AsyncMock() + self.backend.io.set_baudrate = unittest.mock.AsyncMock() + self.backend.io.set_line_property = unittest.mock.AsyncMock() + self.backend.io.set_flowctrl = unittest.mock.AsyncMock() + self.backend.io.set_rts = unittest.mock.AsyncMock() + self.plate = CellVis_24_wellplate_3600uL_Fb(name="plate") + + # Freeze the clock so the timestamp in the results is predictable. + self.now = datetime(2024, 5, 17, 12, 34, 56) + self._time_patcher = unittest.mock.patch( + "pylabrobot.agilent.biotek.plate_reader_base.datetime", + unittest.mock.Mock(now=unittest.mock.Mock(return_value=self.now)), + ) + self._time_patcher.start() + self.addCleanup(self._time_patcher.stop) + + async def test_setup(self): + self.backend.io.read.side_effect = _byte_iter("\x061650200 Version 1.04 0000\x03") + await self.backend.setup() + assert self.backend.io.setup.called + self.backend.io.usb_reset.assert_called_once() + self.backend.io.set_latency_timer.assert_called_with(16) + self.backend.io.set_baudrate.assert_called_with(9600) + # self.backend.io.set_line_property.assert_called_with(8, 2, 0) #? + self.backend.io.set_flowctrl.assert_called_with(0x100) + self.backend.io.set_rts.assert_called_with(1) + + await self.backend.stop() + assert self.backend.io.stop.called + + async def test_request_serial_number(self): + self.backend.io.read.side_effect = _byte_iter("\x0600000000 0000\x03") + assert await self.backend.request_serial_number() == "00000000" + + async def test_open(self): + self.backend.io.read.side_effect = [b"\x06", b"\x03", b"\x03"] + await self.backend.open() + self.backend.io.write.assert_called_with(b"J") + + async def test_close(self): + self.backend.io.read.side_effect = [b"\x06", b"\x03", b"\x06", b"\x03", b"\x03"] + plate = CellVis_24_wellplate_3600uL_Fb(name="plate") + await self.backend.close(plate=plate) + self.backend.io.write.assert_called_with(b"A") + + async def test_request_current_temperature(self): + self.backend.io.read.side_effect = _byte_iter("\x062360000\x03") + assert await self.backend.request_current_temperature() == 23.6 + + async def test_read_absorbance(self): + self.backend.io.read.side_effect = _byte_iter( + "\x06" + + "\x03" + + "\x06" + + "0350000000000000010000000000490300000\x03" + + "\x06" + + "0000\x03" + + ( + "01,1,\r000:00:00.0,228,01,01,+0.1917,01,02,+0.1225,01,03,+0.0667,01,04,+0.0728,01,05,+0." + "0722,01,06,+0.0664,01,07,+0.0763,01,08,+0.0726,01,09,+0.0825,01,10,+0.1001,01,11,+0.1443" + ",01,12,+0.2105\r\n,02,12,+0.1986,02,11,+0.0800,02,10,+0.0796,02,09,+0.0871,02,08,+0.1059" + ",02,07,+0.0868,02,06,+0.0544,02,05,+0.0644,02,04,+0.0752,02,03,+0.0768,02,02,+0.0925,02" + ",01,+0.0802\r\n,03,01,+0.0925,03,02,+0.1007,03,03,+0.0697,03,04,+0.0736,03,05,+0.0712,03" + ",06,+0.0719,03,07,+0.0710,03,08,+0.0794,03,09,+0.0645,03,10,+0.0799,03,11,+0.0779,03,12," + "+0.1256\r\n,04,12,+0.1525,04,11,+0.0711,04,10,+0.0858,04,09,+0.0753,04,08,+0.0787,04,07," + "+0.0778,04,06,+0.0895,04,05,+0.0733,04,04,+0.0711,04,03,+0.0672,04,02,+0.0719,04,01,+0.0" + "954\r\n,05,01,+0.0841,05,02,+0.0610,05,03,+0.0766,05,04,+0.0773,05,05,+0.0632,05,06,+0.0" + "787,05,07,+0.1100,05,08,+0.0645,05,09,+0.0934,05,10,+0.1439,05,11,+0.1113,05,12,+0.1281" + "\r\n,06,12,+0.1649,06,11,+0.0707,06,10,+0.0892,06,09,+0.0712,06,08,+0.0935,06,07,+0.1079" + ",06,06,+0.0704,06,05,+0.0978,06,04,+0.0596,06,03,+0.0794,06,02,+0.0776,06,01,+0.0930\r\n" + ",07,01,+0.1255,07,02,+0.0742,07,03,+0.0747,07,04,+0.0694,07,05,+0.1004,07,06,+0.0900,07," + "07,+0.0659,07,08,+0.0858,07,09,+0.0876,07,10,+0.0815,07,11,+0.0980,07,12,+0.1329\r\n,08," + "12,+0.1316,08,11,+0.1290,08,10,+0.1103,08,09,+0.0667,08,08,+0.0790,08,07,+0.0602,08,06,+" + "0.0670,08,05,+0.0732,08,04,+0.0657,08,03,+0.0684,08,02,+0.1174,08,01,+0.1427\r\n228\x1a0" + "41\x1a0000\x03" + ) + + "\x062360000\x03" # Temperature call + ) + + plate = CellVis_96_wellplate_350uL_Fb(name="plate") + resp = await self.backend.read_absorbance( + plate=plate, wells=plate.get_all_items(), wavelength=580 + ) + + self.backend.io.write.assert_any_call(b"D") + self.backend.io.write.assert_any_call( + b"004701010108120001200100001100100000106000080580113\x03" + ) + self.backend.io.write.assert_any_call(b"O") + + expected_data = [ + [ + 0.1917, + 0.1225, + 0.0667, + 0.0728, + 0.0722, + 0.0664, + 0.0763, + 0.0726, + 0.0825, + 0.1001, + 0.1443, + 0.2105, + ], + [ + 0.0802, + 0.0925, + 0.0768, + 0.0752, + 0.0644, + 0.0544, + 0.0868, + 0.1059, + 0.0871, + 0.0796, + 0.08, + 0.1986, + ], + [ + 0.0925, + 0.1007, + 0.0697, + 0.0736, + 0.0712, + 0.0719, + 0.071, + 0.0794, + 0.0645, + 0.0799, + 0.0779, + 0.1256, + ], + [ + 0.0954, + 0.0719, + 0.0672, + 0.0711, + 0.0733, + 0.0895, + 0.0778, + 0.0787, + 0.0753, + 0.0858, + 0.0711, + 0.1525, + ], + [0.0841, 0.061, 0.0766, 0.0773, 0.0632, 0.0787, 0.11, 0.0645, 0.0934, 0.1439, 0.1113, 0.1281], + [ + 0.093, + 0.0776, + 0.0794, + 0.0596, + 0.0978, + 0.0704, + 0.1079, + 0.0935, + 0.0712, + 0.0892, + 0.0707, + 0.1649, + ], + [0.1255, 0.0742, 0.0747, 0.0694, 0.1004, 0.09, 0.0659, 0.0858, 0.0876, 0.0815, 0.098, 0.1329], + [0.1427, 0.1174, 0.0684, 0.0657, 0.0732, 0.067, 0.0602, 0.079, 0.0667, 0.1103, 0.129, 0.1316], + ] + self.assertEqual(len(resp), 1) + self.assertEqual(resp[0].wavelength, 580) + self.assertEqual(resp[0].data, expected_data) + self.assertEqual(resp[0].temperature, 23.6) + self.assertEqual(resp[0].timestamp, self.now) + + async def test_read_luminescence_partial(self): + self.backend.io.read.side_effect = _byte_iter( + # plate + "\x06" + + "\x03" + # focal height + + "\x06" + + "\x03" + # read block 1 + + "\x06" + + "0350000000000000010000000000490300000\x03" + + "\x060000\x03" + + "01,1,\r000:00:00.0,237,01,01,0000003\r\n,02,01,0000003\r\n,03,01,0000005\r\n,04,01,0000004\r\n,05,01,0000003\r\n,06,01,0000002\r\n,07,01,0000000\r\n237\x1a132\x1a0000\x03" + # read block 2 + + "\x06" + + "0350000000000000010000000000030200000\x03" + + "\x060000\x03" + + "01,1,\r000:00:00.0,237,02,02,0000043,02,03,0000014\r\n,03,03,0000014,03,02,0000012\r\n,04,02,0000011,04,03,0000014\r\n,05,03,0000010,05,02,0000010\r\n,06,02,0000011,06,03,0000027\r\n,07,03,0000009,07,02,0000010\r\n237\x1a160\x1a0000\x03" + # read block 3 + + "\x06" + + "0350000000000000010000000000000170000\x03" + + "\x060000\x03" + + "01,1,\r000:00:00.0,237,04,04,0000018\r\n,05,04,0000017\r\n,06,04,0000014\r\n237\x1a210\x1a0000\x03" + + "\x062360000\x03" # Temperature call + ) + + plate = CellVis_96_wellplate_350uL_Fb(name="plate") + wells = plate["A1"] + plate["B1:G3"] + plate["D4:F4"] + resp = await self.backend.read_luminescence( + focal_height=4.5, + plate=plate, + wells=wells, + integration_time=0.4, + ) + + self.backend.io.write.assert_any_call(b"D") + self.backend.io.write.assert_any_call( + b"008401010107010001200100001100100000123000020200200-001000-00300000000000000000001351086" + ) + self.backend.io.write.assert_any_call( + b"008401020207030001200100001100100000123000020200200-001000-00300000000000000000001351090" + ) + self.backend.io.write.assert_any_call( + b"008401040406040001200100001100100000123000020200200-001000-00300000000000000000001351094" + ) + self.backend.io.write.assert_any_call(b"O") + + expected_data = [ + [3.0, None, None, None, None, None, None, None, None, None, None, None], + [3.0, 43.0, 14.0, None, None, None, None, None, None, None, None, None], + [5.0, 12.0, 14.0, None, None, None, None, None, None, None, None, None], + [4.0, 11.0, 14.0, 18.0, None, None, None, None, None, None, None, None], + [3.0, 10.0, 10.0, 17.0, None, None, None, None, None, None, None, None], + [2.0, 11.0, 27.0, 14.0, None, None, None, None, None, None, None, None], + [0.0, 10.0, 9.0, None, None, None, None, None, None, None, None, None], + [None, None, None, None, None, None, None, None, None, None, None, None], + ] + self.assertEqual(len(resp), 1) + self.assertEqual(resp[0].data, expected_data) + self.assertEqual(resp[0].temperature, 23.6) + self.assertEqual(resp[0].timestamp, self.now) + + async def test_read_fluorescence(self): + self.backend.io.read.side_effect = _byte_iter( + "\x06" + + "\x03" + + "\x06" + + "0000\x03" + + "\x06" + + "0350000000000000010000000000490300000\x03" + + "\x06" + + "0000\x03" + + ( + "01,1,\r000:00:00.0,227,01,01,0000427,01,02,0000746,01,03,0000598,01,04,0000742,01,05,0001" + "516,01,06,0000704,01,07,0000676,01,08,0000734,01,09,0001126,01,10,0000790,01,11,0000531,0" + "1,12,0000531\r\n,02,12,0002066,02,11,0000541,02,10,0000618,02,09,0000629,02,08,0000891,02" + ",07,0000731,02,06,0000484,02,05,0000576,02,04,0000465,02,03,0000501,02,02,0002187,02,01,0" + "000462\r\n,03,01,0000728,03,02,0000583,03,03,0000472,03,04,0000492,03,05,0000501,03,06,00" + "00491,03,07,0000580,03,08,0000541,03,09,0000556,03,10,0000474,03,11,0000532,03,12,0000522" + "\r\n,04,12,0000570,04,11,0000523,04,10,0000784,04,09,0000441,04,08,0000703,04,07,0000591," + "04,06,0000580,04,05,0000479,04,04,0000474,04,03,0000414,04,02,0000520,04,01,0000427\r\n,0" + "5,01,0000486,05,02,0000422,05,03,0000612,05,04,0000588,05,05,0000805,05,06,0000510,05,07," + "0001697,05,08,0000615,05,09,0001137,05,10,0000653,05,11,0000558,05,12,0000648\r\n,06,12,0" + "000765,06,11,0000487,06,10,0000683,06,09,0001068,06,08,0000721,06,07,0003269,06,06,000067" + "9,06,05,0000532,06,04,0000601,06,03,0000491,06,02,0000538,06,01,0000688\r\n,07,01,0000653" + ",07,02,0000783,07,03,0000522,07,04,0000536,07,05,0000673,07,06,0000858,07,07,0000526,07,0" + "8,0000627,07,09,0000574,07,10,0001993,07,11,0000712,07,12,0000970\r\n,08,12,0000523,08,11" + ",0000607,08,10,0003002,08,09,0000900,08,08,0000697,08,07,0000542,08,06,0000688,08,05,0000" + "622,08,04,0000555,08,03,0000542,08,02,0000742,08,01,0001118\r\n228\x1a091\x1a0000\x03" + ) + + "\x062360000\x03" # Temperature call + ) + + plate = CellVis_96_wellplate_350uL_Fb(name="plate") + resp = await self.backend.read_fluorescence( + plate=plate, + wells=plate.get_all_items(), + excitation_wavelength=485, + emission_wavelength=528, + focal_height=7.5, + ) + + self.backend.io.write.assert_any_call(b"t") + self.backend.io.write.assert_any_call(b"621720\x03") + self.backend.io.write.assert_any_call(b"D") + self.backend.io.write.assert_any_call( + b"0084010101081200012001000011001000001350001002002000485000052800000000000000000021001119" + b"\x03" + ) + self.backend.io.write.assert_any_call(b"O") + + expected_data = [ + [427.0, 746.0, 598.0, 742.0, 1516.0, 704.0, 676.0, 734.0, 1126.0, 790.0, 531.0, 531.0], + [462.0, 2187.0, 501.0, 465.0, 576.0, 484.0, 731.0, 891.0, 629.0, 618.0, 541.0, 2066.0], + [728.0, 583.0, 472.0, 492.0, 501.0, 491.0, 580.0, 541.0, 556.0, 474.0, 532.0, 522.0], + [427.0, 520.0, 414.0, 474.0, 479.0, 580.0, 591.0, 703.0, 441.0, 784.0, 523.0, 570.0], + [486.0, 422.0, 612.0, 588.0, 805.0, 510.0, 1697.0, 615.0, 1137.0, 653.0, 558.0, 648.0], + [688.0, 538.0, 491.0, 601.0, 532.0, 679.0, 3269.0, 721.0, 1068.0, 683.0, 487.0, 765.0], + [653.0, 783.0, 522.0, 536.0, 673.0, 858.0, 526.0, 627.0, 574.0, 1993.0, 712.0, 970.0], + [1118.0, 742.0, 542.0, 555.0, 622.0, 688.0, 542.0, 697.0, 900.0, 3002.0, 607.0, 523.0], + ] + self.assertEqual(len(resp), 1) + self.assertEqual(resp[0].excitation_wavelength, 485) + self.assertEqual(resp[0].emission_wavelength, 528) + self.assertEqual(resp[0].data, expected_data) + self.assertEqual(resp[0].temperature, 23.6) + self.assertEqual(resp[0].timestamp, self.now) + + async def test_parse_body_asterisks_as_nan(self): + """Unmeasured wells return ******* which should be parsed as NaN.""" + plate = CellVis_96_wellplate_350uL_Fb(name="plate") + self.backend._plate = plate + + body = ( + b"01,1,\r000:00:00.0,233," + b"01,01,4292723,01,02,*******,01,03,3492028,01,04,*******," + b"01,05,3263629,01,06,*******,01,07,3658798,01,08,*******," + b"01,09,4098038,01,10,*******,01,11,4145669,01,12,*******" + b"\r\n,02,12,*******,02,11,*******,02,10,*******,02,09,*******," + b"02,08,*******,02,07,*******,02,06,*******,02,05,*******," + b"02,04,*******,02,03,*******,02,02,*******,02,01,*******" + b"\r\n,03,01,4339544,03,02,*******,03,03,3698255,03,04,*******," + b"03,05,2832844,03,06,*******,03,07,2697772,03,08,*******," + b"03,09,2799316,03,10,*******,03,11,3675674,03,12,*******" + b"\r\n,04,12,*******,04,11,*******,04,10,4180324,04,09,4570484," + b"04,08,3466838,04,07,4408238,04,06,3750929,04,05,5540778," + b"04,04,*******,04,03,*******,04,02,*******,04,01,*******" + b"\r\n,05,01,4638239,05,02,*******,05,03,4478322,05,04,*******," + b"05,05,2829157,05,06,5192196,05,07,2045115,05,08,4018946," + b"05,09,1619483,05,10,4215321,05,11,2473302,05,12,*******" + b"\r\n,06,12,4451055,06,11,4706624,06,10,2240607,06,09,3049162," + b"06,08,2516745,06,07,4645523,06,06,3999330,06,05,*******," + b"06,04,*******,06,03,*******,06,02,*******,06,01,*******" + b"\r\n,07,01,4981153,07,02,*******,07,03,5391780,07,04,*******," + b"07,05,3862731,07,06,*******,07,07,2049449,07,08,4042665," + b"07,09,1386693,07,10,2328369,07,11,1745744,07,12,*******" + b"\r\n,08,12,*******,08,11,3927160,08,10,2585026,08,09,3184874," + b"08,08,2299700,08,07,3675092,08,06,4756136,08,05,*******," + b"08,04,*******,08,03,*******,08,02,*******,08,01,*******" + b"\r\n233\x1a014\x1a0000\x03" + ) + + result = self.backend._parse_body(body) + NaN = float("nan") + + expected = [ + [4292723, NaN, 3492028, NaN, 3263629, NaN, 3658798, NaN, 4098038, NaN, 4145669, NaN], + [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN], + [4339544, NaN, 3698255, NaN, 2832844, NaN, 2697772, NaN, 2799316, NaN, 3675674, NaN], + [NaN, NaN, NaN, NaN, 5540778, 3750929, 4408238, 3466838, 4570484, 4180324, NaN, NaN], + [ + 4638239, + NaN, + 4478322, + NaN, + 2829157, + 5192196, + 2045115, + 4018946, + 1619483, + 4215321, + 2473302, + NaN, + ], + [NaN, NaN, NaN, NaN, NaN, 3999330, 4645523, 2516745, 3049162, 2240607, 4706624, 4451055], + [4981153, NaN, 5391780, NaN, 3862731, NaN, 2049449, 4042665, 1386693, 2328369, 1745744, NaN], + [NaN, NaN, NaN, NaN, NaN, 4756136, 3675092, 2299700, 3184874, 2585026, 3927160, NaN], + ] + + for r in range(8): + for c in range(12): + e, v = expected[r][c], result[r][c] + if math.isnan(e): + self.assertTrue(v is not None and math.isnan(v), f"Expected NaN at ({r},{c}), got {v}") + else: + self.assertEqual(v, e, f"Mismatch at ({r},{c})") + + +class TestBioTekLoadingTray(unittest.IsolatedAsyncioTestCase): + """The tray must send the plate geometry to the firmware before closing (tall-plate clearance).""" + + async def asyncSetUp(self): + self.manager = unittest.mock.Mock() + self.backend = BioTekPlateReaderDriver(timeout=0.1) + self.backend.set_slow_mode = unittest.mock.AsyncMock() # type: ignore[method-assign] + self.backend.set_plate = unittest.mock.AsyncMock() # type: ignore[method-assign] + self.backend.send_command = unittest.mock.AsyncMock() # type: ignore[method-assign] + self.manager.attach_mock(self.backend.set_plate, "set_plate") + self.manager.attach_mock(self.backend.send_command, "send_command") + self.plate = CellVis_24_wellplate_3600uL_Fb(name="plate") + + async def test_close_sends_plate_geometry_then_closes(self): + await self.backend.close(plate=self.plate) + self.backend.set_plate.assert_awaited_once_with(self.plate) + self.backend.send_command.assert_awaited_once_with("A") + # set_plate must run before the close ("A") command. + self.assertEqual([call[0] for call in self.manager.mock_calls], ["set_plate", "send_command"]) + + async def test_close_without_plate_skips_set_plate(self): + await self.backend.close(plate=None) + self.backend.set_plate.assert_not_awaited() + self.backend.send_command.assert_awaited_once_with("A") + + async def test_open_sends_j(self): + await self.backend.open() + self.backend.send_command.assert_awaited_once_with("J") + + async def test_slow_mode_forwarded(self): + await self.backend.open(slow=True) + self.backend.set_slow_mode.assert_awaited_once_with(True) diff --git a/pylabrobot/agilent/biotek/cytation/__init__.py b/pylabrobot/agilent/biotek/cytation/__init__.py new file mode 100644 index 00000000000..c4c48632090 --- /dev/null +++ b/pylabrobot/agilent/biotek/cytation/__init__.py @@ -0,0 +1,3 @@ +from pylabrobot.agilent.biotek.cytation.cytation1 import Cytation1 +from pylabrobot.agilent.biotek.cytation.cytation5 import Cytation5 +from pylabrobot.agilent.biotek.cytation.microscope.microscope import CytationImagingConfig diff --git a/pylabrobot/agilent/biotek/cytation/base.py b/pylabrobot/agilent/biotek/cytation/base.py new file mode 100644 index 00000000000..efb050b3b48 --- /dev/null +++ b/pylabrobot/agilent/biotek/cytation/base.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import abc +import logging +from typing import Optional + +from pylabrobot.agilent.biotek.plate_reader_base import BioTekPlateReaderDriver +from pylabrobot.resources import Coordinate, PlateHolder + +logger = logging.getLogger(__name__) + + +class _CytationBase(BioTekPlateReaderDriver, metaclass=abc.ABCMeta): + """Shared base for Cytation 1 and Cytation 5 devices. + + Owns the serial connection, the loading tray, and the reads and temperature control + inherited from the BioTek base. Model-specific hardware (the Cytation 5's camera) is + added by the subclass. + """ + + _model_name: str # set by subclass + + def __init__( + self, + name: str, + device_id: Optional[str] = None, + ): + super().__init__( + device_id=device_id, + human_readable_device_name=self._model_name, + ) + + self.plate_holder = PlateHolder( + name=name + "_plate_holder", + size_x=127.76, + size_y=85.48, + size_z=0, + pedestal_size_z=0, + child_location=Coordinate.zero(), + ) diff --git a/pylabrobot/agilent/biotek/cytation/cytation1.py b/pylabrobot/agilent/biotek/cytation/cytation1.py new file mode 100644 index 00000000000..15492c48ae2 --- /dev/null +++ b/pylabrobot/agilent/biotek/cytation/cytation1.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from pylabrobot.agilent.biotek.cytation.base import _CytationBase + + +class Cytation1(_CytationBase): + """Agilent BioTek Cytation 1. No camera; imaging lives on the Cytation 5.""" + + _model_name = "Agilent BioTek Cytation 1" diff --git a/pylabrobot/agilent/biotek/cytation/cytation5.py b/pylabrobot/agilent/biotek/cytation/cytation5.py new file mode 100644 index 00000000000..75d5ef1a0f0 --- /dev/null +++ b/pylabrobot/agilent/biotek/cytation/cytation5.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Optional + +from pylabrobot.agilent.biotek.cytation.base import _CytationBase +from pylabrobot.agilent.biotek.cytation.microscope.microscope import ( + CytationImagingConfig, + CytationMicroscope, +) + + +class Cytation5(_CytationBase): + """Agilent BioTek Cytation 5. Adds a camera, reached through :attr:`microscope`.""" + + _model_name = "Agilent BioTek Cytation 5" + + def __init__( + self, + name: str, + camera_serial: Optional[str] = None, + device_id: Optional[str] = None, + imaging_config: Optional[CytationImagingConfig] = None, + use_cam: bool = True, + ): + super().__init__(name=name, device_id=device_id) + + self.microscope = CytationMicroscope( + driver=self, + camera_serial=camera_serial, + imaging_config=imaging_config, + use_cam=use_cam, + ) + + async def setup(self) -> None: + await super().setup() + await self.microscope._on_setup() + + async def stop(self) -> None: + await self.microscope._on_stop() + await super().stop() diff --git a/pylabrobot/agilent/biotek/cytation/microscope/__init__.py b/pylabrobot/agilent/biotek/cytation/microscope/__init__.py new file mode 100644 index 00000000000..170fcd55801 --- /dev/null +++ b/pylabrobot/agilent/biotek/cytation/microscope/__init__.py @@ -0,0 +1,2 @@ +from pylabrobot.agilent.biotek.cytation.microscope.microscope import * +from pylabrobot.agilent.biotek.cytation.microscope.models import * diff --git a/pylabrobot/agilent/biotek/cytation/microscope/aravis_camera.py b/pylabrobot/agilent/biotek/cytation/microscope/aravis_camera.py new file mode 100644 index 00000000000..2370cc776a1 --- /dev/null +++ b/pylabrobot/agilent/biotek/cytation/microscope/aravis_camera.py @@ -0,0 +1,460 @@ +"""Standalone BlackFly camera driver using Aravis (GenICam/USB3 Vision). + +Standalone: no PLR dependencies except numpy. ``CytationMicroscope`` delegates camera +operations here; Aravis (via PyGObject) talks to the camera over USB3 Vision/GenICam. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from typing import Any, Optional + +try: + import numpy as np +except ImportError: + np = None # type: ignore[assignment] + +logger = logging.getLogger(__name__) + +# Aravis is an optional dependency. It requires: +# - System library: brew install aravis (macOS) or apt install libaravis-dev (Linux) +# - Python bindings: pip install PyGObject +# If not installed, AravisCamera.setup() will raise ImportError with instructions. +try: + # PyGObject ships no stubs, and it is absent entirely wherever the + # cytation-microscopy extra is not installed, so both codes can fire. + import gi # type: ignore[import-untyped,import-not-found] + + gi.require_version("Aravis", "0.8") + from gi.repository import ( # type: ignore[attr-defined,import-untyped,import-not-found] + Aravis, + ) + + HAS_ARAVIS = True +except (ImportError, ValueError): + HAS_ARAVIS = False + Aravis = None # type: ignore[assignment] + +# Number of pre-allocated buffers for the Aravis stream. +# For single-frame software-triggered capture, 5 is more than sufficient. +_BUFFER_COUNT = 5 + + +@dataclass +class CameraInfo: + """Discovery result for a GenICam-compatible camera. + + Returned by AravisCamera.enumerate_cameras() and get_device_info(). + Contains identification and connection metadata — no hardware access needed + after discovery. + """ + + serial_number: str + model_name: str + vendor: str + firmware_version: str + connection_type: str # "USB3" for the Cytation 5 BlackFly + + +class AravisCamera: + """BlackFly camera driver using Aravis for GenICam access over USB3 Vision. + + This class wraps all Aravis/GenICam operations for single-frame image + acquisition with software triggering. + + GenICam primer for non-camera-experts: + GenICam is a standard that defines how camera features (exposure, gain, + trigger, etc.) are named and accessed. Every compliant camera publishes + an XML file describing its features as "nodes." Aravis reads this XML + and lets you get/set node values by name (e.g., "ExposureTime", "Gain"). + Aravis provides a Python API to access these nodes via PyGObject. + + Buffer management: + Aravis uses a producer-consumer model with pre-allocated buffers. + During setup(), we allocate a small pool of buffers and push them to + the stream. When we trigger a capture, Aravis fills a buffer with image + data. We pop the buffer, copy the data to a numpy array, and push the + buffer back to the pool for reuse. The copy is necessary because the + buffer memory is owned by Aravis and will be reused. + + Usage: + camera = AravisCamera() + await camera.setup("12345678") + await camera.set_exposure(10.0) # 10 ms + image = await camera.trigger() # numpy array, Mono8 + await camera.stop() + """ + + def __init__(self) -> None: + self._camera: Optional[Any] = None # Aravis.Camera + self._device: Optional[Any] = None # Aravis.Device + self._stream: Optional[Any] = None # Aravis.Stream + self._serial_number: Optional[str] = None + self._acquiring: bool = False + self._width: int = 0 + self._height: int = 0 + self._payload_size: int = 0 + + @property + def width(self) -> int: + """Image width in pixels (read-only, from camera default).""" + return self._width + + @property + def height(self) -> int: + """Image height in pixels (read-only, from camera default).""" + return self._height + + @property + def camera(self) -> Any: + """The ``Aravis.Camera`` bound at setup. Raises before setup().""" + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + return self._camera + + @property + def device(self) -> Any: + """The ``Aravis.Device`` bound at setup. Raises before setup().""" + if self._device is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + return self._device + + @property + def stream(self) -> Any: + """The ``Aravis.Stream`` allocated at setup. Raises before setup().""" + if self._stream is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + return self._stream + + async def setup(self, serial_number: Optional[str] = None) -> None: + """Connect to camera, configure software trigger, allocate buffers. + + The software trigger configuration: + TriggerSelector=FrameStart, TriggerSource=Software, TriggerMode=On. + + Args: + serial_number: Camera serial number (e.g., "12345678"). If None, uses + the first available camera. Use enumerate_cameras() to discover + available cameras. + + Raises: + ImportError: If Aravis/PyGObject is not installed. + RuntimeError: If camera cannot be found or connected. + """ + if not HAS_ARAVIS: + raise ImportError( + "Aravis is not installed. Install it with:\n" + " macOS: brew install aravis && pip install PyGObject\n" + " Linux: sudo apt-get install libaravis-dev gobject-introspection " + "&& pip install PyGObject\n" + " Windows: pacman -S mingw-w64-x86_64-aravis (in MSYS2)" + ) + + self._serial_number = serial_number + + Aravis.update_device_list() + n_devices = Aravis.get_n_devices() + + if n_devices == 0: + raise RuntimeError("No cameras found.") + + device_id_to_connect = None + if serial_number is None: + # Use the first available camera. + device_id_to_connect = Aravis.get_device_id(0) + else: + for i in range(n_devices): + dev_serial = Aravis.get_device_serial_nbr(i) or "" + dev_id = Aravis.get_device_id(i) or "" + if serial_number in (dev_serial, dev_id): + device_id_to_connect = dev_id + break + + if device_id_to_connect is None: + raise RuntimeError( + f"Camera with serial '{serial_number}' not found. " + f"Available devices: {[Aravis.get_device_id(i) for i in range(n_devices)]}" + ) + + try: + self._camera = Aravis.Camera.new(device_id_to_connect) + except Exception as e: + raise RuntimeError( + f"Failed to connect to camera '{device_id_to_connect}'. " + f"Is the camera in use by another process? Error: {e}" + ) from e + + self._device = self.camera.get_device() + + # Configure software trigger mode. + # GenICam nodes: TriggerSelector, TriggerSource, TriggerMode are + # standard SFNC (Standard Features Naming Convention) names. + self.device.set_string_feature_value("TriggerSelector", "FrameStart") + self.device.set_string_feature_value("TriggerSource", "Software") + self.device.set_string_feature_value("TriggerMode", "On") + + # Read image dimensions and payload size from camera. + self._width = self.camera.get_region()[2] # x, y, width, height + self._height = self.camera.get_region()[3] + self._payload_size = self.camera.get_payload() + + # BlackFly/Flea3 cameras need a delay after trigger mode change. + await asyncio.sleep(1) + + # Create stream and pre-allocate buffer pool. + # Aravis requires buffers to be pushed to the stream before acquisition. + # We allocate a small pool (5 buffers) — for single-frame software + # trigger, we only use one at a time but the pool prevents starvation. + self._stream = self.camera.create_stream(None, None) + for _ in range(_BUFFER_COUNT): + self.stream.push_buffer(Aravis.Buffer.new_allocate(self._payload_size)) + + logger.info( + "AravisCamera: Connected to %s (SN: %s), %dx%d", + self.device.get_string_feature_value("DeviceModelName"), + serial_number, + self._width, + self._height, + ) + + def start_acquisition(self) -> None: + """Begin camera acquisition (no-op if already acquiring). + + After this call, the + camera is ready to receive software triggers and produce image buffers. + """ + camera = self.camera + if self._acquiring: + return + camera.start_acquisition() + self._acquiring = True + + def stop_acquisition(self) -> None: + """End camera acquisition (no-op if not acquiring). + + Stop camera acquisition. + """ + camera = self.camera + if not self._acquiring: + return + camera.stop_acquisition() + self._acquiring = False + + async def trigger(self, timeout_ms: int = 5000) -> np.ndarray: + """Capture a single frame: software trigger → grab. + + Acquisition must already be started via start_acquisition(). + Start/stop acquisition bracket the capture loop, not each individual frame. + + Args: + timeout_ms: Maximum time to wait for image buffer, in milliseconds. + Default 5000 (5 seconds). + + Returns: + numpy.ndarray: Image as 2D uint8 array (height × width), Mono8 format. + + Raises: + RuntimeError: If camera not initialized, not acquiring, or times out. + """ + _ = self.camera + if not self._acquiring: + raise RuntimeError("Camera is not acquiring. Call start_acquisition() first.") + + # Send software trigger command. + self.device.execute_command("TriggerSoftware") + + # Pop the filled buffer from the stream. + # timeout_pop_buffer takes microseconds, so convert from ms. + buffer = self.stream.timeout_pop_buffer(timeout_ms * 1000) + if buffer is None: + raise RuntimeError( + f"Camera capture timed out after {timeout_ms}ms. " + "Is the camera connected and trigger mode configured?" + ) + + # Extract image data and copy to numpy array. + data = buffer.get_data() + image = np.frombuffer(data, dtype=np.uint8).reshape(self._height, self._width).copy() + + # Return buffer to pool for reuse. + self.stream.push_buffer(buffer) + + return image + + async def stop(self) -> None: + """Release camera and free all Aravis resources. + + Safe to call at any point — + stops acquisition if active, resets trigger mode, releases camera. + """ + try: + if self._acquiring and self._camera is not None: + self.stop_acquisition() + + if self._device is not None: + try: + self._device.set_string_feature_value("TriggerMode", "Off") + except Exception: + pass # Camera may already be disconnected + finally: + self._camera = None + self._device = None + self._stream = None + self._serial_number = None + self._acquiring = False + self._width = 0 + self._height = 0 + self._payload_size = 0 + + async def set_exposure(self, exposure_ms: float) -> None: + """Set exposure time in milliseconds. + + Disables auto-exposure first, then sets the GenICam ExposureTime node. + GenICam uses microseconds internally; this method handles the conversion. + + Args: + exposure_ms: Exposure time in milliseconds (e.g., 10.0 = 10 ms). + """ + camera = self.camera + # Disable auto-exposure before setting manual value. + self.device.set_string_feature_value("ExposureAuto", "Off") + # GenICam ExposureTime is in microseconds. + exposure_us = exposure_ms * 1000.0 + camera.set_exposure_time(exposure_us) + + async def get_exposure(self) -> float: + """Read current exposure time in milliseconds (from hardware, not cached).""" + exposure_us = self.camera.get_exposure_time() + return float(exposure_us) / 1000.0 + + async def set_gain(self, gain: float) -> None: + """Set gain value. + + Disables auto-gain first, then sets the GenICam Gain node. + The gain range depends on the camera model (typically 0-30 for BlackFly). + + Args: + gain: Gain value (e.g., 1.0). + """ + camera = self.camera + self.device.set_string_feature_value("GainAuto", "Off") + camera.set_gain(gain) + + async def set_auto_gain(self, mode: str) -> None: + """Set auto-gain mode. + + Args: + mode: One of "off", "once", "continuous". Maps to GenICam + GainAuto node values: Off, Once, Continuous. + """ + _ = self.camera + mode_map = {"off": "Off", "once": "Once", "continuous": "Continuous"} + aravis_mode = mode_map.get(mode.lower()) + if aravis_mode is None: + raise ValueError(f"Invalid auto-gain mode '{mode}'. Use 'off', 'once', or 'continuous'.") + self.device.set_string_feature_value("GainAuto", aravis_mode) + + async def get_gain(self) -> float: + """Read current gain value (from hardware, not cached).""" + return float(self.camera.get_gain()) + + async def set_auto_exposure(self, mode: str) -> None: + """Set auto-exposure mode. + + Args: + mode: One of "off", "once", "continuous". Maps to GenICam + ExposureAuto node values: Off, Once, Continuous. + """ + _ = self.camera + mode_map = {"off": "Off", "once": "Once", "continuous": "Continuous"} + aravis_mode = mode_map.get(mode.lower()) + if aravis_mode is None: + raise ValueError(f"Invalid auto-exposure mode '{mode}'. Use 'off', 'once', or 'continuous'.") + self.device.set_string_feature_value("ExposureAuto", aravis_mode) + + async def set_pixel_format(self, fmt: Optional[int] = None) -> None: + """Set pixel format. Default is Mono8. + + Must be called before start_acquisition(). The format value is an + Aravis pixel format constant (e.g., Aravis.PIXEL_FORMAT_MONO_8). + + Args: + fmt: Aravis pixel format constant. If None, uses Mono8. + """ + camera = self.camera + if fmt is None: + if HAS_ARAVIS: + fmt = Aravis.PIXEL_FORMAT_MONO_8 + else: + return + camera.set_pixel_format(fmt) + + def get_device_info(self) -> CameraInfo: + """Read camera identification from GenICam nodes. + + Returns model name, serial number, vendor, and firmware version + without requiring acquisition to be active. + + Returns: + CameraInfo with fields populated from the camera's GenICam XML. + """ + device = self.device + return CameraInfo( + serial_number=device.get_string_feature_value("DeviceSerialNumber"), + model_name=device.get_string_feature_value("DeviceModelName"), + vendor=device.get_string_feature_value("DeviceVendorName"), + firmware_version=device.get_string_feature_value("DeviceFirmwareVersion"), + connection_type="USB3", + ) + + @staticmethod + def enumerate_cameras() -> list[CameraInfo]: + """List all connected GenICam-compatible cameras. + + Uses Aravis device enumeration — finds cameras across USB3 Vision + and GigE Vision transports (though this driver targets USB3 only). + + Returns: + List of CameraInfo for each detected camera. Empty list if none + found (not an error — matches PLR's graceful enumeration pattern). + + Raises: + ImportError: If Aravis/PyGObject is not installed. + """ + if not HAS_ARAVIS: + raise ImportError( + "Aravis is not installed. Install it with:\n" + " macOS: brew install aravis && pip install PyGObject\n" + " Linux: sudo apt-get install libaravis-dev gobject-introspection " + "&& pip install PyGObject\n" + " Windows: pacman -S mingw-w64-x86_64-aravis (in MSYS2)" + ) + + Aravis.update_device_list() + n_devices = Aravis.get_n_devices() + cameras: list[CameraInfo] = [] + + for i in range(n_devices): + # Parse info from device ID string without opening the camera. + # Opening the camera here would lock the USB device and prevent + # a subsequent setup() from connecting (GObject doesn't release + # the USB handle reliably on del). + device_id = Aravis.get_device_id(i) + # device_id format varies: "USB3Vision-vendor-model-serial" or similar + serial = Aravis.get_device_serial_nbr(i) or device_id + model = Aravis.get_device_model(i) or "Unknown" + vendor = Aravis.get_device_vendor(i) or "Unknown" + protocol = Aravis.get_device_protocol(i) or "USB3" + + info = CameraInfo( + serial_number=serial, + model_name=model, + vendor=vendor, + firmware_version="", # Not available without opening camera + connection_type=protocol, + ) + cameras.append(info) + + return cameras diff --git a/pylabrobot/agilent/biotek/cytation/microscope/microscope.py b/pylabrobot/agilent/biotek/cytation/microscope/microscope.py new file mode 100644 index 00000000000..3b33ef459e4 --- /dev/null +++ b/pylabrobot/agilent/biotek/cytation/microscope/microscope.py @@ -0,0 +1,640 @@ +"""The Cytation's microscope. + +Owns the AravisCamera and all imaging state/logic: optics control (filter wheel, +objectives, focus, LED, stage positioning), camera control (exposure, gain, +acquisition), and capture orchestration (tiling, retry). + +Optics commands go over the plate reader's serial connection (``send_command``); frames +come from the AravisCamera (GenICam/USB3 Vision). +""" + +from __future__ import annotations + +import asyncio +import logging +import math +import re +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Literal, Optional, Tuple, Union + +from pylabrobot.agilent.biotek.cytation.microscope.models import ( + Exposure, + FocalPosition, + Gain, + Image, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.resources.plate import Plate + +from .aravis_camera import AravisCamera + +if TYPE_CHECKING: + from pylabrobot.agilent.biotek.plate_reader_base import BioTekPlateReaderDriver + +logger = logging.getLogger(__name__) + +# Color brightfield is not a fixed imaging mode. Instead of a single illuminated acquisition, the +# instrument illuminates the sample sequentially with three separate LED colors and the host +# combines the resulting mono frames into one RGB image. These are the firmware LED codes for the +# three colors, in the order they are cycled (red, green, blue), discovered by reverse-engineering +# a Gen5 color-brightfield capture: per color the firmware sends `i L0{code}10` (LED on) followed +# by `i l{code}` (strobe) before each camera trigger. White brightfield (code 5) is used only for +# setup/focus, never in the cycle. +COLOR_BRIGHTFIELD_LED_CODES = (6, 7, 8) + + +@dataclass +class CytationImagingConfig: + """Imaging configuration for the Cytation.""" + + camera_serial_number: Optional[str] = None + filters: Optional[List[Optional[ImagingMode]]] = None + objectives: Optional[List[Optional[Objective]]] = None + max_image_read_attempts: int = 50 + image_read_delay: float = 0.3 + + +class CytationMicroscope: + """The Cytation's microscope. + + Owns the AravisCamera and all imaging state, and drives the optics over the plate + reader's serial connection. + """ + + def __init__( + self, + driver: BioTekPlateReaderDriver, + camera_serial: Optional[str] = None, + imaging_config: Optional[CytationImagingConfig] = None, + use_cam: bool = True, + ) -> None: + self.driver = driver + self.camera = AravisCamera() + self._camera_serial = camera_serial + self._use_cam = use_cam + self.imaging_config = imaging_config or CytationImagingConfig( + camera_serial_number=camera_serial + ) + + # Imaging state + self._filters: Optional[List[Optional[ImagingMode]]] = self.imaging_config.filters + self._objectives: Optional[List[Optional[Objective]]] = self.imaging_config.objectives + self._exposure: Optional[Exposure] = None + self._focal_height: Optional[FocalPosition] = None + self._gain: Optional[Gain] = None + self._imaging_mode: Optional[ImagingMode] = None + self._row: Optional[int] = None + self._column: Optional[int] = None + self._pos_x: Optional[float] = None + self._pos_y: Optional[float] = None + self._objective: Optional[Objective] = None + self._acquiring = False + + async def _on_setup(self) -> None: + """Connect camera (if use_cam) and load filters/objectives from firmware.""" + if self._use_cam: + serial = self._camera_serial or ( + self.imaging_config.camera_serial_number if self.imaging_config else None + ) + await self.camera.setup(serial_number=serial) + logger.info("Camera connected: %s", self.camera.get_device_info()) + + if self._filters is None: + await self._load_filters() + if self._objectives is None: + await self._load_objectives() + + async def _on_stop(self) -> None: + self._clear_imaging_state() + try: + await self.camera.stop() + except Exception: + logger.exception("Error stopping camera") + + # ─── Properties ───────────────────────────────────────────────────── + + @property + def filters(self) -> List[Optional[ImagingMode]]: + if self._filters is None: + raise RuntimeError("Filters not loaded. Call setup() first.") + return self._filters + + @property + def objectives(self) -> List[Optional[Objective]]: + if self._objectives is None: + raise RuntimeError("Objectives not loaded. Call setup() first.") + return self._objectives + + def _clear_imaging_state(self) -> None: + self._exposure = None + self._focal_height = None + self._gain = None + self._imaging_mode = None + self._row = None + self._column = None + self._pos_x = None + self._pos_y = None + self._objective = None + self._acquiring = False + + # ─── Filter / Objective Discovery ──────────────────────────────────── + + async def _load_filters(self) -> None: + """Discover installed filter cube positions from firmware.""" + cytation_code2imaging_mode = { + 1225121: ImagingMode.C377_647, + 1225123: ImagingMode.C400_647, + 1225113: ImagingMode.C469_593, + 1225109: ImagingMode.ACRIDINE_ORANGE, + 1225107: ImagingMode.CFP, + 1225118: ImagingMode.CFP_FRET_V2, + 1225110: ImagingMode.CFP_YFP_FRET, + 1225119: ImagingMode.CFP_YFP_FRET_V2, + 1225112: ImagingMode.CHLOROPHYLL_A, + 1225105: ImagingMode.CY5, + 1225114: ImagingMode.CY5_5, + 1225106: ImagingMode.CY7, + 1225100: ImagingMode.DAPI, + 1225101: ImagingMode.GFP, + 1225116: ImagingMode.GFP_CY5, + 1225122: ImagingMode.OXIDIZED_ROGFP2, + 1225111: ImagingMode.PROPIDIUM_IODIDE, + 1225103: ImagingMode.RFP, + 1225117: ImagingMode.RFP_CY5, + 1225115: ImagingMode.TAG_BFP, + 1225102: ImagingMode.TEXAS_RED, + 1225104: ImagingMode.YFP, + } + + self._filters = [] + for slot in range(1, 5): + configuration = await self.driver.send_command("i", f"q{slot}") + assert configuration is not None + parts = configuration.decode().strip().split(" ") + if len(parts) == 1: + self._filters.append(None) + else: + cytation_code = int(parts[0]) + self._filters.append(cytation_code2imaging_mode.get(cytation_code, None)) + + logger.info("Loaded filters: %s", self._filters) + + async def _load_objectives(self) -> None: + """Discover installed objective positions from firmware.""" + weird_encoding = { + 0x00: " ", + 0x14: ".", + 0x15: "/", + 0x16: "0", + 0x17: "1", + 0x18: "2", + 0x19: "3", + 0x20: "4", + 0x21: "5", + 0x22: "6", + 0x23: "7", + 0x24: "8", + 0x25: "9", + 0x33: "A", + 0x34: "B", + 0x35: "C", + 0x36: "D", + 0x37: "E", + 0x38: "F", + 0x39: "G", + 0x40: "H", + 0x41: "I", + 0x42: "J", + 0x43: "K", + 0x44: "L", + 0x45: "M", + 0x46: "N", + 0x47: "O", + 0x48: "P", + 0x49: "Q", + 0x50: "R", + 0x51: "S", + 0x52: "T", + 0x53: "U", + 0x54: "V", + 0x55: "W", + 0x56: "X", + 0x57: "Y", + 0x58: "Z", + } + part_number2objective = { + "uplsapo 40x2": Objective.O_40X_PL_APO, + "lucplfln 60X": Objective.O_60X_PL_FL, + "uplfln 4x": Objective.O_4X_PL_FL, + "lucplfln 20xph": Objective.O_20X_PL_FL_Phase, + "lucplfln 40xph": Objective.O_40X_PL_FL_Phase, + "u plan": Objective.O_2_5X_PL_ACH_Meiji, + "uplfln 10xph": Objective.O_10X_PL_FL_Phase, + "plapon 1.25x": Objective.O_1_25X_PL_APO, + "uplfln 10x": Objective.O_10X_PL_FL, + "uplfln 60xoi": Objective.O_60X_OIL_PL_FL, + "pln 4x": Objective.O_4X_PL_ACH, + "pln 40x": Objective.O_40X_PL_ACH, + "lucplfln 40x": Objective.O_40X_PL_FL, + "ec-h-plan/2x": Objective.O_2X_PL_ACH_Motic, + "uplfln 100xO2": Objective.O_100X_OIL_PL_FL, + "uplfln 4xph": Objective.O_4X_PL_FL_Phase, + "lucplfln 20X": Objective.O_20X_PL_FL, + "pln 20x": Objective.O_20X_PL_ACH, + "fluar 2.5x/0.12": Objective.O_2_5X_FL_Zeiss, + "uplsapo 100xo": Objective.O_100X_OIL_PL_APO, + "plapon 60xo": Objective.O_60X_OIL_PL_APO, + "uplsapo 20x": Objective.O_20X_PL_APO, + } + + self._objectives = [] + version = self.driver.version + if version.startswith("1"): + for slot in [1, 2]: + configuration = await self.driver.send_command("i", f"o{slot}") + if configuration is None: + raise RuntimeError("Failed to load objective configuration") + middle_part = re.split(r"\s+", configuration.rstrip(b"\x03").decode("utf-8"))[1] + if middle_part == "0000": + self._objectives.append(None) + else: + part_number = "".join([weird_encoding[x] for x in bytes.fromhex(middle_part)]) + self._objectives.append(part_number2objective.get(part_number.lower(), None)) + elif version.startswith("2"): + for slot in range(1, 7): + configuration = await self.driver.send_command("i", f"h{slot + 1}") + assert configuration is not None + if configuration.startswith(b"****"): + self._objectives.append(None) + else: + annulus_code = int(configuration.decode("latin").strip().split(" ")[0]) + annulus2objective = { + 1320520: Objective.O_4X_PL_FL_Phase, + 1320521: Objective.O_20X_PL_FL_Phase, + 1322026: Objective.O_40X_PL_FL_Phase, + } + self._objectives.append(annulus2objective.get(annulus_code, None)) + else: + raise RuntimeError(f"Unsupported firmware version: {version}") + + logger.info("Loaded objectives: %s", self._objectives) + + # ─── Camera Control ────────────────────────────────────────────────── + + async def set_exposure(self, exposure: Exposure) -> None: + """Set camera exposure time in ms.""" + if exposure == self._exposure: + return + + if isinstance(exposure, str): + if exposure == "machine-auto": + await self.camera.set_auto_exposure("continuous") + self._exposure = "machine-auto" + return + raise ValueError("exposure must be a number or 'machine-auto'") + await self.camera.set_auto_exposure("off") + await self.camera.set_exposure(float(exposure)) + self._exposure = exposure + + async def set_gain(self, gain: Gain) -> None: + """Set camera gain.""" + if gain == self._gain: + return + + if gain == "machine-auto": + await self.camera.set_auto_gain("continuous") + else: + await self.camera.set_gain(float(gain)) + + self._gain = gain + + async def set_auto_exposure(self, auto_exposure: Literal["off", "once", "continuous"]) -> None: + """Set camera auto-exposure mode.""" + await self.camera.set_auto_exposure(auto_exposure) + + def start_acquisition(self) -> None: + """Start camera acquisition (buffered streaming).""" + self.camera.start_acquisition() + self._acquiring = True + + def stop_acquisition(self) -> None: + """Stop camera acquisition.""" + if self._acquiring: + self.camera.stop_acquisition() + self._acquiring = False + + async def acquire_image(self) -> Image: + """Trigger camera and read image, with retry on failure.""" + config = self.imaging_config + for attempt in range(config.max_image_read_attempts): + try: + return await self.camera.trigger(timeout_ms=5000) + except Exception: + if attempt < config.max_image_read_attempts - 1: + logger.warning("Failed to get image, retrying...") + self.stop_acquisition() + self.start_acquisition() + await asyncio.sleep(config.image_read_delay) + else: + raise + raise TimeoutError("max_image_read_attempts reached") + + async def _set_color_brightfield_led(self, led_code: int, intensity: int) -> None: + """Switch color-brightfield illumination to a single LED color and strobe it. + + Unlike other modes, color brightfield has no static LED state: the instrument turns on one of + the three LED colors (``i L0{led_code}{intensity}``), strobes it (``i l{led_code}``), then the + camera is triggered, repeating once per color. See ``COLOR_BRIGHTFIELD_LED_CODES``. + """ + if not 1 <= intensity <= 10: + raise ValueError("intensity must be between 1 and 10") + intensity_str = str(intensity).zfill(2) + await self.driver.send_command("i", f"L0{led_code}{intensity_str}") + await self.driver.send_command("i", f"l{led_code}") + + async def _acquire_color_brightfield_image(self, led_intensity: int) -> Image: + """Acquire one color-brightfield image. + + Captures a mono frame under each of the three brightfield LED colors and stacks them into an + ``(H, W, 3)`` RGB image. A single exposure/gain (set by the caller) is used for all three + channels; Gen5's per-color auto-exposure is not replicated. + """ + import numpy as np + + channels: List[Image] = [] + for led_code in COLOR_BRIGHTFIELD_LED_CODES: + await self._set_color_brightfield_led(led_code, intensity=led_intensity) + channels.append(await self.acquire_image()) + return np.stack(channels, axis=-1) + + async def get_exposure(self) -> float: + """Get current exposure time in ms.""" + return await self.camera.get_exposure() + + # ─── Optics Control (serial protocol) ──────────────────────────────── + + def _imaging_mode_code(self, mode: ImagingMode) -> int: + """Get filter wheel position index for an imaging mode.""" + if mode in ( + ImagingMode.BRIGHTFIELD, + ImagingMode.PHASE_CONTRAST, + ImagingMode.COLOR_BRIGHTFIELD, + ): + # Color brightfield uses the brightfield light path (filter cube 5); its three colors are + # selected via LED codes 6/7/8 during acquisition, not here. + return 5 + for i, f in enumerate(self.filters): + if f == mode: + return i + 1 + raise ValueError(f"Mode {mode} not found in filters: {self.filters}") + + def _objective_code(self, objective: Objective) -> int: + """Get turret position index for an objective.""" + for i, o in enumerate(self.objectives): + if o == objective: + return i + 1 + raise ValueError(f"Objective {objective} not found: {self.objectives}") + + async def set_imaging_mode(self, mode: ImagingMode, led_intensity: int = 10) -> None: + """Set filter wheel position and LED.""" + if mode == self._imaging_mode: + # Color brightfield has no single LED state to (re)enable; its colors are cycled per frame + # during acquisition (see _acquire_color_brightfield_image). + if mode != ImagingMode.COLOR_BRIGHTFIELD: + await self.led_on(intensity=led_intensity) + return + + await self.led_off() + filter_index = self._imaging_mode_code(mode) + + if self.driver.version.startswith("1"): + if mode == ImagingMode.PHASE_CONTRAST: + raise NotImplementedError("Phase contrast not implemented on Cytation 1") + elif mode == ImagingMode.COLOR_BRIGHTFIELD: + raise NotImplementedError("Color brightfield not implemented on Cytation 1") + elif mode == ImagingMode.BRIGHTFIELD: + await self.driver.send_command("Y", "P0c05") + await self.driver.send_command("Y", "P0f02") + else: + await self.driver.send_command("Y", f"P0c{filter_index:02}") + await self.driver.send_command("Y", "P0f01") + else: + if mode == ImagingMode.PHASE_CONTRAST: + await self.driver.send_command("Y", "P1120") + await self.driver.send_command("Y", "P0d05") + await self.driver.send_command("Y", "P1002") + elif mode in (ImagingMode.BRIGHTFIELD, ImagingMode.COLOR_BRIGHTFIELD): + # Color brightfield uses the same light path as brightfield; the three colors are + # selected via LED codes during acquisition. + await self.driver.send_command("Y", "P1101") + await self.driver.send_command("Y", "P0d05") + await self.driver.send_command("Y", "P1002") + else: + await self.driver.send_command("Y", "P1101") + await self.driver.send_command("Y", f"P0d{filter_index:02}") + await self.driver.send_command("Y", "P1001") + + # Color brightfield has no single LED state; its colors are cycled per frame during + # acquisition (see _acquire_color_brightfield_image). + self._imaging_mode = mode + if mode != ImagingMode.COLOR_BRIGHTFIELD: + await self.led_on(intensity=led_intensity) + + async def set_objective(self, objective: Objective) -> None: + """Rotate objective turret to the specified objective.""" + if objective == self._objective: + return + obj_code = self._objective_code(objective) + if self.driver.version.startswith("1"): + await self.driver.send_command("Y", f"P0d{obj_code:02}", timeout=60) + else: + await self.driver.send_command("Y", f"P0e{obj_code:02}", timeout=60) + self._objective = objective + self._imaging_mode = None # force re-set after objective change + + async def set_focus(self, focal_position: FocalPosition) -> None: + """Move focus motor to the specified height (mm).""" + if focal_position == "machine-auto": + raise ValueError("focal_position cannot be 'machine-auto'. Use PLR's auto-focus instead.") + + if focal_position == self._focal_height: + return + + slope, intercept = (10.637991436186072, 1.0243013203461762) + focus_integer = int(float(focal_position) + intercept + slope * float(focal_position) * 1000) + focus_str = str(focus_integer).zfill(5) + + if self._imaging_mode is None: + raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") + imaging_mode_code = self._imaging_mode_code(self._imaging_mode) + await self.driver.send_command("i", f"F{imaging_mode_code}0{focus_str}") + + self._focal_height = focal_position + + async def led_on(self, intensity: int = 10) -> None: + """Turn on LED at specified intensity (1-10).""" + if not 1 <= intensity <= 10: + raise ValueError("intensity must be between 1 and 10") + if self._imaging_mode is None: + raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") + imaging_mode_code = self._imaging_mode_code(self._imaging_mode) + intensity_str = str(intensity).zfill(2) + await self.driver.send_command("i", f"L0{imaging_mode_code}{intensity_str}") + + async def led_off(self) -> None: + """Turn off LED.""" + await self.driver.send_command("i", "L0001") + + async def select(self, row: int, column: int) -> None: + """Move plate stage to a well position.""" + if row == self._row and column == self._column: + return + row_str = str(row).zfill(2) + col_str = str(column).zfill(2) + await self.driver.send_command("Y", f"W6{row_str}{col_str}") + self._row, self._column = row, column + self._pos_x, self._pos_y = None, None + await self.set_position(0, 0) + + async def set_position(self, x: float, y: float) -> None: + """Fine-position the plate stage within a well (mm).""" + if self._imaging_mode is None: + raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") + + if x == self._pos_x and y == self._pos_y: + return + + x_str = str(round(x * 100 * 0.984)).zfill(6) + y_str = str(round(y * 100 * 0.984)).zfill(6) + + if self._row is None or self._column is None: + raise ValueError("Row and column not set. Call select() first.") + row_str = str(self._row).zfill(2) + column_str = str(self._column).zfill(2) + + if self._objective is None: + raise ValueError("Objective not set. Call set_objective() first.") + objective_code = self._objective_code(self._objective) + imaging_mode_code = self._imaging_mode_code(self._imaging_mode) + await self.driver.send_command( + "Y", + f"Z{objective_code}{imaging_mode_code}6{row_str}{column_str}{y_str}{x_str}", + ) + + relative_x = x - (self._pos_x or 0) + relative_y = y - (self._pos_y or 0) + if relative_x != 0: + relative_x_str = str(round(relative_x * 100 * 0.984)).zfill(6) + await self.driver.send_command("Y", f"O00{relative_x_str}") + if relative_y != 0: + relative_y_str = str(round(relative_y * 100 * 0.984)).zfill(6) + await self.driver.send_command("Y", f"O01{relative_y_str}") + + self._pos_x, self._pos_y = x, y + await asyncio.sleep(0.1) + + async def capture( + self, + row: int, + column: int, + mode: ImagingMode, + objective: Objective, + exposure_time: Exposure, + focal_height: FocalPosition, + gain: Gain, + plate: Plate, + led_intensity: int = 10, + coverage: Union[Literal["full"], Tuple[int, int]] = (1, 1), + center_position: Optional[Tuple[float, float]] = None, + overlap: Optional[float] = None, + auto_stop_acquisition: bool = True, + ) -> ImagingResult: + """Capture an image of a well. + + Args: + led_intensity: LED intensity (1-10). + coverage: Image tiling coverage. ``"full"`` for full-well montage, or a + ``(rows, cols)`` tuple for a specific tile grid. ``(1, 1)`` is a single image. + center_position: Center position of the capture area as ``(x_mm, y_mm)`` relative + to the well center. If None, centers on the well. + overlap: Fractional overlap between tiles (0.0-1.0) for montage stitching. + If None, no overlap. Only used when coverage produces multiple tiles. + auto_stop_acquisition: Whether to automatically stop image acquisition after capture. + """ + if overlap is not None: + raise NotImplementedError("overlap is not implemented yet") + + await self.driver.set_plate(plate) + + if not self._acquiring: + self.start_acquisition() + + try: + await self.set_objective(objective) + await self.set_imaging_mode(mode, led_intensity=led_intensity) + await self.select(row, column) + await self.set_exposure(exposure_time) + await self.set_gain(gain) + await self.set_focus(focal_height) + + def image_size(magnification: float) -> Tuple[float, float]: + if magnification == 4: + return (3474 / 1000, 3474 / 1000) + if magnification == 10: + # Estimated from the ~13880 um * magnification^-1 pattern of the other entries; the + # 4/20/40x values are measured. Refine with a measured 10x field of view if available. + return (1388 / 1000, 1388 / 1000) + if magnification == 20: + return (694 / 1000, 694 / 1000) + if magnification == 40: + return (347 / 1000, 347 / 1000) + raise ValueError(f"Don't know image size for magnification {magnification}") + + if self._objective is None: + raise RuntimeError("Objective not set. Run set_objective() first.") + magnification = self._objective.magnification + img_width, img_height = image_size(magnification) + + first_well = plate.get_item(0) + well_size_x, well_size_y = (first_well.get_size_x(), first_well.get_size_y()) + if coverage == "full": + coverage = ( + math.ceil(well_size_x / image_size(magnification)[0]), + math.ceil(well_size_y / image_size(magnification)[1]), + ) + rows, cols = coverage + + if center_position is None: + center_position = (0, 0) + positions = [ + (x * img_width + center_position[0], -y * img_height + center_position[1]) + for y in [i - (rows - 1) / 2 for i in range(rows)] + for x in [i - (cols - 1) / 2 for i in range(cols)] + ] + + images: List[Image] = [] + for x_pos, y_pos in positions: + await self.set_position(x=x_pos, y=y_pos) + t0 = time.time() + if mode == ImagingMode.COLOR_BRIGHTFIELD: + images.append(await self._acquire_color_brightfield_image(led_intensity=led_intensity)) + else: + images.append(await self.acquire_image()) + t1 = time.time() + logger.debug("[cytation] acquired image in %.2f seconds", t1 - t0) + finally: + try: + await self.led_off() + except Exception: + logger.exception("Failed to turn off LED during cleanup") + if auto_stop_acquisition: + self.stop_acquisition() + + exposure_ms = await self.get_exposure() + assert self._focal_height is not None + focal_height_val = float(self._focal_height) + + return ImagingResult(images=images, exposure_time=exposure_ms, focal_height=focal_height_val) diff --git a/pylabrobot/agilent/biotek/cytation/microscope/microscope_tests.py b/pylabrobot/agilent/biotek/cytation/microscope/microscope_tests.py new file mode 100644 index 00000000000..5c6b1d16750 --- /dev/null +++ b/pylabrobot/agilent/biotek/cytation/microscope/microscope_tests.py @@ -0,0 +1,74 @@ +import unittest +import unittest.mock + +import pytest + +pytest.importorskip("numpy") + +import numpy as np + +from pylabrobot.agilent.biotek.cytation.microscope.microscope import ( + COLOR_BRIGHTFIELD_LED_CODES, + CytationMicroscope, +) +from pylabrobot.agilent.biotek.cytation.microscope.models import ImagingMode + + +def _make_backend(version: str = "2.07"): + """A microscope with a fully mocked serial driver (no hardware).""" + driver = unittest.mock.MagicMock() + driver.version = version + driver.send_command = unittest.mock.AsyncMock() + backend = CytationMicroscope(driver=driver, use_cam=False) + return backend, driver + + +class TestColorBrightfield(unittest.IsolatedAsyncioTestCase): + """Color brightfield illuminates with three LEDs (codes 6/7/8) and stacks the frames.""" + + def test_imaging_mode_code_uses_brightfield_light_path(self): + backend, _ = _make_backend() + # Color brightfield shares the brightfield filter cube (5); colors come from the LEDs. + self.assertEqual(backend._imaging_mode_code(ImagingMode.COLOR_BRIGHTFIELD), 5) + + async def test_acquire_cycles_three_leds_and_stacks_rgb(self): + backend, driver = _make_backend() + frame = np.zeros((4, 5), dtype=np.uint8) + backend.acquire_image = unittest.mock.AsyncMock(return_value=frame) + + image = await backend._acquire_color_brightfield_image(led_intensity=10) + + # One mono frame per color, stacked into an (H, W, 3) RGB image. + self.assertEqual(image.shape, (4, 5, 3)) + self.assertEqual(backend.acquire_image.await_count, 3) + + # Per color, in cycle order: LED on (L0{code}10) then strobe (l{code}). + expected = [] + for code in COLOR_BRIGHTFIELD_LED_CODES: + expected.append(unittest.mock.call("i", f"L0{code}10")) + expected.append(unittest.mock.call("i", f"l{code}")) + self.assertEqual(driver.send_command.await_args_list, expected) + + async def test_set_imaging_mode_matches_brightfield_setup_without_static_led(self): + backend, driver = _make_backend(version="2.07") + backend._imaging_mode = None + + await backend.set_imaging_mode(ImagingMode.COLOR_BRIGHTFIELD, led_intensity=10) + + sent = [call.args for call in driver.send_command.await_args_list] + # Same optics setup as plain brightfield (filter cube 5). + for cmd in (("Y", "P1101"), ("Y", "P0d05"), ("Y", "P1002")): + self.assertIn(cmd, sent) + # No single LED is enabled (colors are cycled during acquisition): only led_off (L0001). + self.assertEqual([p for (ch, p) in sent if ch == "i"], ["L0001"]) + self.assertEqual(backend._imaging_mode, ImagingMode.COLOR_BRIGHTFIELD) + + async def test_color_brightfield_not_supported_on_cytation1(self): + backend, _ = _make_backend(version="1.05") + backend._imaging_mode = None + with self.assertRaises(NotImplementedError): + await backend.set_imaging_mode(ImagingMode.COLOR_BRIGHTFIELD, led_intensity=10) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/biotek/cytation/microscope/models.py b/pylabrobot/agilent/biotek/cytation/microscope/models.py new file mode 100644 index 00000000000..284184432bb --- /dev/null +++ b/pylabrobot/agilent/biotek/cytation/microscope/models.py @@ -0,0 +1,130 @@ +import enum +import sys +from dataclasses import dataclass +from typing import Awaitable, Callable, List, Literal, Union + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + from typing_extensions import TypeAlias + +try: + import numpy.typing as npt # type: ignore + + Image: TypeAlias = npt.NDArray +except ImportError: + Image: TypeAlias = object # type: ignore + + +class Objective(enum.Enum): + # Cytation objectives + O_1_25X_PL_APO = enum.auto() + O_2X_PL_ACH_Motic = enum.auto() + O_2_5X_PL_ACH_Meiji = enum.auto() + O_2_5X_FL_Zeiss = enum.auto() + O_2_5X_N_PLAN = enum.auto() + O_4X_PL_FL = enum.auto() + O_4X_PL_ACH = enum.auto() + O_4X_PL_FL_Phase = enum.auto() + O_10X_PL_FL = enum.auto() + O_10X_PL_FL_Phase = enum.auto() + O_20X_PL_FL = enum.auto() + O_20X_PL_ACH = enum.auto() + O_20X_PL_FL_Phase = enum.auto() + O_20X_PL_APO = enum.auto() + O_40X_PL_FL = enum.auto() + O_40X_PL_ACH = enum.auto() + O_40X_PL_APO = enum.auto() + O_40X_PL_FL_Phase = enum.auto() + O_60X_PL_FL = enum.auto() + O_60X_OIL_PL_FL = enum.auto() + O_60X_OIL_PL_APO = enum.auto() + O_100X_OIL_PL_FL = enum.auto() + O_100X_OIL_PL_APO = enum.auto() + + @property + def magnification(self) -> float: + return { + Objective.O_1_25X_PL_APO: 1.25, + Objective.O_2X_PL_ACH_Motic: 2, + Objective.O_2_5X_PL_ACH_Meiji: 2.5, + Objective.O_2_5X_FL_Zeiss: 2.5, + Objective.O_2_5X_N_PLAN: 2.5, + Objective.O_4X_PL_FL: 4, + Objective.O_4X_PL_ACH: 4, + Objective.O_4X_PL_FL_Phase: 4, + Objective.O_10X_PL_FL: 10, + Objective.O_10X_PL_FL_Phase: 10, + Objective.O_20X_PL_FL: 20, + Objective.O_20X_PL_ACH: 20, + Objective.O_20X_PL_FL_Phase: 20, + Objective.O_20X_PL_APO: 20, + Objective.O_40X_PL_FL: 40, + Objective.O_40X_PL_ACH: 40, + Objective.O_40X_PL_APO: 40, + Objective.O_40X_PL_FL_Phase: 40, + Objective.O_60X_PL_FL: 60, + Objective.O_60X_OIL_PL_FL: 60, + Objective.O_60X_OIL_PL_APO: 60, + Objective.O_100X_OIL_PL_FL: 100, + Objective.O_100X_OIL_PL_APO: 100, + }[self] + + +class ImagingMode(enum.Enum): + BRIGHTFIELD = enum.auto() + PHASE_CONTRAST = enum.auto() + COLOR_BRIGHTFIELD = enum.auto() + + C377_647 = enum.auto() + C400_647 = enum.auto() + C469_593 = enum.auto() + ACRIDINE_ORANGE = enum.auto() + CFP = enum.auto() + CFP_FRET_V2 = enum.auto() + CFP_YFP_FRET = enum.auto() + CFP_YFP_FRET_V2 = enum.auto() + CHLOROPHYLL_A = enum.auto() + CY5 = enum.auto() + CY5_5 = enum.auto() + CY7 = enum.auto() + DAPI = enum.auto() + FITC = enum.auto() + GFP = enum.auto() + GFP_CY5 = enum.auto() + OXIDIZED_ROGFP2 = enum.auto() + PROPIDIUM_IODIDE = enum.auto() + RFP = enum.auto() + RFP_CY5 = enum.auto() + TAG_BFP = enum.auto() + TEXAS_RED = enum.auto() + YFP = enum.auto() + + +Exposure = Union[float, Literal["machine-auto"]] +FocalPosition = Union[float, Literal["machine-auto"]] +Gain = Union[float, Literal["machine-auto"]] + + +@dataclass +class AutoExposure: + evaluate_exposure: Callable[[Image], Awaitable[Literal["higher", "lower", "good"]]] + max_rounds: int + low: float + high: float + + +@dataclass +class AutoFocus: + evaluate_focus: Callable[[Image], float] + timeout: float + low: float + high: float + tolerance: float = 0.001 # 1 micron + + +@dataclass +class ImagingResult: + images: List[Image] + exposure_time: float + focal_height: float diff --git a/pylabrobot/agilent/biotek/el406/__init__.py b/pylabrobot/agilent/biotek/el406/__init__.py new file mode 100644 index 00000000000..e3d743aa40f --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/__init__.py @@ -0,0 +1 @@ +from pylabrobot.agilent.biotek.el406.el406 import EL406 diff --git a/pylabrobot/agilent/biotek/el406/el406.py b/pylabrobot/agilent/biotek/el406/el406.py new file mode 100644 index 00000000000..7e8bfde8c4b --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/el406.py @@ -0,0 +1,1112 @@ +from __future__ import annotations + +import asyncio +import enum +import logging +import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Literal, NamedTuple, TypedDict, TypeVar + +from pylabrobot.agilent.biotek.el406.peristaltic_dispenser import PeristalticDispenser +from pylabrobot.agilent.biotek.el406.plate_washer import PlateWasher +from pylabrobot.agilent.biotek.el406.syringe_dispenser import SyringeDispenser +from pylabrobot.io.binary import Reader, Writer +from pylabrobot.io.ftdi import FTDI +from pylabrobot.resources import Coordinate, Plate, PlateHolder + +from .enums import ( + EL406Motor, + EL406MotorHomeType, + EL406Sensor, + EL406StepType, + EL406SyringeManifold, + EL406WasherManifold, +) +from .error_codes import get_error_message +from .errors import EL406CommunicationError, EL406DeviceError +from .helpers import plate_to_wire_byte +from .protocol import build_framed_message + +logger = logging.getLogger(__name__) + +Intensity = Literal["Variable", "Slow", "Medium", "Fast"] + +INTENSITY_TO_BYTE: dict[str, int] = { + "Variable": 0x01, + "Slow": 0x02, + "Medium": 0x03, + "Fast": 0x04, +} + + +def validate_intensity(intensity: Intensity) -> None: + if intensity not in {"Slow", "Medium", "Fast", "Variable"}: + raise ValueError( + f"intensity must be one of {sorted({'Slow', 'Medium', 'Fast', 'Variable'})}, " + f"got {intensity!r}" + ) + + +LONG_READ_TIMEOUT = 120.0 # seconds, for long operations (wash cycles can take >30s) + +STATE_INITIAL = 1 +STATE_RUNNING = 2 +STATE_PAUSED = 3 +STATE_STOPPED = 4 + + +class DevicePollResult(NamedTuple): + """Parsed result from a STATUS_POLL response.""" + + validity: int + state: int + status: int + raw_response: bytes + + +class EL406: + """FTDI-based driver for the BioTek EL406 plate washer. + + Owns the USB connection, low-level protocol framing, command serialization, + batch management, and device-level operations (reset, home, pause, etc.). + """ + + def __init__( + self, + name: str, + timeout: float = 15.0, + device_id: str | None = None, + ) -> None: + super().__init__() + self.timeout = timeout + self._device_id = device_id + self.io = FTDI(human_readable_device_name="BioTek EL406", device_id=self._device_id) + self._command_lock: asyncio.Lock | None = None + self._in_batch: bool = False + self._current_plate: Plate | None = None + + self.washer = self.warsher = PlateWasher(self) + self.syringe_dispenser = SyringeDispenser(self) + self.peristaltic_dispenser = PeristalticDispenser(self) + + self.plate_holder = PlateHolder( + name=name + "_plate_holder", + size_x=127.76, + size_y=85.48, + size_z=0, + pedestal_size_z=0, + child_location=Coordinate.zero(), + ) + + @property + def plate(self) -> Plate: + """The plate currently loaded in the EL406. + + Raises: + RuntimeError: If no plate is loaded. + """ + if self._current_plate is None: + raise RuntimeError("No plate is loaded in the EL406. Call set_plate() first.") + return self._current_plate + + def set_plate(self, plate: Plate) -> None: + """Record the plate now sitting in the carrier. + + Operations encode the plate geometry into their wire commands, so this must match the + labware physically loaded. + """ + self._current_plate = plate + + def clear_plate(self) -> None: + """Forget the loaded plate. Operations raise until :meth:`set_plate` is called again.""" + self._current_plate = None + + async def setup(self, skip_reset: bool = False) -> None: + """Set up communication with the EL406. + + Configures the FTDI USB interface with the correct parameters: + - 38400 baud + - 8 data bits, 2 stop bits, no parity (8N2) + - No flow control (disabled) + + If ``self.io`` is already set (e.g. injected mock for testing), + it is used as-is and ``setup()`` is not called on it again. + + Args: + skip_reset: If True, skip the instrument reset step during setup. + + Raises: + RuntimeError: If pylibftdi is not installed or communication fails. + """ + self._command_lock = asyncio.Lock() + + logger.info("EL406Driver setting up") + logger.info(" Timeout: %.1f seconds", self.timeout) + + await self.io.setup() + + # Configure serial parameters + logger.debug("Configuring serial parameters...") + try: + await self.io.set_baudrate(38400) + await self.io.set_line_property(8, 2, 0) # 8 data bits, 2 stop bits, no parity + logger.info(" Serial: 38400 baud, 8N2") + + SIO_DISABLE_FLOW_CTRL = 0x0 + await self.io.set_flowctrl(SIO_DISABLE_FLOW_CTRL) + logger.info(" Flow control: NONE") + + await self.io.set_rts(True) + await self.io.set_dtr(True) + logger.debug(" RTS and DTR enabled") + except Exception as e: + await self.io.stop() + raise EL406CommunicationError( + f"Failed to configure FTDI device: {e}", + operation="configure", + original_error=e, + ) from e + + # Purge buffers + logger.debug("Purging TX/RX buffers...") + await self._purge_buffers() + + # Test communication + logger.info("Testing communication with device...") + try: + await self._test_communication() + logger.info(" Communication test: PASSED") + except Exception as e: + logger.error(" Communication test: FAILED - %s", e) + raise + + if not skip_reset: + logger.info("Performing full instrument reset...") + await self.reset() + logger.info(" Instrument reset: DONE") + + logger.info("EL406Driver setup complete") + + async def stop(self) -> None: + """Close the FTDI connection.""" + logger.info("EL406Driver stopping") + await self.io.stop() + + # --------------------------------------------------------------------------- + # Low-level I/O + # --------------------------------------------------------------------------- + + async def _write_to_device(self, data: bytes) -> None: + """Write bytes to the FTDI device, wrapping errors. + + Raises: + EL406CommunicationError: If the write fails. + """ + assert self.io is not None + try: + await self.io.write(data) + except Exception as e: + raise EL406CommunicationError( + f"Failed to write to device: {e}. Device may have disconnected.", + operation="write", + original_error=e, + ) from e + + async def _wait_for_ack(self, timeout: float, t0: float) -> None: + """Poll device for ACK byte within the remaining timeout window. + + Args: + timeout: Total timeout budget in seconds. + t0: Start timestamp (from ``time.monotonic()``). + + Raises: + RuntimeError: If device sends NAK. + TimeoutError: If no ACK within timeout. + """ + assert self.io is not None + while time.monotonic() - t0 < timeout: + byte = await self.io.read(1) + if byte: + if byte[0] == 0x15: # NAK + raise RuntimeError( + f"Device rejected command (NAK). Response: {byte!r}. " + "This may indicate an invalid command, bad parameters, or device busy state." + ) + if byte[0] == 0x06: # ACK + return + await asyncio.sleep(0.01) + raise TimeoutError("Timeout waiting for ACK") + + async def _read_exact_bytes(self, count: int, timeout: float, t0: float) -> bytes: + """Read exactly *count* bytes from the device, polling until done or timeout. + + Args: + count: Number of bytes to read. + timeout: Total timeout budget in seconds. + t0: Start timestamp (from ``time.monotonic()``). + + Returns: + Bytes read (may be shorter than *count* if timeout is reached). + """ + assert self.io is not None + buf = b"" + while len(buf) < count and time.monotonic() - t0 < timeout: + chunk = await self.io.read(count - len(buf)) + if chunk: + buf += chunk + else: + await asyncio.sleep(0.01) + return buf + + async def _purge_buffers(self) -> None: + """Purge the RX and TX buffers.""" + if self.io is None: + return + + try: + for _ in range(6): + await self.io.usb_purge_rx_buffer() + await self.io.usb_purge_tx_buffer() + except Exception as e: + raise EL406CommunicationError( + f"Failed to purge FTDI buffers: {e}. Device may have disconnected.", + operation="purge", + original_error=e, + ) from e + + async def _test_communication(self) -> None: + """Test communication with the device. + + Sends framed command 0x73 (115) and expects ACK (0x06) response. + + Raises: + RuntimeError: If communication test fails. + """ + if self.io is None: + raise RuntimeError("EL406 communication test failed: device not open") + + try: + framed_command = build_framed_message(command=0x73) + response = await self._send_framed_command(framed_command, timeout=self.timeout) + if 0x06 not in response: + raise RuntimeError( + f"EL406 communication test failed: expected ACK (0x06), got {response!r}" + ) + except TimeoutError as e: + raise RuntimeError(f"EL406 communication test failed: timeout - {e}") from e + + logger.info("EL406 communication test passed") + + # Send INIT_STATE (0xA0) command to clear device state + logger.info("Sending INIT_STATE command (0xA0) to clear device state") + init_state_cmd = build_framed_message(command=0xA0) + init_response = await self._send_framed_command(init_state_cmd, timeout=self.timeout) + logger.debug("INIT_STATE sent, response: %s", init_response.hex()) + + # --------------------------------------------------------------------------- + # Command sending + # --------------------------------------------------------------------------- + + async def _send_framed_command( + self, + framed_message: bytes, + timeout: float | None = None, + ) -> bytes: + """Send a framed command and wait for full response. + + The device responds to framed commands with: + - ACK (0x06) + 11-byte header + N-byte data + + This method reads the complete response to avoid leaving data in the buffer. + For ACK-only commands (e.g. TEST_COMM, INIT_STATE), the header wait acts as + an implicit settling delay that the device needs before accepting further + commands. + + Args: + framed_message: Complete framed message (from build_framed_message). + timeout: Timeout in seconds. + + Returns: + Complete response bytes (ACK + header + data). + + Raises: + TimeoutError: If timeout waiting for response. + """ + if self.io is None or self._command_lock is None: + raise RuntimeError("Device not initialized") + + if timeout is None: + timeout = self.timeout + + async with self._command_lock: + await self._purge_buffers() + + # Send header and data separately + header = framed_message[:11] + data = framed_message[11:] if len(framed_message) > 11 else b"" + + await self._write_to_device(header) + logger.debug("Sent header: %s", header.hex()) + + if data: + await asyncio.sleep(0.001) # Small delay between header and data + await self._write_to_device(data) + logger.debug("Sent data: %s", data.hex()) + logger.debug("Sent framed: %s", framed_message.hex()) + + # Read full response: ACK + 11-byte header + variable data + await self._wait_for_ack(timeout, time.monotonic()) + result = bytes([0x06]) + + # Fresh timestamp after ACK — header + data share a single timeout budget. + t0 = time.monotonic() + resp_header = await self._read_exact_bytes(11, timeout, t0) + + if len(resp_header) == 11: + result += resp_header + # Parse data length from header bytes 7-8 (little-endian) + data_len = Reader(resp_header[7:]).u16() + response_data = await self._read_exact_bytes(data_len, timeout, t0) + result += response_data + logger.debug("Full response: %s (%d bytes)", result.hex(), len(result)) + else: + logger.debug("ACK-only response (no frame): %s", result.hex()) + + return result + + async def _send_action_command( + self, + framed_message: bytes, + timeout: float | None = None, + ) -> bytes: + """Send an action command and wait for completion frame. + + Action commands (like reset, home_motors) work differently from query commands: + 1. Send command + 2. Device sends ACK immediately (acknowledging receipt) + 3. Device performs the physical action (takes time) + 4. Device sends completion frame when done + + This method waits for both the ACK and the completion frame. + + Args: + framed_message: Complete framed message (from build_framed_message). + timeout: Timeout in seconds for the entire operation including action completion. + + Returns: + Completion frame bytes (header + data). + + Raises: + TimeoutError: If timeout waiting for ACK or completion. + RuntimeError: If device rejects command (NAK). + """ + if self.io is None or self._command_lock is None: + raise RuntimeError("Device not initialized") + + if timeout is None: + timeout = LONG_READ_TIMEOUT # Default to long timeout for actions + + async with self._command_lock: + await self._purge_buffers() + + # Send header and data separately (matches _send_framed_command protocol) + header = framed_message[:11] + data = framed_message[11:] if len(framed_message) > 11 else b"" + + await self._write_to_device(header) + if data: + await asyncio.sleep(0.001) + await self._write_to_device(data) + logger.debug("Sent action command: %s", framed_message.hex()) + + t0 = time.monotonic() + + # Step 1: Wait for ACK (short timeout) + await self._wait_for_ack(min(timeout, self.timeout), t0) + logger.debug("Got ACK, waiting for completion...") + + # Step 2: Wait for completion frame (11-byte header + data) + header = await self._read_exact_bytes(11, timeout, t0) + if len(header) < 11: + raise TimeoutError(f"Timeout waiting for completion header (got {len(header)} bytes)") + + # Parse data length and read remaining data + data_len = Reader(header[7:]).u16() + data = await self._read_exact_bytes(data_len, timeout, t0) + + result = header + data + + logger.debug("Completion frame: %s (%d bytes)", result.hex(), len(result)) + + # Parse and log result + cmd_echo = Reader(result[2:]).u16() + response_data = result[11 : 11 + data_len] if len(result) >= 11 + data_len else b"" + logger.debug(" Command echo: 0x%04X, data: %s", cmd_echo, response_data.hex()) + + return result + + async def _send_framed_query( + self, + command: int, + data: bytes = b"", + timeout: float | None = None, + ) -> bytes: + """Send a framed query command and read full response with header and data. + + Sends the 11-byte header and optional data payload as separate USB writes, + then reads the full response: ACK + 11-byte response header + data. + + Args: + command: 16-bit command code + data: Optional data bytes to send with command + timeout: Timeout in seconds + + Returns: + Data bytes from response (header stripped). + + Raises: + RuntimeError: If device not initialized or response invalid. + TimeoutError: If timeout waiting for response. + """ + if self.io is None or self._command_lock is None: + raise RuntimeError("Device not initialized") + + if timeout is None: + timeout = self.timeout + + framed_message = build_framed_message(command, data) + + async with self._command_lock: + await self._purge_buffers() + + # Split header and data + msg_header = framed_message[:11] + msg_data = framed_message[11:] if len(framed_message) > 11 else b"" + + await self._write_to_device(msg_header) + logger.debug("Sent query header 0x%04X: %s", command, msg_header.hex()) + + if msg_data: + await asyncio.sleep(0.001) + await self._write_to_device(msg_data) + logger.debug("Sent query data: %s", msg_data.hex()) + + # Wait for ACK + try: + await self._wait_for_ack(timeout, time.monotonic()) + except RuntimeError as e: + raise RuntimeError( + f"Device rejected command 0x{command:04X} (NAK). Check command code and parameters." + ) from e + except TimeoutError as e: + raise TimeoutError(f"Timeout waiting for ACK (command 0x{command:04X})") from e + + t0 = time.monotonic() + # Read 11-byte response header (shares timeout budget with data) + resp_header = await self._read_exact_bytes(11, timeout, t0) + if len(resp_header) < 11: + raise TimeoutError(f"Timeout reading response header (got {len(resp_header)}/11 bytes)") + + logger.debug("Response header: %s", resp_header.hex()) + + # Parse data length from header bytes 7-8 (little-endian) + data_len = Reader(resp_header[7:]).u16() + logger.debug("Response data length: %d", data_len) + + # Read data bytes + response_data = await self._read_exact_bytes(data_len, timeout, t0) + if len(response_data) < data_len: + raise TimeoutError( + f"Timeout reading response data (got {len(response_data)}/{data_len} bytes)" + ) + + logger.debug("Response data: %s", response_data.hex()) + return response_data + + # --------------------------------------------------------------------------- + # Polling + # --------------------------------------------------------------------------- + + async def _poll_device_state(self) -> DevicePollResult: + """Send one STATUS_POLL and return the parsed device state. + + Returns: + DevicePollResult with validity, state, status, and raw_response. + + Raises: + EL406CommunicationError: If poll response is too short to parse. + """ + poll_command = build_framed_message(command=0x92) + poll_response = await self._send_framed_command(poll_command, timeout=self.timeout) + logger.debug("Status poll response (%d bytes): %s", len(poll_response), poll_response.hex()) + + if len(poll_response) < 21: + # Short response — return zeroed fields so callers can handle it + return DevicePollResult(validity=0, state=0, status=0, raw_response=poll_response) + + # Data layout (after ACK+header at offset 12): + # bytes 12-13: validity (little-endian, must be 0) + # bytes 14-15: state (little-endian) + # bytes 16-19: timestamp/counter + # byte 20: status code + r = Reader(poll_response[12:]) + validity = r.u16() + state = r.u16() + r.raw_bytes(4) # skip timestamp/counter (bytes 16-19) + status = r.u8() + + if validity != 0: + error_msg = get_error_message(validity) + logger.warning("Status poll returned error 0x%04X (%d): %s", validity, validity, error_msg) + + logger.debug("Status poll: validity=%d, state=%d, status=%d", validity, state, status) + return DevicePollResult( + validity=validity, state=state, status=status, raw_response=poll_response + ) + + async def _wait_until_ready(self, timeout: float = 5.0, poll_interval: float = 0.1) -> None: + """Poll until the device is no longer in STATE_RUNNING. + + Args: + timeout: Maximum time to wait in seconds. + poll_interval: Time between polls in seconds. + + Raises: + TimeoutError: If the device stays busy beyond *timeout*. + """ + t0 = time.monotonic() + while time.monotonic() - t0 < timeout: + poll = await self._poll_device_state() + if poll.state != STATE_RUNNING: + return + await asyncio.sleep(poll_interval) + raise TimeoutError(f"Device still busy (STATE_RUNNING) after {timeout}s waiting for readiness") + + async def _send_step_command( + self, + framed_message: bytes, + timeout: float | None = None, + poll_interval: float = 0.1, + ) -> bytes: + """Send a step command and poll for completion. + + Step commands (prime, dispense, aspirate, shake, etc.) require polling + for completion using STATUS_POLL (0x92) until the operation completes. + + Protocol flow: + 1. Wait for device to be ready (not RUNNING) + 2. Send step command (e.g., SYRINGE_PRIME 0xA2) + 3. Device ACKs immediately + 4. Poll with STATUS_POLL (0x92) repeatedly + 5. Check state in response to determine completion + + Args: + framed_message: Complete framed message (from build_framed_message). + timeout: Timeout in seconds for the entire operation. + poll_interval: Time between status polls in seconds. + + Returns: + Final status response bytes. + + Raises: + TimeoutError: If timeout waiting for completion. + EL406DeviceError: If device reports an error during the step. + RuntimeError: If device rejects command (NAK). + """ + if self.io is None: + raise RuntimeError("Device not initialized") + + if timeout is None: + timeout = LONG_READ_TIMEOUT + + logger.debug("Starting step command with timeout=%ss", timeout) + + # 1. Wait for device to be ready (not RUNNING) + await self._wait_until_ready(timeout=min(timeout, self.timeout)) + + # 2. Send the step command + logger.debug("Sending step command: %s", framed_message.hex()) + response = await self._send_framed_command(framed_message, timeout=min(timeout, self.timeout)) + logger.debug("Step command sent, got initial response: %s", response.hex()) + + # 3. Initial delay before polling + await asyncio.sleep(0.5) + + # 4. Poll for completion + t0 = time.monotonic() + poll_count = 0 + + logger.debug("Starting polling loop...") + + while time.monotonic() - t0 < timeout: + await asyncio.sleep(poll_interval) + poll_count += 1 + + poll = await self._poll_device_state() + logger.debug("Poll #%d: %d bytes", poll_count, len(poll.raw_response)) + + if poll.state in (STATE_INITIAL, STATE_STOPPED): + logger.debug("Step completed (state=%d) after %d polls", poll.state, poll_count) + if poll.validity != 0: + raise EL406DeviceError(poll.validity, get_error_message(poll.validity)) + return poll.raw_response + + if poll.state == STATE_RUNNING: + logger.debug("Step in progress (state=Running), continuing poll...") + elif poll.state == STATE_PAUSED: + logger.warning("Step is paused (state=3)") + elif poll.status == 0: + # Unknown state with status=0 means done + logger.debug("Done (unknown state=%d, status=0)", poll.state) + return poll.raw_response + else: + logger.debug("Unknown state=%d, status=%d, continuing...", poll.state, poll.status) + + raise TimeoutError(f"Timeout waiting for step completion after {timeout}s") + + # --------------------------------------------------------------------------- + # Batch management + # --------------------------------------------------------------------------- + + @asynccontextmanager + async def batch(self) -> AsyncIterator[None]: + """Context manager for batching step commands. + + Each step command (wash, syringe_prime, etc.) automatically wraps + its execution in a batch. Use this context manager to group multiple step + commands into a single batch, avoiding repeated start/cleanup cycles. + + If already inside a batch, this is a no-op passthrough. + + The plate must be assigned to the device's plate_holder before calling this. + + Example: + >>> async with driver.batch(): + ... await driver._send_step_command(framed_cmd) + """ + if self._in_batch: + yield + return + + self._in_batch = True + try: + await self.start_batch(plate_to_wire_byte(self.plate)) + yield + finally: + try: + await self.cleanup_after_protocol() + finally: + self._in_batch = False + + async def start_batch(self, wire_byte: int) -> None: + """Send START_STEP command to begin a batch of step operations. + + Use this function at the beginning of a protocol, before executing any step + commands. This puts the device in "ready to execute steps" mode. Must be + called once before running step commands like prime, dispense, aspirate, + shake, etc. + + This should be called: + - After setup() completes + - Before running any step commands + - Only once per batch of operations (not before each individual step) + + Args: + wire_byte: EL406 plate-type byte for the wire protocol. + """ + if self.io is None: + raise RuntimeError("Device not initialized - call setup() first") + + logger.info("Sending START_STEP to begin batch operations") + + # Send initialization commands before START_STEP + pre_batch_commands = [0xBF, 0xC1, 0xF2, 0xF4, 0x0154, 0x0102, 0x010A] + for cmd in pre_batch_commands: + cmd_frame = build_framed_message(cmd) + try: + resp = await self._send_framed_command(cmd_frame, timeout=self.timeout) + logger.debug("Command 0x%04X response: %s", cmd, resp.hex()) + except Exception as e: + logger.warning("Pre-batch command 0x%04X failed: %s", cmd, e) + + # Data byte is the plate type value (e.g., 0x04 for 96-well, 0x01 for 384-well). + start_step_data = bytes([wire_byte]) + start_step_cmd = build_framed_message(command=0x8D, data=start_step_data) + response = await self._send_framed_command(start_step_cmd, timeout=self.timeout) + logger.debug("START_STEP sent, response: %s", response.hex()) + + # --------------------------------------------------------------------------- + # Device-level operations + # --------------------------------------------------------------------------- + + async def abort( + self, + step_type: EL406StepType | None = None, + ) -> None: + """Abort a running operation. + + Args: + step_type: Optional step type to abort. If None, aborts current operation. + + Raises: + RuntimeError: If device not initialized. + TimeoutError: If timeout waiting for ACK response. + """ + logger.info( + "Aborting %s", + f"step type {step_type.name}" if step_type is not None else "current operation", + ) + + step_type_value = step_type.value if step_type is not None else 0 + data = bytes([step_type_value]) + framed_command = build_framed_message(command=0x89, data=data) + await self._send_framed_command(framed_command) + + async def pause(self) -> None: + """Pause a running operation.""" + logger.info("Pausing operation") + framed_command = build_framed_message(command=0x8A) + await self._send_framed_command(framed_command) + + async def resume(self) -> None: + """Resume a paused operation.""" + logger.info("Resuming operation") + framed_command = build_framed_message(command=0x8B) + await self._send_framed_command(framed_command) + + async def reset(self) -> None: + """Reset the instrument to a known state.""" + logger.info("Resetting instrument") + framed_command = build_framed_message(command=0x70) + await self._send_action_command(framed_command, timeout=LONG_READ_TIMEOUT) + logger.info("Instrument reset complete") + + async def _perform_end_of_batch(self) -> None: + """Perform end-of-batch activities - sends completion marker. + + NOTE: This command (140) is just a completion marker and does NOT: + - Stop the pump + - Home the syringes + + For a complete cleanup after a protocol, use cleanup_after_protocol() instead. + """ + logger.info("Performing end-of-batch activities (completion marker)") + framed_command = build_framed_message(command=0x8C) + await self._send_action_command(framed_command, timeout=60.0) + logger.info("End-of-batch marker sent") + + async def cleanup_after_protocol(self) -> None: + """Complete cleanup after running a protocol. + + This method performs the full cleanup sequence that the original BioTek + software does after all protocol steps complete: + 1. Home the syringes (XYZ motors) + 2. Send end-of-batch completion marker + + This is the recommended way to end a protocol run. + + Example: + >>> # Run protocol steps + >>> await backend.syringe_prime("A", 1000, 5, 2) + >>> await backend.syringe_prime("B", 1000, 5, 2) + >>> # Then cleanup + >>> await backend.cleanup_after_protocol() + """ + logger.info("Starting post-protocol cleanup") + + # Step 1: Home syringes + logger.info(" Homing motors...") + await self.home_motors(EL406MotorHomeType.HOME_XYZ_MOTORS) + + # Step 2: Send end-of-batch marker + logger.info(" Sending end-of-batch marker...") + await self._perform_end_of_batch() + + logger.info("Post-protocol cleanup complete") + + async def home_motors( + self, + home_type: EL406MotorHomeType, + motor: EL406Motor | None = None, + ) -> None: + """Home or verify motor positions.""" + logger.info( + "Home/verify motors: type=%s, motor=%s", + home_type.name, + motor.name if motor is not None else "default(0)", + ) + + motor_num = motor.value if motor is not None else 0 + data = bytes([home_type.value, motor_num]) + framed_command = build_framed_message(command=0xC8, data=data) + await self._send_action_command(framed_command, timeout=120.0) + logger.info("Motors homed") + + async def set_washer_manifold(self, manifold: EL406WasherManifold) -> None: + """Set the washer manifold type.""" + logger.info("Setting washer manifold to: %s", manifold.name) + data = bytes([manifold.value]) + framed_command = build_framed_message(command=0xD9, data=data) + await self._send_framed_command(framed_command) + logger.info("Washer manifold set to: %s", manifold.name) + + # --------------------------------------------------------------------------- + # Queries + # --------------------------------------------------------------------------- + + @staticmethod + def _extract_payload_byte(response_data: bytes) -> int: + """Extract the first payload byte, handling optional 2-byte header prefix.""" + return response_data[2] if len(response_data) > 2 else response_data[0] + + _E = TypeVar("_E", bound=enum.Enum) + + async def _query_enum(self, command: int, enum_cls: type[_E], label: str) -> _E: + """Send a framed query and parse the response byte as an *enum_cls* member.""" + logger.info("Querying %s", label) + response_data = await self._send_framed_query(command) + logger.debug("%s response data: %s", label.capitalize(), response_data.hex()) + value_byte = self._extract_payload_byte(response_data) + + try: + result = enum_cls(value_byte) + except ValueError: + logger.warning("Unknown %s: %d (0x%02X)", label, value_byte, value_byte) + raise ValueError( + f"Unknown {label}: {value_byte} (0x{value_byte:02X}). " + f"Valid types: {[m.name for m in enum_cls]}" + ) from None + + logger.info("%s: %s (0x%02X)", label.capitalize(), result.name, result.value) + return result + + async def request_washer_manifold(self) -> EL406WasherManifold: + """Query the installed washer manifold type.""" + return await self._query_enum( + command=0xD8, enum_cls=EL406WasherManifold, label="washer manifold type" + ) + + async def request_syringe_manifold(self) -> EL406SyringeManifold: + """Query the installed syringe manifold type.""" + return await self._query_enum( + command=0xBB, enum_cls=EL406SyringeManifold, label="syringe manifold type" + ) + + async def request_serial_number(self) -> str: + """Query the product serial number.""" + logger.info("Querying product serial number") + response_data = await self._send_framed_query(command=0x0100) + serial_number = response_data[2:].decode("ascii", errors="ignore").strip().rstrip("\x00") + logger.info("Product serial number: %s", serial_number) + return serial_number + + async def request_sensor_enabled(self, sensor: EL406Sensor) -> bool: + """Query whether a specific sensor is enabled.""" + logger.info("Querying sensor enabled status: %s", sensor.name) + response_data = await self._send_framed_query(command=0xD2, data=bytes([sensor.value])) + logger.debug("Sensor enabled response data: %s", response_data.hex()) + enabled = bool(self._extract_payload_byte(response_data)) + logger.info("Sensor %s enabled: %s", sensor.name, enabled) + return enabled + + class SyringeBoxInfo(TypedDict): + box_type: int + box_size: int + installed: bool + + async def request_syringe_box_info(self) -> SyringeBoxInfo: + """Get syringe box information.""" + logger.info("Querying syringe box info") + response_data = await self._send_framed_query(command=0xF6) + logger.debug("Syringe box info response data: %s", response_data.hex()) + + box_type = self._extract_payload_byte(response_data) + box_size = ( + response_data[3] + if len(response_data) > 3 + else (response_data[1] if len(response_data) > 1 else 0) + ) + installed = box_type != 0 + + info = self.SyringeBoxInfo(box_type=box_type, box_size=box_size, installed=installed) + logger.info("Syringe box info: %s", info) + return info + + async def request_peristaltic_installed(self, selector: int) -> bool: + """Check if a peristaltic pump is installed.""" + if selector < 0 or selector > 1: + raise ValueError(f"Invalid selector {selector}. Must be 0 (primary) or 1 (secondary).") + + logger.info("Querying peristaltic pump installed: selector=%d", selector) + response_data = await self._send_framed_query(command=0x0104, data=bytes([selector])) + logger.debug("Peristaltic installed response data: %s", response_data.hex()) + + installed = bool(self._extract_payload_byte(response_data)) + + logger.info("Peristaltic pump %d installed: %s", selector, installed) + return installed + + class InstrumentSettings(TypedDict): + washer_manifold: EL406WasherManifold + syringe_manifold: EL406SyringeManifold + syringe_box: "EL406.SyringeBoxInfo" + peristaltic_pump_1: bool + peristaltic_pump_2: bool + + async def request_instrument_settings(self) -> InstrumentSettings: + """Get current instrument hardware configuration.""" + logger.info("Querying instrument settings from hardware") + + washer_manifold = await self.request_washer_manifold() + syringe_manifold = await self.request_syringe_manifold() + syringe_box = await self.request_syringe_box_info() + peristaltic_1 = await self.request_peristaltic_installed(0) + peristaltic_2 = await self.request_peristaltic_installed(1) + + settings = self.InstrumentSettings( + washer_manifold=washer_manifold, + syringe_manifold=syringe_manifold, + syringe_box=syringe_box, + peristaltic_pump_1=peristaltic_1, + peristaltic_pump_2=peristaltic_2, + ) + logger.info("Instrument settings: %s", settings) + return settings + + class SelfCheckResult(TypedDict): + success: bool + error_code: int + message: str + + async def run_self_check(self) -> SelfCheckResult: + """Run instrument self-check diagnostics.""" + logger.info("Running instrument self-check") + response_data = await self._send_framed_query(command=0x95, timeout=LONG_READ_TIMEOUT) + logger.debug("Self-check response data: %s", response_data.hex()) + error_code = self._extract_payload_byte(response_data) + success = error_code == 0 + + message = "Self-check passed" if success else f"Self-check failed (error code: {error_code})" + result = self.SelfCheckResult(success=success, error_code=error_code, message=message) + logger.info("Self-check result: %s", result["message"]) + return result + + # --------------------------------------------------------------------------- + # Shake / soak + # --------------------------------------------------------------------------- + + MAX_SHAKE_DURATION = 3599 # 59:59 max (mm:ss format, mm max=59) + MAX_SOAK_DURATION = 3599 # 59:59 max (mm:ss format, mm max=59) + + async def shake( + self, + duration: float, + intensity: Intensity = "Medium", + soak_duration: int = 0, + move_home_first: bool = True, + ) -> None: + """Shake the plate with optional soak period. + + The EL406 shake is a single fire-and-forget command with the duration baked in; + it has no continuous start/stop shaking and no plate locking. Intensity is a + discrete level, not an RPM. + + Durations are in whole seconds (GUI uses mm:ss picker, max 59:59 each). + A duration of 0 disables shake. A soak_duration of 0 disables soak. + + Note: The GUI forces move_home_first=True when total time exceeds 60s + to prevent manifold drip contamination. Our default of True matches this. + + The plate is read from :attr:`plate` (set by assigning to the device's plate_holder). + + Args: + duration: Shake duration in seconds (0-3599). 0 to disable shake. + intensity: Shake intensity - "Variable", "Slow" (3.5 Hz), "Medium" (5 Hz), + or "Fast" (8 Hz). + soak_duration: Soak duration in seconds after shaking (0-3599). 0 to disable. + move_home_first: Move carrier to home position before shaking. + + Raises: + ValueError: If parameters are invalid. + """ + dur = int(duration) + plate = self.plate + + if dur < 0 or dur > self.MAX_SHAKE_DURATION: + raise ValueError(f"Invalid duration {dur}. Must be 0-{self.MAX_SHAKE_DURATION}.") + if soak_duration < 0 or soak_duration > self.MAX_SOAK_DURATION: + raise ValueError( + f"Invalid soak_duration {soak_duration}. Must be 0-{self.MAX_SOAK_DURATION}." + ) + if dur == 0 and soak_duration == 0: + raise ValueError("At least one of duration or soak_duration must be > 0.") + validate_intensity(intensity) + + shake_enabled = dur > 0 + + logger.info( + "Shake: %ds, %s intensity, move_home=%s, soak=%ds", + dur, + intensity, + move_home_first, + soak_duration, + ) + + data = self._build_shake_command( + plate=plate, + shake_duration=dur, + soak_duration=soak_duration, + intensity=intensity, + shake_enabled=shake_enabled, + move_home_first=move_home_first, + ) + framed_command = build_framed_message(command=0xA3, data=data) + total_timeout = dur + soak_duration + self.timeout + async with self.batch(): + await self._send_step_command(framed_command, timeout=total_timeout) + + def _build_shake_command( + self, + plate: Plate, + shake_duration: int = 0, + soak_duration: int = 0, + intensity: Intensity = "Medium", + shake_enabled: bool = True, + move_home_first: bool = True, + ) -> bytes: + """Build shake command bytes. + + Byte structure (12 bytes): + [0] Plate type + [1] move_home_first: 0x00 or 0x01 + [2-3] Shake duration in total seconds (16-bit LE) + [4] Intensity: 0x01=Variable, 0x02=Slow, 0x03=Medium, 0x04=Fast + [5] Reserved: 0x00 + [6-7] Soak duration in total seconds (16-bit LE) + [8-11] Padding (4 bytes) + + Args: + plate: PLR Plate resource. + shake_duration: Shake duration in seconds. + soak_duration: Soak duration in seconds. + intensity: Shake intensity ("Variable", "Slow", "Medium", "Fast"). + shake_enabled: Whether shake is enabled. When False, shake_duration is not encoded. + move_home_first: Move carrier to home position before shaking. + + Returns: + Command bytes (12 bytes). + """ + shake_total_seconds = int(shake_duration) if shake_enabled else 0 + + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u8(0x01 if move_home_first else 0x00) # [1] move_home_first + .u16(shake_total_seconds) # [2-3] Shake duration (seconds) + .u8(INTENSITY_TO_BYTE.get(intensity, 0x03)) # [4] Intensity + .u8(0x00) # [5] Reserved + .u16(int(soak_duration)) # [6-7] Soak duration (seconds) + .raw_bytes(b'\x00' * 4) # [8-11] Padding + .finish() + ) # fmt: skip diff --git a/pylabrobot/plate_washing/biotek/el406/enums.py b/pylabrobot/agilent/biotek/el406/enums.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/enums.py rename to pylabrobot/agilent/biotek/el406/enums.py diff --git a/pylabrobot/plate_washing/biotek/el406/error_codes.py b/pylabrobot/agilent/biotek/el406/error_codes.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/error_codes.py rename to pylabrobot/agilent/biotek/el406/error_codes.py diff --git a/pylabrobot/plate_washing/biotek/el406/errors.py b/pylabrobot/agilent/biotek/el406/errors.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/errors.py rename to pylabrobot/agilent/biotek/el406/errors.py diff --git a/pylabrobot/plate_washing/biotek/el406/helpers.py b/pylabrobot/agilent/biotek/el406/helpers.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/helpers.py rename to pylabrobot/agilent/biotek/el406/helpers.py diff --git a/pylabrobot/agilent/biotek/el406/peristaltic_dispenser.py b/pylabrobot/agilent/biotek/el406/peristaltic_dispenser.py new file mode 100644 index 00000000000..a60254e4bc8 --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/peristaltic_dispenser.py @@ -0,0 +1,497 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Dict, Literal, Optional + +from pylabrobot.io.binary import Writer +from pylabrobot.resources import Plate + +from .helpers import ( + plate_default_z, + plate_max_columns, + plate_max_row_groups, + plate_to_wire_byte, + plate_well_count, +) +from .protocol import build_framed_message, columns_to_column_mask, encode_column_mask + +if TYPE_CHECKING: + from .el406 import EL406 + +logger = logging.getLogger(__name__) + +PeristalticFlowRate = Literal["Low", "Medium", "High"] +Cassette = Literal["Any", "1uL", "5uL", "10uL"] + +PERISTALTIC_FLOW_RATE_MAP: dict[str, int] = {"Low": 0, "Medium": 1, "High": 2} + + +def cassette_to_byte(cassette: Cassette) -> int: + mapping = {"ANY": 0, "1UL": 1, "5UL": 2, "10UL": 3} + key = cassette.upper() + if key not in mapping: + raise ValueError(f"Invalid cassette '{cassette}'. Must be one of: Any, 1uL, 5uL, 10uL") + return mapping[key] + + +def encode_quadrant_mask_inverted( + rows: Optional[list[int]], + num_row_groups: int = 4, +) -> int: + """Encode row/quadrant selection as inverted bitmask. + + The protocol uses INVERTED encoding for the quadrant/row mask byte: + 0 = selected, 1 = deselected. This is the opposite of the well mask. + + Args: + rows: List of row numbers (1 to num_row_groups) to select, or None for all. + If None, returns 0x00 (all selected in inverted encoding). + num_row_groups: Number of valid row groups for this plate type (1, 2, or 4). + + Returns: + Single byte with inverted bit encoding (only lower num_row_groups bits used). + + Raises: + ValueError: If any row number is out of range. + """ + if rows is None: + return 0x00 + + max_mask = (1 << num_row_groups) - 1 + mask = max_mask + for row in rows: + if row < 1 or row > num_row_groups: + raise ValueError(f"Row number {row} out of range. Must be 1-{num_row_groups}.") + mask &= ~(1 << (row - 1)) + + return mask & 0xFF + + +def validate_peristaltic_flow_rate(flow_rate: PeristalticFlowRate) -> None: + if flow_rate not in PERISTALTIC_FLOW_RATE_MAP: + raise ValueError( + f"flow_rate must be one of {sorted(PERISTALTIC_FLOW_RATE_MAP)}, got {flow_rate!r}" + ) + + +class PeristalticDispenser: + """Peristaltic dispensing backend for the BioTek EL406.""" + + def __init__(self, el406: EL406) -> None: + self._el406 = el406 + + async def dispense( + self, + volumes: Dict[int, float], + flow_rate: PeristalticFlowRate = "High", + offset_x: float = 0.0, + offset_y: float = 0.0, + offset_z: Optional[float] = None, + pre_dispense_volume: float = 10.0, + num_pre_dispenses: int = 2, + cassette: Cassette = "Any", + rows: Optional[list[int]] = None, + ) -> None: + """Dispense peristaltically, one command per run of columns sharing a volume. + + Dispenses into the plate currently loaded in the EL406 (see ``EL406.set_plate``). + + Args: + volumes: Volume in uL per column index. + flow_rate: Flow rate ("Low", "Medium", or "High"). + offset_x: X offset in mm (-12.5 to 12.5). + offset_y: Y offset in mm (-4.0 to 4.0). + offset_z: Z offset in mm (0.1-150.0). None picks the plate-type default: + 33.6 for 96/384-well, 25.4 for 1536-well. + pre_dispense_volume: Pre-dispense volume in uL (0 to disable). + num_pre_dispenses: Number of pre-dispenses. + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + rows: List of 1-indexed row group numbers, or None for all. + For 96-well: only 1 (no selection). For 384-well: 1-2. For 1536-well: 1-4. + """ + plate = self._el406.plate + + # Group consecutive columns with the same volume, in ascending order + groups: list[tuple[float, list[int]]] = [] + for col in sorted(volumes.keys()): + vol = volumes[col] + if groups and groups[-1][0] == vol: + groups[-1][1].append(col) + else: + groups.append((vol, [col])) + + async with self._el406.batch(): + for vol, cols in groups: + await self._peristaltic_dispense( + plate, + volume=vol, + columns=cols, + flow_rate=flow_rate, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + pre_dispense_volume=pre_dispense_volume, + num_pre_dispenses=num_pre_dispenses, + cassette=cassette, + rows=rows, + ) + + async def prime( + self, + volume: Optional[float] = None, + duration: Optional[int] = None, + flow_rate: PeristalticFlowRate = "High", + cassette: Cassette = "Any", + ) -> None: + """Prime the peristaltic pump fluid lines. + + Fills the peristaltic pump tubing with liquid. Specify either volume + or duration, but not both. If neither is specified, defaults to 1000 uL. + + Note: Peristaltic prime has no buffer selection. Use the manifold + ``prime()`` on :class:`PlateWasher` for buffer-specific priming. + + Uses the plate currently loaded in the EL406 (see ``EL406.set_plate``). + + Args: + volume: Prime volume in uL (1-3000). Mutually exclusive with duration. + duration: Fixed prime duration in seconds (1-300). Mutually exclusive + with volume. + flow_rate: Flow rate ("Low", "Medium", or "High"). + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + + Raises: + ValueError: If parameters are invalid or both volume and duration given. + """ + if volume is not None and duration is not None: + raise ValueError("Specify either volume or duration, not both.") + if duration is not None: + if not 1 <= duration <= 300: + raise ValueError("duration must be 1-300 seconds") + wire_volume, wire_duration = 0.0, duration + else: + if volume is None: + volume = 1000.0 + if not 1 <= volume <= 3000: + raise ValueError("volume must be 1-3000 uL (GUI limit)") + wire_volume, wire_duration = volume, 0 + + validate_peristaltic_flow_rate(flow_rate) + logger.info( + "Peristaltic prime: %.1f uL, flow rate %s, cassette %s", wire_volume, flow_rate, cassette + ) + + data = self._build_peristaltic_prime_command( + plate=self._el406.plate, + volume=wire_volume, + duration=wire_duration, + flow_rate=PERISTALTIC_FLOW_RATE_MAP[flow_rate], + reverse=True, + cassette=cassette, + pump=1, + ) + framed_command = build_framed_message(command=0x90, data=data) + async with self._el406.batch(): + await self._el406._send_step_command( + framed_command, timeout=self._el406.timeout + wire_duration + 30 + ) + + async def purge( + self, + volume: Optional[float] = None, + duration: Optional[int] = None, + flow_rate: PeristalticFlowRate = "High", + cassette: Cassette = "Any", + ) -> None: + """Purge the peristaltic pump fluid lines. + + Clears liquid from the peristaltic pump tubing. Uses the same wire + format as prime (identical data bytes, different command byte 0x91). + Specify either volume or duration, but not both. + + Uses the plate currently loaded in the EL406 (see ``EL406.set_plate``). + + Args: + volume: Purge volume in uL (1-3000). Mutually exclusive with duration. + duration: Fixed purge duration in seconds (1-300). Mutually exclusive + with volume. + flow_rate: Flow rate ("Low", "Medium", or "High"). + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + + Raises: + ValueError: If parameters are invalid, both are given, or neither is given. + """ + if volume is not None and duration is not None: + raise ValueError("Specify either volume or duration, not both.") + if volume is None and duration is None: + raise ValueError("Either volume or duration must be specified.") + if duration is not None: + if not 1 <= duration <= 300: + raise ValueError("duration must be 1-300 seconds") + wire_volume, wire_duration = 0.0, duration + else: + assert volume is not None + if not 1 <= volume <= 3000: + raise ValueError("volume must be 1-3000 uL (GUI limit)") + wire_volume, wire_duration = volume, 0 + + validate_peristaltic_flow_rate(flow_rate) + logger.info( + "Peristaltic purge: %.1f uL, flow rate %s, cassette %s", wire_volume, flow_rate, cassette + ) + + data = self._build_peristaltic_prime_command( + plate=self._el406.plate, + volume=wire_volume, + duration=wire_duration, + flow_rate=PERISTALTIC_FLOW_RATE_MAP[flow_rate], + reverse=True, + cassette=cassette, + pump=1, + ) + framed_command = build_framed_message(command=0x91, data=data) + async with self._el406.batch(): + await self._el406._send_step_command( + framed_command, timeout=self._el406.timeout + wire_duration + 30 + ) + + def _validate_well_selection( + self, + plate: Plate, + columns: Optional[list[int]], + rows: Optional[list[int]], + ) -> Optional[list[int]]: + """Validate column/row selection and return column mask.""" + max_cols = plate_max_columns(plate) + if columns is not None: + for col in columns: + if col < 1 or col > max_cols: + raise ValueError(f"Column {col} out of range for plate type (1-{max_cols}).") + max_rows = plate_max_row_groups(plate) + if rows is not None: + for row in rows: + if row < 1 or row > max_rows: + raise ValueError(f"Row {row} out of range for plate type (1-{max_rows}).") + return columns_to_column_mask(columns, plate_wells=plate_well_count(plate)) + + def _validate_dispense_params( + self, + plate: Plate, + volume: float, + columns: Optional[list[int]], + flow_rate: PeristalticFlowRate, + offset_x: float, + offset_y: float, + offset_z: Optional[float], + pre_dispense_volume: float, + rows: Optional[list[int]], + ) -> tuple[int, int, int, int, Optional[list[int]]]: + """Validate peristaltic dispense parameters and resolve defaults. + + Returns: + (offset_x_steps, offset_y_steps, offset_z_steps, flow_rate_enum, column_mask) + """ + # Convert mm → 0.1mm steps for wire protocol + offset_x_steps = round(offset_x * 10) + offset_y_steps = round(offset_y * 10) + offset_z_steps = round(offset_z * 10) if offset_z is not None else None + + if not 1 <= volume <= 3000: + raise ValueError(f"Peri-pump dispense volume must be 1-3000 uL, got {volume}") + validate_peristaltic_flow_rate(flow_rate) + if not -125 <= offset_x_steps <= 125: + raise ValueError(f"Peri-pump dispense X-axis offset must be -125..125, got {offset_x_steps}") + if not -40 <= offset_y_steps <= 40: + raise ValueError(f"Peri-pump dispense Y-axis offset must be -40..40, got {offset_y_steps}") + + if offset_z_steps is None: + offset_z_steps = plate_default_z(plate) + if not 1 <= offset_z_steps <= 1500: + raise ValueError(f"Peri-pump dispense Z-axis offset must be 1..1500, got {offset_z_steps}") + + if pre_dispense_volume < 0: + raise ValueError(f"pre_dispense_volume must be non-negative, got {pre_dispense_volume}") + + column_mask = self._validate_well_selection(plate, columns, rows) + flow_rate_enum = PERISTALTIC_FLOW_RATE_MAP[flow_rate] + + return (offset_x_steps, offset_y_steps, offset_z_steps, flow_rate_enum, column_mask) + + async def _peristaltic_dispense( + self, + plate: Plate, + volume: float, + columns: Optional[list[int]] = None, + flow_rate: PeristalticFlowRate = "High", + offset_x: float = 0.0, + offset_y: float = 0.0, + offset_z: Optional[float] = None, + pre_dispense_volume: float = 10.0, + num_pre_dispenses: int = 2, + cassette: Cassette = "Any", + rows: Optional[list[int]] = None, + ) -> None: + """Send a single peristaltic dispense command for a set of columns. + + Args: + plate: PLR Plate resource. + volume: Dispense volume in microliters (1-3000). + columns: 1-indexed column numbers to dispense to, or None for all. + """ + offset_x_steps, offset_y_steps, offset_z_steps, flow_rate_enum, column_mask = ( + self._validate_dispense_params( + plate, + volume, + columns, + flow_rate=flow_rate, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + pre_dispense_volume=pre_dispense_volume, + rows=rows, + ) + ) + + logger.info( + "Peristaltic dispense: %.1f uL, flow rate %s, cassette %s", volume, flow_rate, cassette + ) + + data = self._build_peristaltic_dispense_command( + plate=plate, + volume=volume, + flow_rate=flow_rate_enum, + cassette=cassette, + offset_x=offset_x_steps, + offset_y=offset_y_steps, + offset_z=offset_z_steps, + pre_dispense_volume=pre_dispense_volume, + num_pre_dispenses=num_pre_dispenses, + column_mask=column_mask, + rows=rows, + pump=1, + ) + framed_command = build_framed_message(command=0x8F, data=data) + async with self._el406.batch(): + await self._el406._send_step_command(framed_command) + + # ========================================================================= + # COMMAND BUILDERS + # ========================================================================= + + def _build_peristaltic_prime_command( + self, + plate: Plate, + volume: float, + duration: int = 0, + flow_rate: int = 2, + reverse: bool = True, + cassette: Cassette = "Any", + pump: int = 1, + ) -> bytes: + """Build peristaltic prime command bytes. + + Protocol format (11 bytes): + Example: 04 2c 01 00 00 02 01 00 01 00 00 + + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1-2] Volume (LE) — 0x0000 when using duration mode + [3-4] Duration in seconds (LE) — 0x0000 when using volume mode + [5] Flow rate enum (0=Low, 1=Medium, 2=High) + [6] Reverse/submerge (0 or 1) + [7] Cassette type (Any: 0, 1uL: 1, 5uL: 2, 10uL: 3) + [8] Pump (Primary: 1, Secondary: 2) + [9-10] Padding (0x0000) + + Args: + volume: Prime volume in microliters (0 when using duration mode). + duration: Fixed duration in seconds (0 when using volume mode). + flow_rate: Flow rate (0=Low, 1=Medium, 2=High). + reverse: Whether to reverse/submerge after prime. + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + pump: Pump (1=Primary, 2=Secondary). + + Returns: + Command bytes (11 bytes). + """ + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u16(int(volume)) # [1-2] Volume (LE) + .u16(duration) # [3-4] Duration (LE) + .u8(flow_rate) # [5] Flow rate + .u8(1 if reverse else 0) # [6] Reverse/submerge + .u8(cassette_to_byte(cassette)) # [7] Cassette type + .u8(pump & 0xFF) # [8] Pump + .raw_bytes(b'\x00' * 2) # [9-10] Padding + .finish() + ) # fmt: skip + + def _build_peristaltic_dispense_command( + self, + plate: Plate, + volume: float, + flow_rate: int, + cassette: Cassette = "Any", + offset_x: int = 0, + offset_y: int = 0, + offset_z: int = 336, + pre_dispense_volume: float = 0.0, + num_pre_dispenses: int = 2, + column_mask: Optional[list[int]] = None, + rows: Optional[list[int]] = None, + pump: int = 1, + ) -> bytes: + """Build peristaltic dispense command bytes. + + Protocol format (24 bytes): + Example: 04 0a 00 02 00 00 00 50 01 0a 00 02 ff ff ff ff ff ff 00 01 00 00 00 00 + + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1-2] Volume (LE) + [3] Flow rate (0=Low, 1=Med, 2=High) + [4] Cassette type (Any: 0, 1uL: 1, 5uL: 2, 10uL: 3) + [5] Offset X (signed byte) + [6] Offset Y (signed byte) + [7-8] Offset Z (LE) + [9-10] Pre-dispense volume (LE, 0 if disabled) + [11] Num pre-dispenses + [12-17] Column mask (48 bits packed, normal: 1=selected) + [18] Row mask (4 bits packed, INVERTED: 0=selected, 1=deselected) + [19] Pump (Primary: 1, Secondary: 2) + [20-23] Padding + + Args: + volume: Dispense volume in microliters. + flow_rate: Flow rate (0=Low, 1=Medium, 2=High). + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + offset_x: X offset (signed, 0.1mm units). + offset_y: Y offset (signed, 0.1mm units). + offset_z: Z offset (0.1mm units). + pre_dispense_volume: Pre-dispense volume in uL. + num_pre_dispenses: Number of pre-dispenses (default 2). + column_mask: List of column indices (0-47) or None for all columns. + rows: List of row numbers (1-4) or None for all rows. + pump: Pump (1=Primary, 2=Secondary). + + Returns: + Command bytes (24 bytes). + """ + num_row_groups = plate_max_row_groups(plate) + + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u16(int(volume)) # [1-2] Volume (LE) + .u8(flow_rate) # [3] Flow rate + .u8(cassette_to_byte(cassette)) # [4] Cassette type + .i8(offset_x) # [5] Offset X + .i8(offset_y) # [6] Offset Y + .u16(offset_z) # [7-8] Offset Z (LE) + .u16(int(pre_dispense_volume)) # [9-10] Pre-dispense vol + .u8(num_pre_dispenses) # [11] Num pre-dispenses + .raw_bytes(encode_column_mask(column_mask)) # [12-17] Column mask + .u8(encode_quadrant_mask_inverted(rows, num_row_groups=num_row_groups)) # [18] Row mask + .u8(pump & 0xFF) # [19] Pump + .raw_bytes(b'\x00' * 4) # [20-23] Padding + .finish() + ) # fmt: skip diff --git a/pylabrobot/agilent/biotek/el406/plate_washer.py b/pylabrobot/agilent/biotek/el406/plate_washer.py new file mode 100644 index 00000000000..5df2d331560 --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/plate_washer.py @@ -0,0 +1,1224 @@ +"""EL406 manifold step methods. + +Provides aspirate, dispense, wash, prime, +and auto_clean operations plus their corresponding command builders. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Literal, Optional + +from pylabrobot.io.binary import Writer +from pylabrobot.resources import Plate + +from .helpers import plate_defaults, plate_to_wire_byte +from .protocol import build_framed_message + +if TYPE_CHECKING: + from .el406 import EL406 + +Intensity = Literal["Variable", "Slow", "Medium", "Fast"] + +INTENSITY_TO_BYTE: dict[str, int] = { + "Variable": 0x01, + "Slow": 0x02, + "Medium": 0x03, + "Fast": 0x04, +} + + +def validate_intensity(intensity: Intensity) -> None: + if intensity not in {"Slow", "Medium", "Fast", "Variable"}: + raise ValueError( + f"intensity must be one of {sorted({'Slow', 'Medium', 'Fast', 'Variable'})}, " + f"got {intensity!r}" + ) + + +logger = logging.getLogger(__name__) + +Buffer = Literal["A", "B", "C", "D"] +TravelRate = Literal["1", "2", "3", "4", "5", "1 CW", "2 CW", "3 CW", "4 CW", "6 CW"] + +TRAVEL_RATE_TO_BYTE: dict[str, int] = { + "1": 1, + "2": 2, + "3": 3, + "4": 4, + "5": 5, + "1 CW": 7, + "2 CW": 8, + "3 CW": 9, + "4 CW": 10, + "6 CW": 6, +} + + +def travel_rate_to_byte(rate: TravelRate) -> int: + if rate not in TRAVEL_RATE_TO_BYTE: + valid = sorted(TRAVEL_RATE_TO_BYTE.keys()) + raise ValueError( + f"Invalid travel rate '{rate}'. Must be one of: {', '.join(repr(r) for r in valid)}" + ) + return TRAVEL_RATE_TO_BYTE[rate] + + +def get_plate_wash_defaults(plate: Plate) -> dict: + pt = plate_defaults(plate) + return { + "dispense_volume": 300.0 if pt["cols"] == 12 else 100.0, + "dispense_z": pt["dispense_z"], + "aspirate_z": pt["aspirate_z"], + } + + +def validate_buffer(buffer: Buffer) -> None: + if buffer.upper() not in {"A", "B", "C", "D"}: + raise ValueError(f"Invalid buffer '{buffer}'. Must be one of: A, B, C, D") + + +def validate_flow_rate(flow_rate: int) -> None: + if not 1 <= flow_rate <= 9: + raise ValueError(f"Invalid flow rate {flow_rate}. Must be between 1 and 9.") + + +def validate_cycles(cycles: int) -> None: + if not 1 <= cycles <= 250: + raise ValueError(f"cycles must be 1-250, got {cycles}") + + +def validate_delay_ms(delay_ms: int) -> None: + if not 0 <= delay_ms <= 65535: + raise ValueError(f"delay_ms must be 0-65535, got {delay_ms}") + + +def validate_travel_rate(rate: int) -> None: + if not 1 <= rate <= 9: + raise ValueError(f"travel_rate must be 1-9, got {rate}") + + +class PlateWasher: + """Manifold plate washer for the BioTek EL406.""" + + def __init__(self, el406: EL406) -> None: + self._el406 = el406 + + @staticmethod + def _validate_manifold_xy(x: int, y: int, label: str) -> None: + """Validate manifold X/Y offsets (X: -60..60, Y: -40..40).""" + if not -60 <= x <= 60: + raise ValueError(f"{label} X offset must be -60..60, got {x}") + if not -40 <= y <= 40: + raise ValueError(f"{label} Y offset must be -40..40, got {y}") + + def _validate_aspirate_params( + self, + plate: Plate, + vacuum_filtration: bool, + travel_rate: TravelRate, + delay: float, + vacuum_time: float, + offset_x: int, + offset_y: int, + offset_z: Optional[int], + secondary_aspirate: bool, + secondary_x: int, + secondary_y: int, + secondary_z: Optional[int], + ) -> tuple[int, int, int, int]: + """Validate aspirate parameters and resolve plate-type defaults. + + Returns: + (offset_z, secondary_z, time_value, rate_byte) + """ + pt_defaults = get_plate_wash_defaults(plate) + resolved_z = offset_z if offset_z is not None else pt_defaults["aspirate_z"] + resolved_secondary_z = secondary_z if secondary_z is not None else pt_defaults["aspirate_z"] + + # validate aspiration mode + delay_ms = round(delay * 1000) + vacuum_time_sec = round(vacuum_time) + if not vacuum_filtration: + if travel_rate not in TRAVEL_RATE_TO_BYTE: + raise ValueError( + f"Invalid travel rate '{travel_rate}'. Must be one of: " + f"{', '.join(repr(r) for r in sorted(TRAVEL_RATE_TO_BYTE))}" + ) + if not 0 <= delay_ms <= 5000: + raise ValueError(f"Aspirate delay must be 0-5000 ms, got {delay_ms}") + time_value, rate_byte = delay_ms, travel_rate_to_byte(travel_rate) + else: + if not 5 <= vacuum_time_sec <= 999: + raise ValueError(f"Vacuum filtration time must be 5-999 seconds, got {vacuum_time_sec}") + time_value, rate_byte = vacuum_time_sec, travel_rate_to_byte("3") + + # validate offsets + self._validate_manifold_xy(offset_x, offset_y, "Aspirate") + if not 1 <= resolved_z <= 210: + raise ValueError(f"Aspirate Z offset must be 1-210, got {resolved_z}") + if secondary_aspirate: + self._validate_manifold_xy(secondary_x, secondary_y, "Secondary") + if not 1 <= resolved_secondary_z <= 210: + raise ValueError(f"Secondary Z offset must be 1-210, got {resolved_secondary_z}") + + return (resolved_z, resolved_secondary_z, time_value, rate_byte) + + def _validate_dispense_params( + self, + plate: Plate, + volume: float, + buffer: Buffer, + flow_rate: int, + offset_x: int, + offset_y: int, + offset_z: Optional[int], + pre_dispense_volume: float, + pre_dispense_flow_rate: int, + vacuum_delay_volume: float, + ) -> int: + """Validate dispense parameters and resolve plate-type defaults. + + Returns: + Resolved offset_z. + """ + if offset_z is None: + pt_defaults = get_plate_wash_defaults(plate) + offset_z = pt_defaults["dispense_z"] + + if not 25 <= volume <= 3000: + raise ValueError(f"Manifold dispense volume must be 25-3000 uL, got {volume}") + validate_buffer(buffer) + if not 1 <= flow_rate <= 11: + raise ValueError(f"Manifold dispense flow rate must be 1-11, got {flow_rate}") + if flow_rate <= 2 and vacuum_delay_volume <= 0: + raise ValueError( + f"Flow rates 1-2 (cell wash) require vacuum_delay_volume > 0, " + f"got flow_rate={flow_rate} with vacuum_delay_volume={vacuum_delay_volume}" + ) + self._validate_manifold_xy(offset_x, offset_y, "Manifold dispense") + if not 1 <= offset_z <= 210: + raise ValueError(f"Manifold dispense Z offset must be 1-210, got {offset_z}") + + # validate pre-dispense and vacuum delay + if pre_dispense_volume != 0 and not 25 <= pre_dispense_volume <= 3000: + raise ValueError( + f"Manifold pre-dispense volume must be 0 (disabled) or 25-3000 uL, " + f"got {pre_dispense_volume}" + ) + if not 3 <= pre_dispense_flow_rate <= 11: + raise ValueError( + f"Manifold pre-dispense flow rate must be 3-11, got {pre_dispense_flow_rate}" + ) + if not 0 <= vacuum_delay_volume <= 3000: + raise ValueError(f"Manifold vacuum delay volume must be 0-3000 uL, got {vacuum_delay_volume}") + + return offset_z + + def _resolve_wash_defaults( + self, + plate: Plate, + dispense_volume: Optional[float], + dispense_z: Optional[int], + aspirate_z: Optional[int], + secondary_z: Optional[int], + final_secondary_z: Optional[int], + ) -> tuple[float, int, int, int, int]: + """Resolve plate-type-aware defaults for wash parameters.""" + pt_defaults = get_plate_wash_defaults(plate) + if dispense_volume is None: + dispense_volume = pt_defaults["dispense_volume"] + if dispense_z is None: + dispense_z = pt_defaults["dispense_z"] + if aspirate_z is None: + aspirate_z = pt_defaults["aspirate_z"] + if secondary_z is None: + secondary_z = pt_defaults["aspirate_z"] + if final_secondary_z is None: + final_secondary_z = pt_defaults["aspirate_z"] + return (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z) + + def _validate_wash_params( + self, + plate: Plate, + cycles: int, + dispense_volume: Optional[float], + *, + buffer: Buffer, + dispense_flow_rate: int, + dispense_x: int, + dispense_y: int, + dispense_z: Optional[int], + aspirate_travel_rate: int, + aspirate_z: Optional[int], + pre_dispense_flow_rate: int, + aspirate_delay: float, + aspirate_x: int, + aspirate_y: int, + final_aspirate_x: int, + final_aspirate_y: int, + final_aspirate_delay: float, + pre_dispense_volume: float, + vacuum_delay_volume: float, + soak_duration: int, + shake_duration: int, + shake_intensity: Intensity, + secondary_aspirate: bool, + secondary_z: Optional[int], + secondary_x: int, + secondary_y: int, + final_secondary_aspirate: bool, + final_secondary_z: Optional[int], + final_secondary_x: int, + final_secondary_y: int, + bottom_wash: bool, + bottom_wash_volume: float, + bottom_wash_flow_rate: int, + pre_dispense_between_cycles_volume: float, + pre_dispense_between_cycles_flow_rate: int, + wash_format: Literal["Plate", "Sector"], + sectors: Optional[list[int]], + ) -> tuple[float, int, int, int, int, int, int, int]: + """Validate wash parameters and resolve plate-type defaults. + + Returns: + (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z, + aspirate_delay_ms, final_aspirate_delay_ms, sector_mask) + """ + aspirate_delay_ms = round(aspirate_delay * 1000) + final_aspirate_delay_ms = round(final_aspirate_delay * 1000) + + if sectors is not None: + sector_mask = 0 + for q in sectors: + if not 1 <= q <= 4: + raise ValueError(f"Sector/quadrant must be 1-4, got {q}") + sector_mask |= 1 << (q - 1) + else: + sector_mask = 0x0F + # resolve plate-type defaults + ( + dispense_volume, + resolved_dispense_z, + resolved_aspirate_z, + resolved_secondary_z, + resolved_final_secondary_z, + ) = self._resolve_wash_defaults( + plate, + dispense_volume, + dispense_z, + aspirate_z, + secondary_z, + final_secondary_z, + ) + + # core dispense/aspirate params + validate_cycles(cycles) + if dispense_volume <= 0: + raise ValueError(f"dispense_volume must be positive, got {dispense_volume}") + validate_buffer(buffer) + validate_flow_rate(dispense_flow_rate) + self._validate_manifold_xy(dispense_x, dispense_y, "Wash dispense") + validate_travel_rate(aspirate_travel_rate) + self._validate_manifold_xy(aspirate_x, aspirate_y, "Wash aspirate") + if wash_format not in ("Plate", "Sector"): + raise ValueError(f"wash_format must be 'Plate' or 'Sector', got '{wash_format}'") + if not 0 <= sector_mask <= 0xFFFF: + raise ValueError(f"sector_mask must be 0x0000-0xFFFF, got 0x{sector_mask:04X}") + validate_flow_rate(pre_dispense_flow_rate) + validate_delay_ms(aspirate_delay_ms) + + # final aspirate, pre-dispense, soak/shake + self._validate_manifold_xy(final_aspirate_x, final_aspirate_y, "Final aspirate") + validate_delay_ms(final_aspirate_delay_ms) + if pre_dispense_volume != 0 and not 25 <= pre_dispense_volume <= 3000: + raise ValueError( + f"Wash pre-dispense volume must be 0 (disabled) or 25-3000 uL, got {pre_dispense_volume}" + ) + if not 0 <= vacuum_delay_volume <= 3000: + raise ValueError(f"Wash vacuum delay volume must be 0-3000 uL, got {vacuum_delay_volume}") + if not 0 <= soak_duration <= 3599: + raise ValueError(f"Wash soak duration must be 0-3599 seconds, got {soak_duration}") + if not 0 <= shake_duration <= 3599: + raise ValueError(f"Wash shake duration must be 0-3599 seconds, got {shake_duration}") + validate_intensity(shake_intensity) + + # secondary aspirates + if secondary_aspirate: + self._validate_manifold_xy(secondary_x, secondary_y, "Secondary") + if final_secondary_aspirate: + self._validate_manifold_xy(final_secondary_x, final_secondary_y, "Final secondary") + + # bottom wash and mid-cycle pre-dispense + if bottom_wash: + if not 25 <= bottom_wash_volume <= 3000: + raise ValueError(f"Bottom wash volume must be 25-3000 uL, got {bottom_wash_volume}") + validate_flow_rate(bottom_wash_flow_rate) + if pre_dispense_between_cycles_volume != 0: + if not 25 <= pre_dispense_between_cycles_volume <= 3000: + raise ValueError( + f"Pre-dispense between cycles volume must be 0 (disabled) or " + f"25-3000 uL, got {pre_dispense_between_cycles_volume}" + ) + validate_flow_rate(pre_dispense_between_cycles_flow_rate) + + return ( + dispense_volume, + resolved_dispense_z, + resolved_aspirate_z, + resolved_secondary_z, + resolved_final_secondary_z, + aspirate_delay_ms, + final_aspirate_delay_ms, + sector_mask, + ) + + async def aspirate( + self, + vacuum_filtration: bool = False, + travel_rate: TravelRate = "3", + delay: float = 0.0, + vacuum_time: float = 30.0, + offset_x: int = 0, + offset_y: int = 0, + offset_z: Optional[int] = None, + secondary_aspirate: bool = False, + secondary_x: int = 0, + secondary_y: int = 0, + secondary_z: Optional[int] = None, + ) -> None: + """Aspirate liquid from all wells via the wash manifold. + + Two modes, based on ``vacuum_filtration``: + + - Normal (vacuum_filtration=False): Uses travel_rate and delay. + - Vacuum filtration (vacuum_filtration=True): Uses vacuum_time. + Travel rate is ignored (greyed out in GUI). + + Args: + vacuum_filtration: Enable vacuum filtration mode. + travel_rate: Head travel rate ("1"-"5", or cell wash "1 CW"-"6 CW"). + delay: Post-aspirate delay in seconds (0-5). + vacuum_time: Vacuum filtration time in seconds (5-999). + offset_x: X offset in steps (-60 to +60). + offset_y: Y offset in steps (-40 to +40). + offset_z: Z offset in steps (1-210). None for plate-type default. + secondary_aspirate: Enable secondary aspirate at a different position. + secondary_x: Secondary X offset (-60 to +60). + secondary_y: Secondary Y offset (-40 to +40). + secondary_z: Secondary Z offset (1-210). None for plate-type default. + """ + resolved_z, resolved_secondary_z, time_value, rate_byte = self._validate_aspirate_params( + self._el406.plate, + vacuum_filtration=vacuum_filtration, + travel_rate=travel_rate, + delay=delay, + vacuum_time=vacuum_time, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + secondary_aspirate=secondary_aspirate, + secondary_x=secondary_x, + secondary_y=secondary_y, + secondary_z=secondary_z, + ) + + logger.info( + "Aspirating: vacuum=%s, travel_rate=%s, delay=%.3f s", + vacuum_filtration, + travel_rate, + delay, + ) + + data = self._build_aspirate_command( + plate=self._el406.plate, + vacuum_filtration=vacuum_filtration, + time_value=time_value, + travel_rate_byte=rate_byte, + offset_x=offset_x, + offset_y=offset_y, + offset_z=resolved_z, + secondary_mode=1 if secondary_aspirate else 0, + secondary_x=secondary_x, + secondary_y=secondary_y, + secondary_z=resolved_secondary_z, + ) + framed_command = build_framed_message(command=0xA5, data=data) + async with self._el406.batch(): + await self._el406._send_step_command(framed_command) + + async def dispense( + self, + volume: float, + buffer: Buffer = "A", + flow_rate: int = 7, + offset_x: int = 0, + offset_y: int = 0, + offset_z: Optional[int] = None, + pre_dispense_volume: float = 0.0, + pre_dispense_flow_rate: int = 9, + vacuum_delay_volume: float = 0.0, + ) -> None: + """Dispense liquid to all wells via the wash manifold. + + Args: + volume: Volume to dispense in uL/well (25-3000). + buffer: Buffer valve selection (A, B, C, D). + flow_rate: Dispense flow rate (1-11). + offset_x: X offset in steps (-60 to +60). + offset_y: Y offset in steps (-40 to +40). + offset_z: Z offset in steps (1-210). None for plate-type default. + pre_dispense_volume: Pre-dispense volume in uL/tube (0 to disable). + pre_dispense_flow_rate: Pre-dispense flow rate (3-11). + vacuum_delay_volume: Delay start of vacuum until volume dispensed in uL/well. + """ + resolved_z = self._validate_dispense_params( + self._el406.plate, + volume, + buffer=buffer, + flow_rate=flow_rate, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + pre_dispense_volume=pre_dispense_volume, + pre_dispense_flow_rate=pre_dispense_flow_rate, + vacuum_delay_volume=vacuum_delay_volume, + ) + + logger.info("Dispensing %.1f uL from buffer %s, flow rate %d", volume, buffer, flow_rate) + + data = self._build_dispense_command( + plate=self._el406.plate, + volume=volume, + buffer=buffer, + flow_rate=flow_rate, + offset_x=offset_x, + offset_y=offset_y, + offset_z=resolved_z, + pre_dispense_volume=pre_dispense_volume, + pre_dispense_flow_rate=pre_dispense_flow_rate, + vacuum_delay_volume=vacuum_delay_volume, + ) + framed_command = build_framed_message(command=0xA6, data=data) + async with self._el406.batch(): + await self._el406._send_step_command(framed_command) + + async def wash( + self, + cycles: int = 3, + dispense_volume: Optional[float] = None, + buffer: Buffer = "A", + dispense_flow_rate: int = 7, + dispense_x: int = 0, + dispense_y: int = 0, + dispense_z: Optional[int] = None, + aspirate_travel_rate: int = 3, + aspirate_z: Optional[int] = None, + pre_dispense_flow_rate: int = 9, + aspirate_delay: float = 0.0, + aspirate_x: int = 0, + aspirate_y: int = 0, + final_aspirate: bool = True, + final_aspirate_z: Optional[int] = None, + final_aspirate_x: int = 0, + final_aspirate_y: int = 0, + final_aspirate_delay: float = 0.0, + pre_dispense_volume: float = 0.0, + vacuum_delay_volume: float = 0.0, + soak_duration: int = 0, + shake_duration: int = 0, + shake_intensity: Intensity = "Medium", + secondary_aspirate: bool = False, + secondary_z: Optional[int] = None, + secondary_x: int = 0, + secondary_y: int = 0, + final_secondary_aspirate: bool = False, + final_secondary_z: Optional[int] = None, + final_secondary_x: int = 0, + final_secondary_y: int = 0, + bottom_wash: bool = False, + bottom_wash_volume: float = 0.0, + bottom_wash_flow_rate: int = 5, + pre_dispense_between_cycles_volume: float = 0.0, + pre_dispense_between_cycles_flow_rate: int = 9, + wash_format: Literal["Plate", "Sector"] = "Plate", + sectors: Optional[list[int]] = None, + move_home_first: bool = False, + ) -> None: + """Perform manifold wash cycles. + + Sends a 102-byte MANIFOLD_WASH (0xA4) command that performs repeated + dispense-aspirate cycles. The wire format contains two dispense sections, + two aspirate sections, and a final shake/soak section. + + The wash command supports 4 independent coordinate sets: + - Primary aspirate (aspirate_x/y/z): between-cycle aspirate position + - Primary secondary (secondary_x/y/z): second aspirate position per cycle + - Final aspirate (final_aspirate_x/y/z): aspirate after last cycle + - Final secondary (final_secondary_x/y/z): second position for final aspirate + + Args: + cycles: Number of wash cycles (1-250). Default 3. + Encoded at header byte [6]. + dispense_volume: Volume to dispense per cycle in uL. Default None + (plate-type-aware: 300 for 96-well, 100 for others). + buffer: Buffer valve selection (A, B, C, D). + dispense_flow_rate: Flow rate for dispensing (1-9). + dispense_x: Dispense X offset in steps (-60 to +60). + dispense_y: Dispense Y offset in steps (-40 to +40). + dispense_z: Z offset for dispense in 0.1mm units (1-210). None is + plate-type-aware: 121 for 96-well, 120 for 384-well, etc. + aspirate_travel_rate: Travel rate for aspiration (1-9). + aspirate_z: Z offset for aspirate in 0.1mm units (1-210). None is + plate-type-aware: 29 for 96-well, 22 for 384-well, etc. + pre_dispense_flow_rate: Pre-dispense flow rate (3-11). Controls how fast the + pre-dispense is delivered. + aspirate_delay: Post-aspirate delay in seconds (0-65.535). Wire resolution: 1 ms. + aspirate_x: Aspirate X offset in steps (-60 to +60). + aspirate_y: Aspirate Y offset in steps (-40 to +40). + final_aspirate: Enable final aspirate after last cycle. Encoded in header + config flags byte [2]. + final_aspirate_z: Z offset for final aspirate (1-210). None inherits from + aspirate_z. Independent from primary aspirate Z. + final_aspirate_x: X offset for final aspirate (-60 to +60). + final_aspirate_y: Y offset for final aspirate (-40 to +40). + final_aspirate_delay: Post-aspirate delay for final aspirate in seconds + (0-65.535). Wire resolution: 1 ms. + pre_dispense_volume: Pre-dispense volume in uL/tube (0 to disable, 25-3000 + when enabled). + vacuum_delay_volume: Vacuum delay volume in uL/well (0 to disable, 0-3000 + when enabled). Cell wash operations only. + soak_duration: Soak duration in seconds (0 to disable, 0-3599). + shake_duration: Shake duration in seconds (0 to disable, 0-3599). + shake_intensity: Shake intensity ("Variable", "Slow", "Medium", "Fast"). + secondary_aspirate: Enable secondary aspirate for primary (between-cycle) aspirate. + secondary_z: Z offset for secondary aspirate in 0.1mm units (1-210). None is + plate-type-aware, same as aspirate_z default. + secondary_x: Secondary aspirate X offset (-60 to +60). + secondary_y: Secondary aspirate Y offset (-40 to +40). + final_secondary_aspirate: Enable secondary aspirate for final aspirate. + final_secondary_z: Z offset for final secondary aspirate (1-210). None is + plate-type-aware, same as aspirate_z default. + final_secondary_x: X offset for final secondary aspirate (-60 to +60). + final_secondary_y: Y offset for final secondary aspirate (-40 to +40). + bottom_wash: Enable bottom wash. Encoded in header[1]. + bottom_wash_volume: Bottom wash volume in uL (25-3000). + bottom_wash_flow_rate: Bottom wash flow rate (3-11). + pre_dispense_between_cycles_volume: Pre-dispense volume between wash cycles in + uL (0 to disable, 25-3000 when enabled). + pre_dispense_between_cycles_flow_rate: Flow rate for pre-dispense between + cycles (3-11). + wash_format: Wash format ("Plate" or "Sector"). Encoded at header[3]: + Plate=0x00, Sector=0x01. 384-well plates typically use "Sector" for + quadrant-based washing. + sectors: List of quadrant numbers to wash (1-4). None means all 4. Example: + ``sectors=[1, 2]`` washes quadrants 1 and 2. Only used when + wash_format="Sector". + move_home_first: Move carrier to home position before shake/soak. Same as in + the standalone shake interface. Encoded at wire [87] (shake/soak section + byte 0). + + Raises: + ValueError: If parameters are invalid. + """ + # Validate — returns resolved defaults and derived wire values + ( + dispense_volume, + resolved_dispense_z, + resolved_aspirate_z, + resolved_secondary_z, + resolved_final_secondary_z, + aspirate_delay_ms, + final_aspirate_delay_ms, + sector_mask, + ) = self._validate_wash_params( + self._el406.plate, + cycles, + dispense_volume, + buffer=buffer, + dispense_flow_rate=dispense_flow_rate, + dispense_x=dispense_x, + dispense_y=dispense_y, + dispense_z=dispense_z, + aspirate_travel_rate=aspirate_travel_rate, + aspirate_z=aspirate_z, + pre_dispense_flow_rate=pre_dispense_flow_rate, + aspirate_delay=aspirate_delay, + aspirate_x=aspirate_x, + aspirate_y=aspirate_y, + final_aspirate_x=final_aspirate_x, + final_aspirate_y=final_aspirate_y, + final_aspirate_delay=final_aspirate_delay, + pre_dispense_volume=pre_dispense_volume, + vacuum_delay_volume=vacuum_delay_volume, + soak_duration=soak_duration, + shake_duration=shake_duration, + shake_intensity=shake_intensity, + secondary_aspirate=secondary_aspirate, + secondary_z=secondary_z, + secondary_x=secondary_x, + secondary_y=secondary_y, + final_secondary_aspirate=final_secondary_aspirate, + final_secondary_z=final_secondary_z, + final_secondary_x=final_secondary_x, + final_secondary_y=final_secondary_y, + bottom_wash=bottom_wash, + bottom_wash_volume=bottom_wash_volume, + bottom_wash_flow_rate=bottom_wash_flow_rate, + pre_dispense_between_cycles_volume=pre_dispense_between_cycles_volume, + pre_dispense_between_cycles_flow_rate=pre_dispense_between_cycles_flow_rate, + wash_format=wash_format, + sectors=sectors, + ) + + data = self._build_wash_composite_command( + plate=self._el406.plate, + cycles=cycles, + buffer=buffer, + dispense_volume=dispense_volume, + dispense_flow_rate=dispense_flow_rate, + dispense_x=dispense_x, + dispense_y=dispense_y, + dispense_z=resolved_dispense_z, + aspirate_travel_rate=aspirate_travel_rate, + aspirate_z=resolved_aspirate_z, + pre_dispense_flow_rate=pre_dispense_flow_rate, + aspirate_delay_ms=aspirate_delay_ms, + aspirate_x=aspirate_x, + aspirate_y=aspirate_y, + final_aspirate=final_aspirate, + final_aspirate_z=final_aspirate_z, + final_aspirate_x=final_aspirate_x, + final_aspirate_y=final_aspirate_y, + final_aspirate_delay_ms=final_aspirate_delay_ms, + pre_dispense_volume=pre_dispense_volume, + vacuum_delay_volume=vacuum_delay_volume, + soak_duration=soak_duration, + shake_duration=shake_duration, + shake_intensity=shake_intensity, + secondary_aspirate=secondary_aspirate, + secondary_z=resolved_secondary_z, + secondary_x=secondary_x, + secondary_y=secondary_y, + final_secondary_aspirate=final_secondary_aspirate, + final_secondary_z=resolved_final_secondary_z, + final_secondary_x=final_secondary_x, + final_secondary_y=final_secondary_y, + bottom_wash=bottom_wash, + bottom_wash_volume=bottom_wash_volume, + bottom_wash_flow_rate=bottom_wash_flow_rate, + pre_dispense_between_cycles_volume=pre_dispense_between_cycles_volume, + pre_dispense_between_cycles_flow_rate=pre_dispense_between_cycles_flow_rate, + wash_format=wash_format, + sector_mask=sector_mask, + move_home_first=move_home_first, + ) + + framed_command = build_framed_message(command=0xA4, data=data) + wash_timeout = (cycles * 60) + shake_duration + soak_duration + 120 + async with self._el406.batch(): + await self._el406._send_step_command(framed_command, timeout=wash_timeout) + + async def prime( + self, + volume: float = 10000.0, + buffer: Buffer = "A", + flow_rate: int = 9, + low_flow_volume: float = 5000.0, + submerge_duration: float = 0.0, + ) -> None: + """Prime the manifold fluid lines. + + Fills the wash manifold tubing with liquid from the specified buffer. + This is typically done at the start of a protocol to ensure the lines + are filled and ready for dispensing. + + Args: + volume: Prime volume in uL (5000-999000). Wire resolution: 1000 uL. + buffer: Buffer valve selection (A, B, C, D). + flow_rate: Flow rate (3-11). + low_flow_volume: Low flow path volume in uL (5000-999000). Set to 0 to disable. + submerge_duration: Submerge duration in seconds (0 to disable, 60-86340). + Must be a multiple of 60. + + Raises: + ValueError: If parameters are invalid. + """ + # Validate in PLR units + if not 5000 <= volume <= 999000: + raise ValueError(f"Washer prime volume must be 5000-999000 uL, got {volume}") + validate_buffer(buffer) + if not 3 <= flow_rate <= 11: + raise ValueError(f"Washer prime flow rate must be 3-11, got {flow_rate}") + if low_flow_volume != 0 and not 5000 <= low_flow_volume <= 999000: + raise ValueError( + f"Low flow path volume must be 0 (disabled) or 5000-999000 uL, got {low_flow_volume}" + ) + if submerge_duration != 0 and not 60 <= submerge_duration <= 86340: + raise ValueError( + f"Submerge duration must be 0 (disabled) or 60-86340 seconds, got {submerge_duration}" + ) + if submerge_duration % 60 != 0: + raise ValueError( + f"Submerge duration must be a multiple of 60 seconds (device resolution is 1 minute), " + f"got {submerge_duration}" + ) + + volume_ml = round(volume / 1000) + low_flow_volume_ml = round(low_flow_volume / 1000) + submerge_duration_min = round(submerge_duration / 60) + low_flow_enabled = low_flow_volume > 0 + submerge_enabled = submerge_duration > 0 + + data = self._build_prime_command( + plate=self._el406.plate, + buffer=buffer, + volume_ml=volume_ml, + flow_rate=flow_rate, + low_flow_volume_ml=low_flow_volume_ml, + low_flow_enabled=low_flow_enabled, + submerge_enabled=submerge_enabled, + submerge_duration_min=submerge_duration_min, + ) + framed_command = build_framed_message(command=0xA7, data=data) + prime_timeout = self._el406.timeout + submerge_duration + 30 + async with self._el406.batch(): + await self._el406._send_step_command(framed_command, timeout=prime_timeout) + + async def auto_clean( + self, + buffer: Buffer = "A", + duration: float = 60.0, + ) -> None: + """Run a manifold auto-clean cycle. + + Args: + buffer: Buffer valve to use (A, B, C, or D). + duration: Cleaning duration in seconds (60-14340, i.e. up to 3h59m). + Wire resolution: 60 s (1 minute). + + Raises: + ValueError: If parameters are invalid. + """ + validate_buffer(buffer) + if not 60 <= duration <= 14340: + raise ValueError(f"AutoClean duration must be 60-14340 seconds, got {duration}") + if duration % 60 != 0: + raise ValueError( + f"AutoClean duration must be a multiple of 60 seconds (device resolution is 1 minute), " + f"got {duration}" + ) + + # Convert to wire units: seconds → minutes + duration_min = round(duration / 60) + + logger.info("Auto-clean: buffer %s, duration %.0f s", buffer, duration) + + data = self._build_auto_clean_command( + plate=self._el406.plate, + buffer=buffer, + duration_min=duration_min, + ) + framed_command = build_framed_message(command=0xA8, data=data) + auto_clean_timeout = max(120.0, duration + 30.0) + async with self._el406.batch(): + await self._el406._send_step_command(framed_command, timeout=auto_clean_timeout) + + # ========================================================================= + # COMMAND BUILDERS + # ========================================================================= + + def _build_wash_composite_command( + self, + plate: Plate, + cycles: int = 3, + buffer: Buffer = "A", + dispense_volume: Optional[float] = None, + dispense_flow_rate: int = 7, + dispense_x: int = 0, + dispense_y: int = 0, + dispense_z: Optional[int] = None, + aspirate_travel_rate: int = 3, + aspirate_z: Optional[int] = None, + pre_dispense_flow_rate: int = 9, + aspirate_delay_ms: int = 0, + aspirate_x: int = 0, + aspirate_y: int = 0, + final_aspirate: bool = True, + final_aspirate_z: Optional[int] = None, + final_aspirate_x: int = 0, + final_aspirate_y: int = 0, + final_aspirate_delay_ms: int = 0, + pre_dispense_volume: float = 0.0, + vacuum_delay_volume: float = 0.0, + soak_duration: int = 0, + shake_duration: int = 0, + shake_intensity: Intensity = "Medium", + secondary_aspirate: bool = False, + secondary_z: Optional[int] = None, + secondary_x: int = 0, + secondary_y: int = 0, + final_secondary_aspirate: bool = False, + final_secondary_z: Optional[int] = None, + final_secondary_x: int = 0, + final_secondary_y: int = 0, + bottom_wash: bool = False, + bottom_wash_volume: float = 0.0, + bottom_wash_flow_rate: int = 5, + pre_dispense_between_cycles_volume: float = 0.0, + pre_dispense_between_cycles_flow_rate: int = 9, + wash_format: Literal["Plate", "Sector"] = "Plate", + sector_mask: int = 0x0F, + move_home_first: bool = False, + ) -> bytes: + """Build 102-byte MANIFOLD_WASH (0xA4) command payload. + + Structure: header(7) + dispense1(22) + final_aspirate(20) + primary_aspirate(19) + + dispense2(19) + shake_soak(15) = 102 bytes. + + Header [0-6]: + [0] plate_type (plate_type.value) + [1] bottom_wash enable + [2] config flags -- final_aspirate + [3] wash_format -- 0=Plate, 1=Sector + [4-5] sector_mask as 16-bit LE + [6] wash cycles count + + Four coordinate sets for aspirate positions: + - Primary: aspirate_x/y/z (between-cycle aspirate, wire [49-67]) + - Primary secondary: secondary_x/y/z (wire [55-61]) + - Final: final_aspirate_x/y/z (post-cycle aspirate, wire [29-48]) + - Final secondary: final_secondary_x/y/z (wire [37-41]) + + Returns: + 102-byte command payload. + """ + # Resolve plate-type defaults + ( + dispense_volume, + dispense_z, + aspirate_z, + secondary_z, + final_secondary_z, + ) = self._resolve_wash_defaults( + plate, dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z + ) + + # Derived values + buffer_char = ord(buffer.upper()) + disp_vol = int(dispense_volume) + final_asp_z = final_aspirate_z if final_aspirate_z is not None else aspirate_z + pre_disp = int(pre_dispense_volume) if pre_dispense_volume > 0 else 0 + vac_delay = int(vacuum_delay_volume) if vacuum_delay_volume > 0 else 0 + intensity_byte = INTENSITY_TO_BYTE.get(shake_intensity, 0x03) if shake_duration > 0 else 0x00 + + # Secondary aspirate offsets (0 when disabled) + sec_x = secondary_x if secondary_aspirate else 0 + sec_y = secondary_y if secondary_aspirate else 0 + final_sec_x = final_secondary_x if final_secondary_aspirate else 0 + final_sec_y = final_secondary_y if final_secondary_aspirate else 0 + final_sec_z = final_secondary_z if final_secondary_aspirate else final_asp_z + + # Bottom wash: Dispense1 gets bottom wash params when enabled, else mirrors main + bw_vol = int(bottom_wash_volume) if bottom_wash else disp_vol + bw_flow = bottom_wash_flow_rate if bottom_wash else dispense_flow_rate + + # Pre-dispense between cycles: override or fall back to main pre-dispense + if pre_dispense_between_cycles_volume > 0: + midcyc_vol = int(pre_dispense_between_cycles_volume) + midcyc_flow = pre_dispense_between_cycles_flow_rate + else: + midcyc_vol = pre_disp + midcyc_flow = pre_dispense_flow_rate + + w = Writer() + + # --- Header [0-6] (7 bytes) --- + w.u8(plate_to_wire_byte(plate)) # [0] Plate type + w.u8(0x01 if bottom_wash else 0x00) # [1] Bottom wash enable + w.u8(0x01 if final_aspirate else 0x00) # [2] Config flags + w.u8({"Plate": 0x00, "Sector": 0x01}[wash_format]) # [3] Wash format + w.u16(sector_mask) # [4-5] Sector mask (LE) + w.u8(cycles) # [6] Wash cycles + + # --- Dispense section 1 [7-28] (22 bytes) — bottom wash or mirror of main --- + w.u8(buffer_char) # [7] Buffer (ASCII) + w.u16(bw_vol) # [8-9] Volume (LE) + w.u8(bw_flow) # [10] Flow rate + w.i8(dispense_x) # [11] Offset X + w.i8(dispense_y) # [12] Offset Y + w.u16(dispense_z) # [13-14] Dispense Z (LE) + w.u16(pre_disp) # [15-16] Pre-dispense vol (LE) + w.u8(pre_dispense_flow_rate) # [17] Pre-dispense flow rate + w.u16(vac_delay) # [18-19] Vacuum delay vol (LE) + w.raw_bytes(b"\x00" * 7) # [20-26] Padding + w.u16(final_aspirate_delay_ms) # [27-28] Final asp delay (LE) + + # --- Final aspirate section [29-48] (20 bytes) --- + w.u8(aspirate_travel_rate) # [29] Travel rate + w.u16(0x0000) # [30-31] Delay (always 0 here) + w.u16(final_asp_z) # [32-33] Final aspirate Z (LE) + w.u8(0x01 if final_secondary_aspirate else 0x00) # [34] Final secondary mode + w.i8(final_aspirate_x) # [35] Final aspirate X + w.i8(final_aspirate_y) # [36] Final aspirate Y + w.u16(final_sec_z) # [37-38] Final secondary Z (LE) + w.u8(0x00) # [39] Reserved + w.i8(final_sec_x) # [40] Final secondary X + w.i8(final_sec_y) # [41] Final secondary Y + w.raw_bytes(b"\x00" * 5) # [42-46] Reserved + w.u8(0x00) # [47] vac_filt (always 0 in wash) + # aspirate_delay_ms split: low byte here, high byte starts next section + w.u8(aspirate_delay_ms & 0xFF) # [48] asp delay low + + # --- Primary aspirate section [49-67] (19 bytes) --- + w.u8((aspirate_delay_ms >> 8) & 0xFF) # [49] asp delay high + w.u8(aspirate_travel_rate) # [50] Travel rate + w.i8(aspirate_x) # [51] Aspirate X + w.i8(aspirate_y) # [52] Aspirate Y + w.u16(aspirate_z) # [53-54] Aspirate Z (LE) + w.u8(0x01 if secondary_aspirate else 0x00) # [55] Secondary mode + w.i8(sec_x) # [56] Secondary X + w.i8(sec_y) # [57] Secondary Y + w.u16(secondary_z) # [58-59] Secondary Z (LE) + w.raw_bytes(b"\x00" * 8) # [60-67] Reserved + + # --- Dispense section 2 [68-86] (19 bytes) — main dispense --- + w.u8(buffer_char) # [68] Buffer (ASCII) + w.u16(disp_vol) # [69-70] Volume (LE) + w.u8(dispense_flow_rate) # [71] Flow rate + w.i8(dispense_x) # [72] Offset X + w.i8(dispense_y) # [73] Offset Y + w.u16(dispense_z) # [74-75] Dispense Z (LE) + w.u16(midcyc_vol) # [76-77] Mid-cycle vol (LE) + w.u8(midcyc_flow) # [78] Mid-cycle flow rate + w.u16(vac_delay) # [79-80] Vacuum delay vol (LE) + w.raw_bytes(b"\x00" * 6) # [81-86] Padding + + # --- Shake/soak section [87-101] (15 bytes) --- + w.u8(0x01 if move_home_first else 0x00) # [87] move_home_first + w.u16(shake_duration) # [88-89] Shake duration (LE) + w.u8(intensity_byte if shake_duration > 0 else 0x03) # [90] Intensity + w.u8(0x00) # [91] Shake type (always 0) + w.u16(soak_duration) # [92-93] Soak duration (LE) + w.raw_bytes(b"\x00" * 4) # [94-97] Padding + w.raw_bytes(b"\x00" * 4) # [98-101] Trailing padding + + data = w.finish() + assert len(data) == 102, f"Wash command should be 102 bytes, got {len(data)}" + + logger.debug("Wash command data (%d bytes): %s", len(data), data.hex()) + return data + + def _build_aspirate_command( + self, + plate: Plate, + vacuum_filtration: bool = False, + time_value: int = 0, + travel_rate_byte: int = 3, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int = 30, + secondary_mode: int = 0, + secondary_x: int = 0, + secondary_y: int = 0, + secondary_z: int = 30, + ) -> bytes: + """Build aspirate command bytes. + + Wire format (22 bytes): + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1] vacuum_filtration: 0 or 1 + [2-3] time_value: ushort LE. delay_ms when normal, vacuum_time_sec when vacuum. + [4] travel_rate: byte from lookup table + [5] x_offset: signed byte + [6] y_offset: signed byte + [7-8] z_offset: short LE + [9] secondary_mode: byte (0=None, 1=enabled) + [10] secondary_x: signed byte + [11] secondary_y: signed byte + [12-13] secondary_z: short LE + [14-15] reserved: 0x0000 + [16-17] unknown: 0xFF0F (possibly column mask?) + [18-21] padding: 4 bytes 0x00 + + Args: + vacuum_filtration: Enable vacuum filtration. + time_value: Delay in ms (normal mode) or time in seconds (vacuum mode). + travel_rate_byte: Pre-encoded travel rate byte value. + offset_x: X offset (signed byte). + offset_y: Y offset (signed byte). + offset_z: Z offset (unsigned short). + secondary_mode: Secondary aspirate mode byte (0=None, 1=enabled). + secondary_x: Secondary X offset (signed byte). + secondary_y: Secondary Y offset (signed byte). + secondary_z: Secondary Z offset (unsigned short). + + Returns: + Command bytes (22 bytes). + """ + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u8(1 if vacuum_filtration else 0) # [1] Vacuum filtration + .u16(time_value) # [2-3] Time/delay (LE) + .u8(travel_rate_byte & 0xFF) # [4] Travel rate + .i8(offset_x) # [5] X offset + .i8(offset_y) # [6] Y offset + .u16(offset_z) # [7-8] Z offset (LE) + .u8(secondary_mode & 0xFF) # [9] Secondary mode + .i8(secondary_x) # [10] Secondary X + .i8(secondary_y) # [11] Secondary Y + .u16(secondary_z) # [12-13] Secondary Z (LE) + .raw_bytes(b'\x00' * 2) # [14-15] Reserved + .raw_bytes(b'\xff\x0f') # [16-17] Unknown, possibly column mask + .raw_bytes(b'\x00' * 4) # [18-21] Padding + .finish() + ) # fmt: skip + + def _build_dispense_command( + self, + plate: Plate, + volume: float, + buffer: Buffer, + flow_rate: int, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int = 121, + pre_dispense_volume: float = 0.0, + pre_dispense_flow_rate: int = 9, + vacuum_delay_volume: float = 0.0, + ) -> bytes: + """Build manifold dispense command bytes. + + Protocol format for manifold dispense: + Wire format: 20 bytes (19 + plate type prefix) + + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) + [2-3] Volume: 2 bytes, LE, in uL (25-3000) + [4] Flow rate: 1-11 (1-2 = cell wash, requires vacuum delay) + [5] Offset X: signed byte (-60..60) + [6] Offset Y: signed byte (-40..40) + [7-8] Offset Z: 2 bytes, LE (1-210) + [9-10] Pre-dispense volume: 2 bytes, LE (0 if disabled, 25-3000 when enabled) + [11] Pre-dispense flow rate: 3-11 + [12-13] Vacuum delay volume: 2 bytes, LE (0 if disabled, 0-3000) + [14-19] Padding: 6 bytes (0x00) + + Note: Pre-dispense is enabled when pre_dispense_volume > 0. + Vacuum delay is enabled when vacuum_delay_volume > 0. + + Args: + volume: Dispense volume in uL. + buffer: Buffer valve (A, B, C, D). + flow_rate: Flow rate (1-11; 1-2 = cell wash, requires vacuum delay). + offset_x: X offset (signed, steps, -60..60). + offset_y: Y offset (signed, steps, -40..40). + offset_z: Z offset (steps, 1-210). + pre_dispense_volume: Pre-dispense volume in uL (0 to disable). + pre_dispense_flow_rate: Pre-dispense flow rate (3-11). + vacuum_delay_volume: Vacuum delay volume in uL (0 to disable). + + Returns: + Command bytes (20 bytes). + """ + pre_disp_vol = int(pre_dispense_volume) if pre_dispense_volume > 0 else 0 + vac_delay = int(vacuum_delay_volume) if vacuum_delay_volume > 0 else 0 + + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u8(ord(buffer.upper())) # [1] Buffer (ASCII) + .u16(int(volume)) # [2-3] Volume (LE) + .u8(flow_rate) # [4] Flow rate + .i8(offset_x) # [5] X offset + .i8(offset_y) # [6] Y offset + .u16(offset_z) # [7-8] Z offset (LE) + .u16(pre_disp_vol) # [9-10] Pre-dispense volume (LE) + .u8(pre_dispense_flow_rate) # [11] Pre-dispense flow rate + .u16(vac_delay) # [12-13] Vacuum delay volume (LE) + .raw_bytes(b'\x00' * 6) # [14-19] Padding + .finish() + ) # fmt: skip + + def _build_prime_command( + self, + plate: Plate, + buffer: Buffer, + volume_ml: float, + flow_rate: int = 9, + low_flow_volume_ml: int = 5, + low_flow_enabled: bool = True, + submerge_enabled: bool = False, + submerge_duration_min: int = 0, + ) -> bytes: + """Build manifold prime command bytes. + + Protocol format for manifold prime (13 bytes): + + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) + [2-3] Volume: 2 bytes, little-endian, in mL + [4] Flow rate: 3-11 + [5-6] Low flow volume: 2 bytes, little-endian (in mL, 0 if disabled) + [7-8] Submerge duration: 2 bytes, little-endian (in minutes, 0 if disabled) + HH:MM encoded as total minutes: hours*60+minutes + [9-12] Padding zeros: 4 bytes + + Args: + buffer: Buffer valve (A, B, C, D). + volume_ml: Prime volume in mL. + flow_rate: Flow rate (3-11, default 9). + low_flow_volume_ml: Low flow volume in mL (default 5). + low_flow_enabled: Enable low flow path (default True). + submerge_enabled: Enable submerge tips after prime (default False). + submerge_duration_min: Submerge duration in minutes (default 0). + + Returns: + Command bytes (13 bytes). + """ + lf_vol = low_flow_volume_ml if (low_flow_enabled and low_flow_volume_ml > 0) else 0 + sub_dur = submerge_duration_min if submerge_enabled else 0 + + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u8(ord(buffer.upper())) # [1] Buffer (ASCII) + .u16(int(volume_ml)) # [2-3] Volume (LE, mL) + .u8(flow_rate) # [4] Flow rate + .u16(lf_vol) # [5-6] Low flow volume (LE, mL) + .u16(sub_dur) # [7-8] Submerge duration (LE, minutes) + .raw_bytes(b'\x00' * 4) # [9-12] Padding + .finish() + ) # fmt: skip + + def _build_auto_clean_command( + self, + plate: Plate, + buffer: Buffer, + duration_min: int = 1, + ) -> bytes: + """Build auto-clean command bytes. + + Protocol format for auto-clean (8 bytes): + + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) + [2-3] Duration: 2 bytes, little-endian (in minutes) + [4-7] Padding zeros: 4 bytes + + Args: + buffer: Buffer valve (A, B, C, D). + duration_min: Cleaning duration in minutes (1-239). + + Returns: + Command bytes (8 bytes). + """ + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u8(ord(buffer.upper())) # [1] Buffer (ASCII) + .u16(int(duration_min)) # [2-3] Duration (LE, minutes) + .raw_bytes(b'\x00' * 4) # [4-7] Padding + .finish() + ) # fmt: skip diff --git a/pylabrobot/plate_washing/biotek/el406/protocol.py b/pylabrobot/agilent/biotek/el406/protocol.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/protocol.py rename to pylabrobot/agilent/biotek/el406/protocol.py diff --git a/pylabrobot/agilent/biotek/el406/syringe_dispenser.py b/pylabrobot/agilent/biotek/el406/syringe_dispenser.py new file mode 100644 index 00000000000..77840e0b47f --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/syringe_dispenser.py @@ -0,0 +1,374 @@ +"""EL406 syringe pump step methods. + +Provides syringe_dispense and syringe_prime operations +plus their corresponding command builders. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Dict, Literal, Optional + +from pylabrobot.io.binary import Writer +from pylabrobot.resources import Plate + +from .helpers import ( + plate_to_wire_byte, + plate_well_count, +) +from .protocol import build_framed_message, columns_to_column_mask, encode_column_mask + +if TYPE_CHECKING: + from .el406 import EL406 + +logger = logging.getLogger(__name__) + +Syringe = Literal["A", "B", "Both"] + + +def syringe_to_byte(syringe: Syringe) -> int: + syringe_upper = syringe.upper() + if syringe_upper == "A": + return 0 + if syringe_upper == "B": + return 1 + if syringe_upper == "BOTH": + return 2 + raise ValueError(f"Invalid syringe: {syringe}") + + +def validate_syringe(syringe: Syringe) -> None: + if syringe.upper() not in {"A", "B", "BOTH"}: + raise ValueError(f"Invalid syringe '{syringe}'. Must be one of: A, B, BOTH") + + +def validate_syringe_flow_rate(flow_rate: int) -> None: + if not 1 <= flow_rate <= 5: + raise ValueError(f"Syringe flow rate must be 1-5, got {flow_rate}") + + +def validate_pump_delay(delay: int) -> None: + if not 0 <= delay <= 5000: + raise ValueError(f"Pump delay must be 0-5000 ms, got {delay}") + + +class SyringeDispenser: + """Syringe dispenser for the BioTek EL406.""" + + def __init__(self, el406: EL406) -> None: + self._el406 = el406 + + async def dispense( + self, + volumes: Dict[int, float], + syringe: Syringe = "A", + flow_rate: int = 2, + offset_x: float = 0.0, + offset_y: float = 0.0, + offset_z: float = 33.6, + pump_delay: float = 0.0, + pre_dispense: bool = False, + pre_dispense_volume: float = 0.0, + num_pre_dispenses: int = 2, + ) -> None: + """Dispense from the syringes, one command per run of columns sharing a volume. + + Dispenses into the plate currently loaded in the EL406 (see ``EL406.set_plate``). + + Args: + volumes: Volume in uL per column index. + syringe: Syringe selection - "A", "B", or "Both". + flow_rate: Flow rate (1-5). Maximum rate depends on volume and plate type. + For 96-well: rate 1 for 10+ uL, rate 2 for 20+ uL, rate 3 for 50+ uL, + rate 4 for 60+ uL, rate 5 for 80+ uL. + For 384-well: rate 1 for 5+ uL, rate 2 for 10+ uL, rate 3 for 25+ uL, + rate 4 for 30+ uL, rate 5 for 40+ uL. + For 1536-well: all rates for 3+ uL. + offset_x: X offset in mm. + offset_y: Y offset in mm. + offset_z: Z offset in mm (33.6 for 96-well, 25.4 for 1536-well). + pump_delay: Post-dispense delay in seconds (0-5). Wire resolution: 1 ms. + pre_dispense: Whether to enable pre-dispense mode. + pre_dispense_volume: Pre-dispense volume in uL/tube (only used if pre_dispense=True). + num_pre_dispenses: Number of pre-dispenses. + """ + plate = self._el406.plate + + # Group consecutive columns with the same volume, in ascending order + groups: list[tuple[float, list[int]]] = [] + for col in sorted(volumes.keys()): + vol = volumes[col] + if groups and groups[-1][0] == vol: + groups[-1][1].append(col) + else: + groups.append((vol, [col])) + + async with self._el406.batch(): + for vol, cols in groups: + await self._syringe_dispense( + plate, + volume=vol, + columns=cols, + syringe=syringe, + flow_rate=flow_rate, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + pump_delay=pump_delay, + pre_dispense=pre_dispense, + pre_dispense_volume=pre_dispense_volume, + num_pre_dispenses=num_pre_dispenses, + ) + + async def _syringe_dispense( + self, + plate: Plate, + volume: float, + columns: Optional[list[int]] = None, + syringe: Syringe = "A", + flow_rate: int = 2, + offset_x: float = 0.0, + offset_y: float = 0.0, + offset_z: float = 33.6, + pump_delay: float = 0.0, + pre_dispense: bool = False, + pre_dispense_volume: float = 0.0, + num_pre_dispenses: int = 2, + ) -> None: + """Send a single syringe dispense command to the firmware.""" + pump_delay_ms = round(pump_delay * 1000) + + if volume <= 0: + raise ValueError(f"volume must be positive, got {volume}") + validate_syringe(syringe) + validate_syringe_flow_rate(flow_rate) + validate_pump_delay(pump_delay_ms) + + column_mask = columns_to_column_mask(columns, plate_wells=plate_well_count(plate)) + + logger.info( + "Syringe dispense: %.1f uL from syringe %s, flow rate %d", volume, syringe, flow_rate + ) + + data = self._build_syringe_dispense_command( + plate=plate, + volume=volume, + syringe=syringe, + flow_rate=flow_rate, + # Convert mm → 0.1mm steps for wire protocol + offset_x=round(offset_x * 10), + offset_y=round(offset_y * 10), + offset_z=round(offset_z * 10), + pump_delay_ms=pump_delay_ms, + pre_dispense=pre_dispense, + pre_dispense_volume=pre_dispense_volume, + num_pre_dispenses=num_pre_dispenses, + column_mask=column_mask, + ) + framed_command = build_framed_message(command=0xA1, data=data) + async with self._el406.batch(): + await self._el406._send_step_command(framed_command) + + async def prime( + self, + volume: float, + syringe: Literal["A", "B"] = "A", + flow_rate: int = 5, + refills: int = 2, + pump_delay: float = 0.0, + submerge_tips: bool = True, + submerge_duration: float = 0.0, + ) -> None: + """Prime the syringe pump fluid lines. + + Fills the syringe pump tubing with liquid by performing one or more + aspirate-dispense cycles (refills). Optionally submerges the tips in + fluid after priming is complete. + + Uses the plate currently loaded in the EL406 (see ``EL406.set_plate``). + + Args: + volume: Prime volume in uL per refill (80-9999). + syringe: Syringe selection - "A" or "B". + flow_rate: Flow rate (1-5). + refills: Number of prime cycles (1-255). + pump_delay: Delay between cycles in seconds (0-5). Wire resolution: 1 ms. + submerge_tips: Submerge tips in fluid after prime. + submerge_duration: Submerge duration in seconds (0-86340, i.e. up to 23:59). + 0 to disable submerge time. Only encoded when submerge_tips=True. + Wire resolution: 60 s (1 minute). + + Raises: + ValueError: If parameters are invalid. + """ + pump_delay_ms = round(pump_delay * 1000) + if submerge_duration != 0 and submerge_duration % 60 != 0: + raise ValueError( + f"Submerge duration must be a multiple of 60 seconds (device resolution is 1 minute), " + f"got {submerge_duration}" + ) + submerge_duration_min = round(submerge_duration / 60) + + validate_syringe(syringe) + # validate syringe volume + if not 80 <= volume <= 9999: + raise ValueError(f"Syringe volume must be 80-9999 uL, got {volume}") + validate_syringe_flow_rate(flow_rate) + validate_pump_delay(pump_delay_ms) + # validate submerge duration + if not 0 <= submerge_duration_min <= 1439: + raise ValueError(f"Submerge duration must be 0-1439 minutes, got {submerge_duration_min}") + if not 1 <= refills <= 255: + raise ValueError(f"refills must be 1-255, got {refills}") + + logger.info( + "Syringe prime: syringe %s, %.1f uL, flow rate %d, %d refills", + syringe, + volume, + flow_rate, + refills, + ) + + data = self._build_syringe_prime_command( + plate=self._el406.plate, + volume=volume, + syringe=syringe, + flow_rate=flow_rate, + refills=refills, + pump_delay_ms=pump_delay_ms, + submerge_tips=submerge_tips, + submerge_duration_min=submerge_duration_min, + ) + framed_command = build_framed_message(command=0xA2, data=data) + prime_timeout = self._el406.timeout + submerge_duration + 30 + async with self._el406.batch(): + await self._el406._send_step_command(framed_command, timeout=prime_timeout) + + # ========================================================================= + # COMMAND BUILDERS + # ========================================================================= + + def _build_syringe_dispense_command( + self, + plate: Plate, + volume: float, + syringe: Syringe, + flow_rate: int, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int = 336, + pump_delay_ms: int = 0, + pre_dispense: bool = False, + pre_dispense_volume: float = 0.0, + num_pre_dispenses: int = 2, + column_mask: Optional[list[int]] = None, + ) -> bytes: + """Build syringe dispense command bytes. + + Wire format (26 bytes): + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1] Syringe: A=0, B=1, Both=2 + [2-3] Volume: 2 bytes, little-endian, in uL + [4] Flow rate: 1-5 + [5] Offset X: signed byte + [6] Offset Y: signed byte + [7-8] Offset Z: 2 bytes, little-endian + [9-10] Pump delay: 2 bytes, little-endian, in ms + [11-12] Pre-dispense volume: 2 bytes, little-endian (0 if pre_dispense=False) + [13] Number of pre-dispenses (default 2) + [14-19] Column mask: 6 bytes (48 bits packed) + [20] Bottle selection (A→0, B→2, Both→4) + [21-25] Padding (5 bytes) + + Args: + volume: Dispense volume in microliters. + syringe: Syringe selection (A, B, Both). + flow_rate: Flow rate (1-5). + offset_x: X offset (signed, 0.1mm units). + offset_y: Y offset (signed, 0.1mm units). + offset_z: Z offset (0.1mm units). + pump_delay_ms: Post-dispense delay in milliseconds. + pre_dispense: Whether to enable pre-dispense mode. + pre_dispense_volume: Pre-dispense volume in uL/tube (only used if pre_dispense=True). + num_pre_dispenses: Number of pre-dispenses (default 2). + column_mask: List of column indices (0-47) or None for all columns. + + Returns: + Command bytes (26 bytes). + """ + pre_disp_vol_int = int(pre_dispense_volume) if pre_dispense else 0 + bottle_byte = {"A": 0, "B": 2, "BOTH": 4}.get(syringe.upper(), 0) + + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u8(syringe_to_byte(syringe)) # [1] Syringe + .u16(int(volume)) # [2-3] Volume (LE) + .u8(flow_rate) # [4] Flow rate + .i8(offset_x) # [5] Offset X + .i8(offset_y) # [6] Offset Y + .u16(offset_z) # [7-8] Offset Z (LE) + .u16(pump_delay_ms) # [9-10] Pump delay (LE) + .u16(pre_disp_vol_int) # [11-12] Pre-dispense vol (LE) + .u8(num_pre_dispenses) # [13] Num pre-dispenses + .raw_bytes(encode_column_mask(column_mask)) # [14-19] Column mask + .u8(bottle_byte) # [20] Bottle selection + .raw_bytes(b'\x00' * 5) # [21-25] Padding + .finish() + ) # fmt: skip + + def _build_syringe_prime_command( + self, + plate: Plate, + volume: float, + syringe: Literal["A", "B"], + flow_rate: int, + refills: int = 2, + pump_delay_ms: int = 0, + submerge_tips: bool = True, + submerge_duration_min: int = 0, + ) -> bytes: + """Build syringe prime command bytes. + + Protocol format (13 bytes): + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1] Syringe: A=0, B=1 + [2-3] Volume: 2 bytes, little-endian, in uL + [4] Flow rate: 1-5 + [5] Refills: byte (number of prime cycles) + [6-7] Pump delay: 2 bytes, little-endian, in ms + [8] Submerge tips (0 or 1) — "Submerge tips in fluid after prime" + [9-10] Submerge duration in minutes (LE uint16). 0 if submerge_tips=False. + [11] Bottle: derived from syringe (A->0, B->2) + [12] Padding + + Args: + volume: Prime volume in microliters. + syringe: Syringe selection (A, B). + flow_rate: Flow rate (1-5). + refills: Number of prime cycles. + pump_delay_ms: Delay between cycles in milliseconds (default 0). + submerge_tips: Submerge tips in fluid after prime (default True). + submerge_duration_min: Submerge duration in minutes (0-1439). Only encoded + when submerge_tips=True. + + Returns: + Command bytes (13 bytes). + """ + sub_total = submerge_duration_min if (submerge_tips and submerge_duration_min > 0) else 0 + bottle_byte = {"A": 0, "B": 2}.get(syringe.upper(), 0) + + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u8(syringe_to_byte(syringe)) # [1] Syringe (A=0, B=1) + .u16(int(volume)) # [2-3] Volume (LE) + .u8(flow_rate) # [4] Flow rate + .u8(refills & 0xFF) # [5] Refills + .u16(pump_delay_ms) # [6-7] Pump delay (LE) + .u8(1 if submerge_tips else 0) # [8] Submerge tips + .u16(sub_total) # [9-10] Submerge duration (LE, minutes) + .u8(bottle_byte) # [11] Bottle selection + .u8(0x00) # [12] Padding + .finish() + ) # fmt: skip diff --git a/pylabrobot/agilent/biotek/plate_reader_base.py b/pylabrobot/agilent/biotek/plate_reader_base.py new file mode 100644 index 00000000000..7bfbe1008d2 --- /dev/null +++ b/pylabrobot/agilent/biotek/plate_reader_base.py @@ -0,0 +1,653 @@ +import asyncio +import enum +import logging +import time +from abc import ABCMeta +from dataclasses import dataclass +from datetime import datetime +from typing import Dict, Iterable, List, Optional, Tuple + +from pylabrobot.io.ftdi import FTDI +from pylabrobot.resources import Plate, Well + +logger = logging.getLogger(__name__) + + +@dataclass +class FluorescenceResult: + """Result of a fluorescence measurement. + + Attributes: + data: 2D array indexed [row][col]. ``None`` for unmeasured wells. + excitation_wavelength: Excitation wavelength in nm. + emission_wavelength: Emission wavelength in nm. + temperature: Temperature in degrees C, or ``None`` if not available. + timestamp: When the measurement was taken. + """ + + data: List[List[Optional[float]]] + excitation_wavelength: int + emission_wavelength: int + temperature: Optional[float] + timestamp: datetime + + +@dataclass +class AbsorbanceResult: + """Result of an absorbance measurement. + + Attributes: + data: 2D array indexed [row][col]. ``None`` for unmeasured wells. + wavelength: Wavelength in nm. + temperature: Temperature in °C, or ``None`` if not available. + timestamp: When the measurement was taken. + """ + + data: List[List[Optional[float]]] + wavelength: int + temperature: Optional[float] + timestamp: datetime + + +@dataclass +class LuminescenceResult: + """Result of a luminescence measurement. + + Attributes: + data: 2D array indexed [row][col]. ``None`` for unmeasured wells. + temperature: Temperature in °C, or ``None`` if not available. + timestamp: When the measurement was taken. + """ + + data: List[List[Optional[float]]] + temperature: Optional[float] + timestamp: datetime + + +class BioTekPlateReaderDriver(metaclass=ABCMeta): + """Driver for Agilent BioTek plate readers.""" + + def __init__( + self, + timeout: float = 20, + device_id: Optional[str] = None, + human_readable_device_name: str = "Agilent BioTek", + ) -> None: + super().__init__() + self.timeout = timeout + + self.io = FTDI(device_id=device_id, human_readable_device_name=human_readable_device_name) + + self._version: Optional[str] = None + + self._plate: Optional[Plate] = None + self._shaking = False + self._slow_mode: Optional[bool] = None + + def _non_overlapping_rectangles( + self, + points: Iterable[Tuple[int, int]], + ) -> List[Tuple[int, int, int, int]]: + """Find non-overlapping rectangles that cover all given points.""" + pts = set(points) + rects = [] + + while pts: + r0, c0 = min(pts) + c1 = c0 + while (r0, c1 + 1) in pts: + c1 += 1 + r1 = r0 + while all((r1 + 1, c) in pts for c in range(c0, c1 + 1)): + r1 += 1 + + rects.append((r0, c0, r1, c1)) + for r in range(r0, r1 + 1): + for c in range(c0, c1 + 1): + pts.discard((r, c)) + + rects.sort() + return rects + + async def setup(self) -> None: + logger.info(f"{self.__class__.__name__} setting up") + + await self.io.setup() + await self.io.usb_reset() + await self.io.set_latency_timer(16) + await self.io.set_baudrate(9600) + await self.io.set_line_property(8, 2, 0) + SIO_RTS_CTS_HS = 0x1 << 8 + await self.io.set_flowctrl(SIO_RTS_CTS_HS) + await self.io.set_rts(True) + + try: + self._version = await self.request_firmware_version() + except TimeoutError: + await self.io.set_baudrate(38_461) + self._version = await self.request_firmware_version() + + self._shaking = False + self._shaking_task: Optional[asyncio.Task] = None + + logger.info("[BioTek %s] connected: firmware=%s", self.io.device_id, self._version) + + async def stop(self) -> None: + logger.info("[BioTek %s] disconnected", self.io.device_id) + await self.stop_shaking() + await self.io.stop() + + self._slow_mode = None + + @property + def version(self) -> str: + if self._version is None: + raise RuntimeError(f"{self.__class__.__name__}: Firmware version is not set") + return self._version + + @property + def abs_wavelength_range(self) -> tuple: + return (230, 999) + + @property + def focal_height_range(self) -> tuple: + return (4.5, 13.88) + + @property + def excitation_range(self) -> tuple: + return (250, 700) + + @property + def emission_range(self) -> tuple: + return (250, 700) + + @property + def supports_heating(self) -> bool: + return False + + @property + def supports_cooling(self) -> bool: + return False + + @property + def supports_active_cooling(self) -> bool: + return self.supports_cooling + + @property + def temperature_range(self) -> Tuple[Optional[float], Optional[float]]: + max_temp = 45.0 if self.supports_heating else None + min_temp = 4.0 if self.supports_cooling else None + return (min_temp, max_temp) + + async def _purge_buffers(self) -> None: + for _ in range(6): + await self.io.usb_purge_rx_buffer() + await self.io.usb_purge_tx_buffer() + + async def _read_until(self, terminator: bytes, timeout: Optional[float] = None) -> bytes: + if timeout is None: + timeout = self.timeout + x = None + res = b"" + t0 = time.time() + while x != terminator: + x = await self.io.read(1) + res += x + + if time.time() - t0 > timeout: + logger.debug(f"{self.__class__.__name__} received incomplete %s", res) + raise TimeoutError(f"{self.__class__.__name__}: Timeout while waiting for response") + + if x == b"": + await asyncio.sleep(0.01) + + logger.debug(f"{self.__class__.__name__} received %s", res) + return res + + async def send_command( + self, + command: str, + parameter: Optional[str] = None, + wait_for_response=True, + timeout: Optional[float] = None, + ) -> Optional[bytes]: + await self._purge_buffers() + + await self.io.write(command.encode()) + logger.debug(f"{self.__class__.__name__} sent %s", command) + response: Optional[bytes] = None + if wait_for_response or parameter is not None: + response = await self._read_until( + b"\x06" if parameter is not None else b"\x03", timeout=timeout + ) + + if parameter is not None: + await self.io.write(parameter.encode()) + logger.debug(f"{self.__class__.__name__} sent %s", parameter) + if wait_for_response: + response = await self._read_until(b"\x03", timeout=timeout) + + return response + + async def request_serial_number(self) -> str: + resp = await self.send_command("C", timeout=1) + assert resp is not None + return resp[1:].split(b" ")[0].decode() + + async def request_firmware_version(self) -> str: + resp = await self.send_command("e", timeout=1) + assert resp is not None + return " ".join(resp[1:-1].decode().split(" ")[3:4]) + + async def set_slow_mode(self, slow: bool): + if self._slow_mode == slow: + return + await self.send_command("&", "S1" if slow else "S0") + self._slow_mode = slow + + def clear_plate(self) -> None: + """Forget the plate geometry the firmware last had loaded (e.g. after closing the tray).""" + self._plate = None + + async def open(self, slow: bool = False): + """Drive the loading tray out. + + Args: + slow: Move at the reduced carrier speed. + """ + await self.set_slow_mode(slow) + await self.send_command("J") + + async def close(self, plate: Optional[Plate] = None, slow: bool = False): + """Drive the loading tray in. + + Args: + plate: Labware being carried in. Its geometry is sent to the firmware before the close + motion so the carrier accounts for the labware height; without it a tall plate can jam + the tray. + slow: Move at the reduced carrier speed. + """ + # Closing invalidates whatever plate geometry the firmware last had loaded. + self.clear_plate() + await self.set_slow_mode(slow) + if plate is not None: + await self.set_plate(plate) + await self.send_command("A") + + async def home(self): + return await self.send_command("i", "x") + + async def request_current_temperature(self) -> float: + resp = await self.send_command("h", timeout=1) + assert resp is not None + temperature = int(resp[1:-1]) / 100000 + logger.info("[BioTek %s] temperature: value=%.3f°C", self.io.device_id, temperature) + return temperature + + async def set_temperature(self, temperature: float): + if not self.supports_heating and not self.supports_cooling: + raise NotImplementedError(f"{self.__class__.__name__} does not support temperature control.") + + tmin, tmax = self.temperature_range + current_temperature = await self.request_current_temperature() + + if (tmin is not None and temperature < tmin) or (tmax is not None and temperature > tmax): + raise ValueError( + f"{self.__class__.__name__}: " + f"Requested temperature {temperature}°C is outside supported range " + f"{tmin}-{tmax}°C" + ) + if temperature < current_temperature and not self.supports_cooling: + raise ValueError(f"{self.__class__.__name__}: Cooling is not supported.") + if temperature > current_temperature and not self.supports_heating: + raise ValueError(f"{self.__class__.__name__}: Heating is not supported.") + + return await self.send_command("g", f"{int(temperature * 1000):05}") + + async def stop_heating_or_cooling(self): + return await self.send_command("g", "00000") + + async def deactivate(self): + return await self.stop_heating_or_cooling() + + def _parse_body(self, body: bytes) -> List[List[Optional[float]]]: + assert self._plate is not None, "Plate must be set before reading data" + plate = self._plate + start_index = 22 + end_index = body.rindex(b"\r\n") + num_rows = plate.num_items_y + rows = body[start_index:end_index].split(b"\r\n,")[:num_rows] + + parsed_data: Dict[Tuple[int, int], float] = {} + for row in rows: + values = row.split(b",") + grouped_values = [values[i : i + 3] for i in range(0, len(values), 3)] + + for group in grouped_values: + assert len(group) == 3 + row_index = int(group[0].decode()) - 1 + column_index = int(group[1].decode()) - 1 + raw_value = group[2].decode() + value = float("nan") if "*" in raw_value else float(raw_value) + parsed_data[(row_index, column_index)] = value + + result: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] + for (row_idx, col_idx), value in parsed_data.items(): + result[row_idx][col_idx] = value + return result + + async def set_plate(self, plate: Plate): + if plate is self._plate: + return + + rows = plate.num_items_y + columns = plate.num_items_x + + bottom_right_well = plate.get_item(plate.num_items - 1) + assert bottom_right_well.location is not None + bottom_right_well_center = bottom_right_well.location + bottom_right_well.get_anchor( + x="c", y="c" + ) + top_left_well = plate.get_item(0) + assert top_left_well.location is not None + top_left_well_center = top_left_well.location + top_left_well.get_anchor(x="c", y="c") + + plate_size_y = plate.get_size_y() + plate_size_x = plate.get_size_x() + plate_size_z = plate.get_size_z() + if plate.lid is not None: + plate_size_z += plate.lid.get_size_z() - plate.lid.nesting_z_height + + top_left_well_center_y = plate.get_size_y() - top_left_well_center.y + bottom_right_well_center_y = plate.get_size_y() - bottom_right_well_center.y + + cmd = ( + f"{rows:02}" + f"{columns:02}" + f"{int(top_left_well_center_y * 100):05}" + f"{int(bottom_right_well_center_y * 100):05}" + f"{int(top_left_well_center.x * 100):05}" + f"{int(bottom_right_well_center.x * 100):05}" + f"{int(plate_size_y * 100):05}" + f"{int(plate_size_x * 100):05}" + f"{int(plate_size_z * 100):04}" + "\x03" + ) + + resp = await self.send_command("y", cmd, timeout=1) + self._plate = plate + return resp + + def _get_min_max_row_col_tuples( + self, wells: List[Well], plate: Plate + ) -> List[Tuple[int, int, int, int]]: + plates = set(well.parent for well in wells) + if len(plates) != 1 or plates.pop() != plate: + raise ValueError("All wells must be in the specified plate") + return self._non_overlapping_rectangles((well.get_row(), well.get_column()) for well in wells) + + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + ) -> List[AbsorbanceResult]: + min_abs, max_abs = self.abs_wavelength_range + if not (min_abs <= wavelength <= max_abs): + raise ValueError(f"{self.__class__.__name__}: wavelength must be within {min_abs}-{max_abs}") + + logger.info( + "[BioTek %s] read_absorbance: plate=%s, wavelength=%dnm, wells=%d", + self.io.device_id, + plate.name, + wavelength, + len(wells), + ) + await self.set_plate(plate) + + wavelength_str = str(wavelength).zfill(4) + all_data: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] + + for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate): + cmd = f"004701{min_row + 1:02}{min_col + 1:02}{max_row + 1:02}{max_col + 1:02}000120010000110010000010600008{wavelength_str}1" + checksum = str(sum(cmd.encode()) % 100).zfill(2) + cmd = cmd + checksum + "\x03" + await self.send_command("D", cmd) + + resp = await self.send_command("O") + assert resp == b"\x060000\x03" + + body = await self._read_until(b"\x03", timeout=60 * 3) + assert body is not None + parsed_data = self._parse_body(body) + for r in range(plate.num_items_y): + for c in range(plate.num_items_x): + if parsed_data[r][c] is not None: + all_data[r][c] = parsed_data[r][c] + + try: + temp = await self.request_current_temperature() + except TimeoutError: + temp = None + + return [ + AbsorbanceResult( + wavelength=wavelength, + data=all_data, + temperature=temp, + timestamp=datetime.now(), + ) + ] + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float, + integration_time: float = 1, + ) -> List[LuminescenceResult]: + """Read luminescence. + + Args: + plate: Plate to read. + wells: Wells to read. + focal_height: Focal height in mm. + integration_time: Integration time in seconds. + """ + min_fh, max_fh = self.focal_height_range + if not (min_fh <= focal_height <= max_fh): + raise ValueError(f"{self.__class__.__name__}: focal height must be within {min_fh}-{max_fh}") + + logger.info( + "[BioTek %s] read_luminescence: plate=%s, wells=%d", + self.io.device_id, + plate.name, + len(wells), + ) + await self.set_plate(plate) + + cmd = f"3{14220 + int(1000 * focal_height)}\x03" + await self.send_command("t", cmd) + + integration_time_seconds = int(integration_time) + assert 0 <= integration_time_seconds <= 60, "Integration time seconds must be between 0 and 60" + integration_time_milliseconds = integration_time - int(integration_time) + assert round(integration_time_milliseconds * 10) % 2 == 0, ( + "Integration time milliseconds must be a multiple of 0.2" + ) + integration_time_seconds_s = str(integration_time_seconds * 5).zfill(2) + integration_time_milliseconds_s = str(int(float(integration_time_milliseconds * 50))).zfill(2) + + all_data: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] + for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate): + cmd = f"008401{min_row + 1:02}{min_col + 1:02}{max_row + 1:02}{max_col + 1:02}000120010000110010000012300{integration_time_seconds_s}{integration_time_milliseconds_s}200200-001000-003000000000000000000013510" + checksum = str((sum(cmd.encode()) + 8) % 100).zfill(2) + cmd = cmd + checksum + await self.send_command("D", cmd) + + resp = await self.send_command("O") + assert resp == b"\x060000\x03" + + timeout = 60 + integration_time_seconds * (2 * 60 + 10) + body = await self._read_until(b"\x03", timeout=timeout) + assert body is not None + parsed_data = self._parse_body(body) + for r in range(plate.num_items_y): + for c in range(plate.num_items_x): + if parsed_data[r][c] is not None: + all_data[r][c] = parsed_data[r][c] + + try: + temp = await self.request_current_temperature() + except TimeoutError: + temp = None + + return [ + LuminescenceResult( + data=all_data, + temperature=temp, + timestamp=datetime.now(), + ) + ] + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[FluorescenceResult]: + min_fh, max_fh = self.focal_height_range + if not (min_fh <= focal_height <= max_fh): + raise ValueError(f"{self.__class__.__name__}: focal height must be within {min_fh}-{max_fh}") + + min_ex, max_ex = self.excitation_range + if not (min_ex <= excitation_wavelength <= max_ex): + raise ValueError( + f"{self.__class__.__name__}: excitation wavelength must be {min_ex}-{max_ex}" + ) + + min_em, max_em = self.emission_range + if not (min_em <= emission_wavelength <= max_em): + raise ValueError(f"{self.__class__.__name__}: emission wavelength must be {min_em}-{max_em}") + + logger.info( + "[BioTek %s] read_fluorescence: plate=%s, excitation=%dnm, emission=%dnm, wells=%d", + self.io.device_id, + plate.name, + excitation_wavelength, + emission_wavelength, + len(wells), + ) + await self.set_plate(plate) + + cmd = f"{614220 + int(1000 * focal_height)}\x03" + await self.send_command("t", cmd) + + excitation_wavelength_str = str(excitation_wavelength).zfill(4) + emission_wavelength_str = str(emission_wavelength).zfill(4) + + all_data: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] + for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate): + cmd = ( + f"008401{min_row + 1:02}{min_col + 1:02}{max_row + 1:02}{max_col + 1:02}0001200100001100100000135000100200200{excitation_wavelength_str}000" + f"{emission_wavelength_str}000000000000000000210011" + ) + checksum = str((sum(cmd.encode()) + 7) % 100).zfill(2) + cmd = cmd + checksum + "\x03" + await self.send_command("D", cmd) + + resp = await self.send_command("O") + assert resp == b"\x060000\x03" + + body = await self._read_until(b"\x03", timeout=60 * 2) + assert body is not None + parsed_data = self._parse_body(body) + for r in range(plate.num_items_y): + for c in range(plate.num_items_x): + if parsed_data[r][c] is not None: + all_data[r][c] = parsed_data[r][c] + + try: + temp = await self.request_current_temperature() + except TimeoutError: + temp = None + + return [ + FluorescenceResult( + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + data=all_data, + temperature=temp, + timestamp=datetime.now(), + ) + ] + + async def _abort(self) -> None: + await self.send_command("x", wait_for_response=False) + + class ShakeType(enum.IntEnum): + LINEAR = 0 + ORBITAL = 1 + + async def shake(self, shake_type: ShakeType, frequency: int) -> None: + """Start continuous shaking. + + Args: + frequency: speed, in mm. 360 CPM = 6mm; 410 CPM = 5mm; 493 CPM = 4mm; + 567 CPM = 3mm; 731 CPM = 2mm; 1096 CPM = 1mm + """ + + max_duration = 16 * 60 + self._shaking_started = asyncio.Event() + + async def shake_maximal_duration(): + shake_type_bit = str(shake_type.value) + duration = str(max_duration).zfill(3) + assert 1 <= frequency <= 6, "Frequency must be between 1 and 6" + cmd = f"0033010101010100002000000013{duration}{shake_type_bit}{frequency}01" + checksum = str((sum(cmd.encode()) + 73) % 100).zfill(2) + cmd = cmd + checksum + "\x03" + await self.send_command("D", cmd) + + resp = await self.send_command("O") + assert resp == b"\x060000\x03" + + if not self._shaking_started.is_set(): + self._shaking_started.set() + + async def shake_continuous(): + while self._shaking: + await shake_maximal_duration() + + seconds_since_start: float = 0 + loop_wait_time = 0.25 + while seconds_since_start < max_duration and self._shaking: + seconds_since_start += loop_wait_time + await asyncio.sleep(loop_wait_time) + + self._shaking = True + self._shaking_task = asyncio.create_task(shake_continuous()) + + await self._shaking_started.wait() + + async def stop_shaking(self) -> None: + if self._shaking: + await self._abort() + self._shaking = False + if self._shaking_task is not None: + self._shaking_task.cancel() + try: + await self._shaking_task + except asyncio.CancelledError: + pass + self._shaking_task = None diff --git a/pylabrobot/agilent/biotek/synergy/__init__.py b/pylabrobot/agilent/biotek/synergy/__init__.py new file mode 100644 index 00000000000..532889a31d7 --- /dev/null +++ b/pylabrobot/agilent/biotek/synergy/__init__.py @@ -0,0 +1 @@ +from .synergy_h1 import SynergyH1 diff --git a/pylabrobot/agilent/biotek/synergy/synergy_h1.py b/pylabrobot/agilent/biotek/synergy/synergy_h1.py new file mode 100644 index 00000000000..68ebb46552c --- /dev/null +++ b/pylabrobot/agilent/biotek/synergy/synergy_h1.py @@ -0,0 +1,107 @@ +import asyncio +import logging +import time +from typing import Optional + +try: + from pylibftdi import FtdiError + + HAS_PYLIBFTDI = True +except ImportError: + HAS_PYLIBFTDI = False + FtdiError = Exception # type: ignore[misc,assignment] + +from pylabrobot.agilent.biotek.plate_reader_base import BioTekPlateReaderDriver +from pylabrobot.resources import Coordinate, PlateHolder + +logger = logging.getLogger(__name__) + + +class SynergyH1(BioTekPlateReaderDriver): + """Agilent BioTek Synergy H1 plate reader.""" + + def __init__( + self, + name: str, + timeout: float = 20, + device_id: Optional[str] = None, + ) -> None: + super().__init__( + timeout=timeout, device_id=device_id, human_readable_device_name="Agilent BioTek Synergy H1" + ) + self.plate_holder = PlateHolder( + name=name + "_plate_holder", + size_x=127.76, + size_y=85.48, + size_z=0, # TODO: measure + pedestal_size_z=0, + child_location=Coordinate.zero(), # TODO: measure + ) + + @property + def supports_heating(self): + return True + + @property + def supports_cooling(self): + return False + + @property + def focal_height_range(self): + return (4.5, 10.68) + + async def _read_until( + self, terminator: bytes, timeout: Optional[float] = None, chunk_size: int = 512 + ) -> bytes: + if timeout is None: + timeout = self.timeout + + deadline = time.time() + timeout + buf = bytearray() + + retries = 0 + max_retries = 3 + + while True: + if time.time() > deadline: + logger.debug( + f"{self.__class__.__name__} _read_until timed out; partial buffer (hex): %s", buf.hex() + ) + raise TimeoutError( + f"{self.__class__.__name__} _read_until timed out waiting for {terminator!r}; partial={buf.hex()}" + ) + + try: + data = await self.io.read(chunk_size) + if len(data) == 0: + await asyncio.sleep(0.02) + continue + + buf.extend(data) + + if terminator in buf: + idx = buf.index(terminator) + len(terminator) + full = bytes(buf[:idx]) + logger.debug( + f"{self.__class__.__name__} _read_until received %d bytes (hex prefix): %s", + len(full), + full[:200].hex(), + ) + return full + + except FtdiError as e: + retries += 1 + logger.warning( + f"{self.__class__.__name__} transient FtdiError while reading: %s — retrying", e + ) + + if retries >= max_retries: + logger.warning( + f"{self.__class__.__name__} too many FtdiError retries ({max_retries}) — stopping", e + ) + raise + + await asyncio.sleep(0.05) + continue + except Exception: + raise diff --git a/pylabrobot/agilent/vspin/__init__.py b/pylabrobot/agilent/vspin/__init__.py new file mode 100644 index 00000000000..c0a0a19e902 --- /dev/null +++ b/pylabrobot/agilent/vspin/__init__.py @@ -0,0 +1,2 @@ +from pylabrobot.agilent.vspin.access2 import Access2, Access2Driver +from pylabrobot.agilent.vspin.vspin import VSpin diff --git a/pylabrobot/agilent/vspin/access2.py b/pylabrobot/agilent/vspin/access2.py new file mode 100644 index 00000000000..e6a72318598 --- /dev/null +++ b/pylabrobot/agilent/vspin/access2.py @@ -0,0 +1,180 @@ +import asyncio +import logging +import time + +from pylabrobot.agilent.vspin.errors import ( + BucketHasPlateError, + BucketNoPlateError, + CentrifugeDoorError, + LoaderNoPlateError, + NotAtBucketError, +) +from pylabrobot.agilent.vspin.vspin import VSpin +from pylabrobot.io.ftdi import FTDI +from pylabrobot.resources import Coordinate, ResourceHolder + +logger = logging.getLogger(__name__) + + +class Access2Driver: + """FTDI driver for the Agilent Access2 centrifuge loader.""" + + 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` + """ + super().__init__() + 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.request_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("1105000e00440b00000000000000007041020203c7")) + + async def stop(self): + logger.debug("[loader] stop") + await self.io.stop() + + async def request_status(self) -> bytes: + logger.debug("[loader] request_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")) + + r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) + if r == bytes.fromhex("1105000800510500000300000079f1"): + raise RuntimeError("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")) + + r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) + if r == bytes.fromhex("1105000800510500000300000079f1"): + raise RuntimeError("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")) + + +class Access2(ResourceHolder): + """Agilent Access2 centrifuge loader.""" + + def __init__( + self, + name: str, + device_id: str, + vspin: VSpin, + size_x: float = 0.0, + size_y: float = 0.0, + size_z: float = 0.0, + ): + driver = Access2Driver(device_id=device_id) + ResourceHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Agilent Access2", + category="loader", + child_location=Coordinate.zero(), + ) + self.driver: Access2Driver = driver + self._vspin = vspin + + async def load(self) -> None: + if not self._vspin.door_open: + raise CentrifugeDoorError("Centrifuge door must be open to load a plate.") + if self._vspin.at_bucket is None: + raise NotAtBucketError( + "Centrifuge must be at a bucket to load a plate. " + "Use vspin.go_to_bucket1() or vspin.go_to_bucket2()." + ) + if self.resource is None: + raise LoaderNoPlateError("Loader must have a plate to load.") + if self._vspin.at_bucket.resource is not None: + raise BucketHasPlateError("Bucket must be empty to load a plate.") + + await self.driver.load() + + self._vspin.at_bucket.assign_child_resource(self.resource, location=Coordinate.zero()) + + async def unload(self) -> None: + if not self._vspin.door_open: + raise CentrifugeDoorError("Centrifuge door must be open to unload a plate.") + if self._vspin.at_bucket is None: + raise NotAtBucketError( + "Centrifuge must be at a bucket to unload a plate. " + "Use vspin.go_to_bucket1() or vspin.go_to_bucket2()." + ) + if self._vspin.at_bucket.resource is None: + raise BucketNoPlateError("Bucket must have a plate to unload.") + + await self.driver.unload() + + self.assign_child_resource(self._vspin.at_bucket.resource) diff --git a/pylabrobot/agilent/vspin/errors.py b/pylabrobot/agilent/vspin/errors.py new file mode 100644 index 00000000000..b1c28ea942d --- /dev/null +++ b/pylabrobot/agilent/vspin/errors.py @@ -0,0 +1,18 @@ +class CentrifugeDoorError(Exception): + pass + + +class NotAtBucketError(Exception): + pass + + +class BucketNoPlateError(Exception): + pass + + +class BucketHasPlateError(Exception): + pass + + +class LoaderNoPlateError(Exception): + pass diff --git a/pylabrobot/agilent/vspin/vspin.py b/pylabrobot/agilent/vspin/vspin.py new file mode 100644 index 00000000000..60ad86fbc9f --- /dev/null +++ b/pylabrobot/agilent/vspin/vspin.py @@ -0,0 +1,512 @@ +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 pylabrobot.resources import Coordinate, ResourceHolder + +logger = logging.getLogger(__name__) + + +_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 go_to_position and " + "then calling set_bucket_1_position_to_current." +) + + +# --------------------------------------------------------------------------- +# VSpin Driver — FTDI I/O and hardware queries +# --------------------------------------------------------------------------- + + +class VSpin: + """FTDI driver for the Agilent VSpin Centrifuge. + + Owns the USB connection, low-level command protocol, and hardware status queries. + """ + + def __init__(self, name: str, device_id: Optional[str] = None): + """ + Args: + device_id: The libftdi id for the centrifuge. Find using + `python -m pylibftdi.examples.list_devices` + """ + super().__init__() + self.io = FTDI(human_readable_device_name="Agilent VSpin Centrifuge", device_id=device_id) + self.device_id = device_id + self._bucket_1_remainder: Optional[int] = None + if device_id is not None: + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + + self.bucket1 = ResourceHolder( + name=f"{name}_bucket1", + size_x=127.76, + size_y=85.48, + size_z=0, + child_location=Coordinate.zero(), + ) + self.bucket2 = ResourceHolder( + name=f"{name}_bucket2", + size_x=127.76, + size_y=85.48, + size_z=0, + child_location=Coordinate.zero(), + ) + + # Door and rotor state, tracked from the commands we issue: the controller has no query for + # which bucket is parked at the load position. + self._door_open = False + self._at_bucket: Optional[ResourceHolder] = None + + @property + def door_open(self) -> bool: + """Whether the door was left open by the last door command.""" + return self._door_open + + @property + def at_bucket(self) -> Optional[ResourceHolder]: + """The bucket parked at the load position, or None if the rotor is elsewhere.""" + return self._at_bucket + + async def setup(self): + logger.info("[vSpin %s] connected", self.device_id) + await self.io.setup() + 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.request_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") + await self.send_command( + bytes.fromhex("aa01d497") + new_position + bytes.fromhex("c3f52800d71a000049") + ) + # ----------------------------------------- + + resp = 0x08 + while resp != 0x09: + resp = (await self.request_positions_and_tachometer()).status + + await self.send_command(bytes.fromhex("aa0117021a")) + + await self.lock_door() + + async def stop(self): + logger.info("[vSpin %s] disconnected", self.device_id) + await self.configure_and_initialize() + await self.io.stop() + + # -- low-level protocol -- + + async def _read_resp(self, timeout: float = 20) -> bytes: + 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")) + + # -- hardware status queries -- + + 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 request_positions_and_tachometer(self) -> "VSpin._StatusPositionTachometer": + resp = await self.send_command(bytes.fromhex("aa010e0f")) + if len(resp) == 0: + raise IOError("Empty status from centrifuge") + return VSpin._StatusPositionTachometer.from_buffer_copy(resp) + + async def request_position(self) -> int: + return (await self.request_positions_and_tachometer()).current_position # type: ignore + + async def request_tachometer(self) -> int: + """Current speed in rpm.""" + tack_to_rpm = -14.69320388 + return (await self.request_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore + + async def request_home_position(self) -> int: + """Changes during a run, but the bucket 1 position relative to it does not.""" + return (await self.request_positions_and_tachometer()).home_position # type: ignore + + async def _request_status(self): + 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 request_bucket_locked(self) -> bool: + resp = await self._request_status() + return resp[2] & 0b0001 != 0 # type: ignore + + async def request_door_open(self) -> bool: + resp = await self._request_status() + return resp[2] & 0b0010 != 0 # type: ignore + + async def request_door_locked(self) -> bool: + resp = await self._request_status() + return resp[2] & 0b0100 == 0 # type: ignore + + # -- bucket calibration -- + + @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.request_position() + device_id = await self.io.request_serial() + remainder = await self.request_home_position() - current_position + self._bucket_1_remainder = current_position % FULL_ROTATION + _save_vspin_calibrations(device_id, remainder) + + async def request_bucket_1_position(self) -> int: + """Get the bucket 1 position based on calibration.""" + if self._bucket_1_remainder is None: + raise bucket_1_not_set_error + home_position = await self.request_home_position() + bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder + current_position = await self.request_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 + + # -- CentrifugeBackend interface -- + + async def open_door(self): + if await self.request_door_open(): + self._door_open = True + return + logger.info("[vSpin %s] open door", self.device_id) + await self.send_command(bytes.fromhex("aa022600062e")) + await asyncio.sleep(4) + self._door_open = True + + async def close_door(self): + if not (await self.request_door_open()): + self._door_open = False + return + logger.info("[vSpin %s] close door", self.device_id) + await self.send_command(bytes.fromhex("aa022600042c")) + await asyncio.sleep(2) + self._door_open = False + + async def lock_door(self): + if await self.request_door_open(): + raise RuntimeError("Cannot lock door while it is open.") + if await self.request_door_locked(): + return + logger.info("[vSpin %s] lock door", self.device_id) + await self.send_command(bytes.fromhex("aa0226000028")) + + async def unlock_door(self): + if not await self.request_door_locked(): + return + await self.send_command(bytes.fromhex("aa022600042c")) + + async def lock_bucket(self): + if await self.request_bucket_locked(): + return + await self.send_command(bytes.fromhex("aa022600072f")) + + async def unlock_bucket(self): + if not await self.request_bucket_locked(): + return + await self.send_command(bytes.fromhex("aa022600062e")) + + async def go_to_bucket1(self): + await self.go_to_position(await self.request_bucket_1_position()) + self._at_bucket = self.bucket1 + + async def go_to_bucket2(self): + await self.go_to_position(await self.request_bucket_1_position() + FULL_ROTATION // 2) + self._at_bucket = self.bucket2 + + async def go_to_position(self, position: int): + logger.info("[vSpin %s] go_to_position: position=%d", self.device_id, position) + 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) + + while abs(await self.request_position() - position) > 10: + await asyncio.sleep(0.1) + await self.open_door() + + @staticmethod + def g_to_rpm(g: float) -> int: + 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. + + Args: + g: relative centrifugal force, also known as g-force + duration: time in seconds spent at speed (g) + acceleration: Acceleration rate as a fraction of maximum (0 to 1, exclusive of 0). + deceleration: Deceleration rate as a fraction of maximum (0 to 1, exclusive of 0). + """ + 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.request_door_open(): + await self.close_door() + if not await self.request_door_locked(): + await self.lock_door() + if await self.request_bucket_locked(): + await self.unlock_bucket() + + rpm = VSpin.g_to_rpm(g) + logger.info( + "[vSpin %s] spin: g=%.1f rpm=%d duration=%.1fs acceleration=%.2f deceleration=%.2f", + self.device_id, + g, + rpm, + duration, + acceleration, + deceleration, + ) + + 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) + + distance_at_speed = ticks_per_second * duration + + current_position = await self.request_position() + final_position = int(current_position + distance_during_acceleration + distance_at_speed) + + if final_position > 2**32 - 1: + raise NotImplementedError( + "We don't know what happens if the destination position exceeds 2^32-1. " + "Please report this issue on discuss.pylabrobot.org." + ) + + 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) + + while ( + await self.request_tachometer() < rpm * 0.95 + and await self.request_position() < final_position + ): + await asyncio.sleep(0.1) + + if await self.request_position() < final_position: + decel_start_position = await self.request_position() + distance_at_speed + + while await self.request_position() < decel_start_position: + await asyncio.sleep(0.1) + + await self.send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + 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) + + 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")) + await self.send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await self.send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await self.send_command(bytes.fromhex("aa01192842")) + + await _reset_to_zero() + + start = await self.request_home_position() + num_tries = 0 + while await self.request_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.") + + # The rotor has moved off whichever bucket was parked at the load position. + self._at_bucket = None diff --git a/pylabrobot/agrowpumps/__init__.py b/pylabrobot/agrowpumps/__init__.py new file mode 100644 index 00000000000..fa6c775eb67 --- /dev/null +++ b/pylabrobot/agrowpumps/__init__.py @@ -0,0 +1 @@ +from .agrow_pump_array import AgrowPump, AgrowPumpArray diff --git a/pylabrobot/agrowpumps/agrow_pump_array.py b/pylabrobot/agrowpumps/agrow_pump_array.py new file mode 100644 index 00000000000..9eda257796d --- /dev/null +++ b/pylabrobot/agrowpumps/agrow_pump_array.py @@ -0,0 +1,172 @@ +import asyncio +import logging +import threading +import time +from typing import Dict, List, Optional, Union + +try: + from pymodbus.client import AsyncModbusSerialClient # type: ignore + + _MODBUS_IMPORT_ERROR = None +except ImportError as e: + AsyncModbusSerialClient = None # type: ignore + _MODBUS_IMPORT_ERROR = e + +logger = logging.getLogger(__name__) + + +class AgrowPumpArray: + """Agrow dose pump array. + + Each channel is exposed as an :class:`AgrowChannel` on ``self.channels`` once + :meth:`setup` has queried the number of channels from the hardware. + """ + + def __init__(self, port: str, address: Union[int, str]): + super().__init__() + if _MODBUS_IMPORT_ERROR is not None: + raise RuntimeError( + "pymodbus is not installed. Install with: pip install pylabrobot[modbus]. " + f"Import error: {_MODBUS_IMPORT_ERROR}" + ) + if not isinstance(port, str): + raise ValueError("Port must be a string") + self.port = port + if address not in range(0, 256): + raise ValueError("Pump address out of range") + self.address = int(address) + self._keep_alive_thread: Optional[threading.Thread] = None + self._pump_index_to_address: Optional[Dict[int, int]] = None + self._modbus: Optional["AsyncModbusSerialClient"] = None + self._num_channels: Optional[int] = None + self._keep_alive_thread_active = False + self.channels: List["AgrowPump"] = [] + + @property + def modbus(self) -> "AsyncModbusSerialClient": + if self._modbus is None: + raise RuntimeError("Modbus connection not established") + return self._modbus + + @property + def pump_index_to_address(self) -> Dict[int, int]: + if self._pump_index_to_address is None: + raise RuntimeError("Pump mappings not established") + return self._pump_index_to_address + + @property + def num_channels(self) -> int: + if self._num_channels is None: + raise RuntimeError("Number of channels not established") + return self._num_channels + + def _start_keep_alive_thread(self): + async def keep_alive(): + i = 0 + while self._keep_alive_thread_active: + time.sleep(0.1) + i += 1 + if i == 250: + await self.modbus.read_holding_registers(0, 1, unit=self.address) # type: ignore[call-arg, misc] + i = 0 + + def manage_async_keep_alive(): + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(keep_alive()) + loop.close() + except Exception as e: + logger.error("[Agrow %s addr=%s] keep-alive thread error: %s", self.port, self.address, e) + + self._keep_alive_thread_active = True + self._keep_alive_thread = threading.Thread(target=manage_async_keep_alive, daemon=True) + self._keep_alive_thread.start() + + async def setup(self): + await self._setup_modbus() + register_return = await self.modbus.read_holding_registers(19, 2, unit=self.address) # type: ignore[call-arg, misc] + self._num_channels = int( + "".join(chr(r // 256) + chr(r % 256) for r in register_return.registers)[2] + ) + self._start_keep_alive_thread() + self._pump_index_to_address = {pump: pump + 100 for pump in range(0, self.num_channels)} + self.channels = [AgrowPump(self, ch) for ch in range(self.num_channels)] + logger.info( + "[Agrow %s addr=%s] connected: channels=%d", self.port, self.address, self._num_channels + ) + + async def _setup_modbus(self): + if AsyncModbusSerialClient is None: + raise RuntimeError( + "pymodbus is not installed. Install with: pip install pylabrobot[modbus]." + f" Import error: {_MODBUS_IMPORT_ERROR}" + ) + self._modbus = AsyncModbusSerialClient( + port=self.port, + baudrate=115200, + timeout=1, + stopbits=1, + bytesize=8, + parity="E", + retry_on_empty=True, # type: ignore[call-arg] + ) + await self.modbus.connect() + if not self.modbus.connected: + logger.error("[Agrow %s] modbus connection failed", self.port) + raise ConnectionError("Modbus connection failed during pump setup") + + async def stop(self): + logger.info("[Agrow %s addr=%s] stopping", self.port, self.address) + for pump in self.pump_index_to_address: + await self.write_speed(pump, 0) + if self._keep_alive_thread is not None: + self._keep_alive_thread_active = False + self._keep_alive_thread.join() + self.modbus.close() + assert not self.modbus.connected, "Modbus failing to disconnect" + + async def write_speed(self, channel: int, speed: int): + if speed not in range(101): + raise ValueError("Pump speed out of range. Value should be between 0 and 100.") + await self.modbus.write_register( + self.pump_index_to_address[channel], + speed, + unit=self.address, # type: ignore[call-arg] + ) + + +class AgrowPump: + """A single pump channel on an :class:`AgrowPumpArray`.""" + + def __init__(self, array: "AgrowPumpArray", channel: int): + self.driver = array + self._channel = channel + + async def run_revolutions(self, num_revolutions: float): + raise NotImplementedError( + "Revolution based pumping commands are not available for Agrow pumps." + ) + + async def run_continuously(self, speed: float): + logger.info( + "[Agrow %s addr=%s] channel %d: run_continuously at speed %d", + self.driver.port, + self.driver.address, + self._channel, + int(speed), + ) + await self.driver.write_speed(self._channel, int(speed)) + + async def halt(self): + logger.info( + "[Agrow %s addr=%s] channel %d: halt", self.driver.port, self.driver.address, self._channel + ) + await self.driver.write_speed(self._channel, 0) + + def serialize(self): + return { + "port": self.driver.port, + "address": self.driver.address, + "channel": self._channel, + } diff --git a/pylabrobot/agrowpumps/agrow_pump_array_tests.py b/pylabrobot/agrowpumps/agrow_pump_array_tests.py new file mode 100644 index 00000000000..abaf6946905 --- /dev/null +++ b/pylabrobot/agrowpumps/agrow_pump_array_tests.py @@ -0,0 +1,76 @@ +# mypy: disable-error-code="attr-defined,assignment" +import unittest +from unittest.mock import AsyncMock, patch + +import pytest + +pytest.importorskip("pymodbus") + +from pylabrobot.agrowpumps import AgrowPumpArray + + +class SimulatedModbusClient: + """Duck-typed modbus client for testing.""" + + def __init__(self): + self._connected = False + self.write_register = AsyncMock() + + async def connect(self): + self._connected = True + + @property + def connected(self): + return self._connected + + async def read_holding_registers(self, address: int, count: int, **kwargs): + if "unit" not in kwargs: + raise ValueError("unit must be specified") + if address == 19: + result = AsyncMock() + result.registers = [16708, 13824, 0, 0, 0, 0, 0][:count] + return result + + def close(self, reconnect=False): + self._connected = False + + +class TestAgrowPumps(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.device = AgrowPumpArray(port="simulated", address=1) + + async def _mock_setup_modbus(): + self.device._modbus = SimulatedModbusClient() + + with patch.object(self.device, "_setup_modbus", _mock_setup_modbus): + await self.device.setup() + + async def asyncTearDown(self): + await self.device.stop() + + async def test_setup(self): + self.assertEqual(self.device.port, "simulated") + self.assertEqual(self.device.address, 1) + self.assertEqual(len(self.device.channels), 6) + self.assertEqual( + self.device._pump_index_to_address, + {pump: pump + 100 for pump in range(0, 6)}, + ) + + async def test_run_continuously(self): + self.device.modbus.write_register.reset_mock() + await self.device.channels[0].run_continuously(speed=1) + self.device.modbus.write_register.assert_called_once_with(100, 1, unit=1) + + # invalid speed: cannot be bigger than 100 + with self.assertRaises(ValueError): + await self.device.channels[0].run_continuously(speed=101) + + async def test_run_revolutions(self): + with self.assertRaises(NotImplementedError): + await self.device.channels[0].run_revolutions(num_revolutions=1.0) + + async def test_halt_single_channel(self): + self.device.modbus.write_register.reset_mock() + await self.device.channels[2].halt() + self.device.modbus.write_register.assert_called_once_with(102, 0, unit=1) diff --git a/pylabrobot/azenta/__init__.py b/pylabrobot/azenta/__init__.py new file mode 100644 index 00000000000..88c532643e6 --- /dev/null +++ b/pylabrobot/azenta/__init__.py @@ -0,0 +1,14 @@ +from .a4s import A4S, A4SStatus +from .fluidx import ( + ERROR_CODE_MESSAGES, + RECOVERABLE_ERROR_CODES, + CartridgeInfo, + CartridgeProfile, + ExtendedStatus, + FirmwareVersions, + FluidXError, + FluidXIntelliXcap96, + get_error_message, + is_recoverable_error, +) +from .xpeel import XPeel diff --git a/pylabrobot/azenta/a4s.py b/pylabrobot/azenta/a4s.py new file mode 100644 index 00000000000..63651f46fa6 --- /dev/null +++ b/pylabrobot/azenta/a4s.py @@ -0,0 +1,246 @@ +import asyncio +import dataclasses +import enum +import logging +import time +from typing import Set + +try: + import serial + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e + +from pylabrobot.io.serial import Serial + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass +class A4SStatus: + class SystemStatus(enum.Enum): + idle = 0 + single_cycle = 1 + repeat_cycle = 2 + error = 3 + finish = 4 + + class HeaterBlockStatus(enum.Enum): + heater_off = 0 + ready = 1 + heating = 2 + cooling = 3 + converging = 4 + + @dataclasses.dataclass + class SensorStatus: + shuttle_middle_sensor: bool + shuttle_open_sensor: bool + shuttle_close_sensor: bool + clean_door_sensor: bool + seal_roll_sensor: bool + heater_motor_up_sensor: bool + heater_motor_down_sensor: bool + + current_temperature: float + system_status: SystemStatus + heater_block_status: HeaterBlockStatus + error_code: int + warning_code: int + sensor_status: SensorStatus + remaining_time: int + + +class A4S: + """Driver for the Azenta a4S thermal sealer. + + https://web.azenta.com/hubfs/azenta-files/resources/tech-drawings/TD-automated-roll-heat-sealer.pdf + """ + + def __init__(self, port: str, timeout: int = 20) -> None: + super().__init__() + if not HAS_SERIAL: + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) + self.port = port + self.timeout = timeout + self.io = Serial( + human_readable_device_name="Azenta a4S Thermal Sealer", + port=self.port, + baudrate=19200, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + ) + + async def setup(self): + await self.io.setup() + await self.system_reset() + logger.info("[A4S %s] connected and reset", self.port) + + async def stop(self): + await self.set_heater(on=False) + await self.io.stop() + logger.info("[A4S %s] disconnected", self.port) + + # -- serial protocol -- + + async def send_command(self, command: str): + await self.io.write(command.encode()) + await asyncio.sleep(0.1) + + async def read_message(self) -> str: + start = time.time() + r, x = b"", b"" + has_read_r = False + while x != b"" or (len(r) == 0 and x == b""): + x = await self.io.read() + if has_read_r: + r += x + if x == b"\r": + if not has_read_r: + has_read_r = True + else: + break + if time.time() - start > self.timeout: + raise TimeoutError("Timeout while waiting for response") + return r.decode("utf-8") + + # -- status -- + + async def request_status(self) -> A4SStatus: + while True: + message = await self.read_message() + if message[1] == "T": + break + + message = message.split("!")[0] + parameters = message[:-4].split("=")[1].split(",") + + error_code = int(str(parameters[3])) + if error_code != 0: + logger.error("[A4S %s] error code %d in status response: %s", self.port, error_code, message) + raise RuntimeError(f"An error occurred: response {message}") + + sensor_status = int(str(parameters[5])) + + return A4SStatus( + current_temperature=int(str(parameters[0])) / 10, + system_status=A4SStatus.SystemStatus(int(str(parameters[1]))), + heater_block_status=A4SStatus.HeaterBlockStatus(int(str(parameters[2]))), + error_code=error_code, + warning_code=int(str(parameters[4])), + sensor_status=A4SStatus.SensorStatus( + shuttle_middle_sensor=sensor_status & 0x0001 != 0, + shuttle_open_sensor=sensor_status & 0x0002 != 0, + shuttle_close_sensor=sensor_status & 0x0004 != 0, + clean_door_sensor=sensor_status & 0x0008 != 0, + seal_roll_sensor=sensor_status & 0x0010 != 0, + heater_motor_up_sensor=sensor_status & 0x0020 != 0, + heater_motor_down_sensor=sensor_status & 0x0040 != 0, + ), + remaining_time=int(str(parameters[6])), + ) + + async def wait_for_status(self, statuses: Set[A4SStatus.SystemStatus]) -> A4SStatus: + start = time.time() + while True: + status = await self.request_status() + + if status.system_status == A4SStatus.SystemStatus.error: + logger.error("[A4S %s] device error: %d", self.port, status.error_code) + raise RuntimeError(f"An error occurred: {status.error_code}") + + if status.system_status in statuses: + return status + + if time.time() - start > self.timeout: + raise TimeoutError("Timeout while waiting for response") + + await asyncio.sleep(0.01) + + async def wait_for_shuttle_open_sensor( + self, shuttle_open: bool, timeout: float = 30.0 + ) -> A4SStatus: + start = time.time() + while True: + status = await self.request_status() + if status.sensor_status.shuttle_open_sensor == shuttle_open: + return status + if time.time() - start > timeout: + raise TimeoutError("Timeout while waiting for shuttle open sensor") + + async def system_reset(self): + await self.send_command("*00SR=zz!") + return await self.wait_for_status({A4SStatus.SystemStatus.idle}) + + async def set_heater(self, on: bool): + command = "*00H1ZZ" if on else "*00H0ZZ" + await self.send_command(command) + return await self.wait_for_status({A4SStatus.SystemStatus.idle}) + + async def set_time(self, seconds: float): + deciseconds = seconds * 10 + if not (0 <= deciseconds <= 9999): + raise ValueError("Time out of range. Please enter a value between 0 and 9999.") + command = f"*00DT={deciseconds:04d}zz!" + return await self.send_command(command) + + async def request_remaining_time(self) -> int: + status = await self.request_status() + return status.remaining_time + + async def seal(self, temperature: int, duration: float): + logger.info("[A4S %s] sealing at %d C for %.1fs", self.port, temperature, duration) + await self.send_command(f"*00DH={round(temperature):04d}zz!") + await self._wait_for_temperature(temperature, timeout=300) + await self.set_time(duration) + await self.send_command("*00GS=zz!") + await self.wait_for_status({A4SStatus.SystemStatus.single_cycle}) + return await self.wait_for_status({A4SStatus.SystemStatus.idle, A4SStatus.SystemStatus.finish}) + + async def open(self): + logger.info("[A4S %s] open shuttle", self.port) + await self.send_command("*00MO=zz!") + return await self.wait_for_shuttle_open_sensor(True) + + async def close(self): + logger.info("[A4S %s] close shuttle", self.port) + await self.send_command("*00MC=zz!") + return await self.wait_for_shuttle_open_sensor(False) + + @property + def supports_active_cooling(self) -> bool: + return False + + async def set_temperature(self, temperature: float): + if not (50 <= temperature <= 200): + raise ValueError("Temperature out of range. Please enter a value between 50 and 200.") + logger.info("[A4S %s] setting temperature to %.1f C", self.port, temperature) + command = f"*00DH={round(temperature):04d}zz!" + await self.send_command(command) + await self._wait_for_temperature(temperature, timeout=300) + + async def _wait_for_temperature(self, degrees: float, timeout: float, tolerance: float = 0.5): + start = time.time() + while True: + current_temperature = await self.request_current_temperature() + if abs(current_temperature - degrees) < tolerance: + break + if time.time() - start > timeout: + raise TimeoutError("Timeout while waiting for temperature") + await asyncio.sleep(0.1) + + async def request_current_temperature(self) -> float: + status = await self.request_status() + temp = status.current_temperature + logger.info("[A4S %s] read temperature: actual=%.1f C", self.port, temp) + return temp + + async def deactivate(self): + logger.info("[A4S %s] deactivate heater", self.port) + await self.set_heater(on=False) diff --git a/pylabrobot/azenta/fluidx/__init__.py b/pylabrobot/azenta/fluidx/__init__.py new file mode 100644 index 00000000000..83a9ad9813d --- /dev/null +++ b/pylabrobot/azenta/fluidx/__init__.py @@ -0,0 +1,12 @@ +from .intellixcap96 import ( + ERROR_CODE_MESSAGES, + RECOVERABLE_ERROR_CODES, + CartridgeInfo, + CartridgeProfile, + ExtendedStatus, + FirmwareVersions, + FluidXError, + FluidXIntelliXcap96, + get_error_message, + is_recoverable_error, +) diff --git a/pylabrobot/azenta/fluidx/intellixcap96.py b/pylabrobot/azenta/fluidx/intellixcap96.py new file mode 100644 index 00000000000..480a03d6247 --- /dev/null +++ b/pylabrobot/azenta/fluidx/intellixcap96.py @@ -0,0 +1,1377 @@ +import asyncio +import dataclasses +import logging +import re +from typing import Dict, FrozenSet, List, NamedTuple, Optional, Tuple + +from pylabrobot.io.serial import Serial + +logger = logging.getLogger(__name__) + +# Every reply is framed between STX (0x02) and ETX (0x03). A command is answered +# with an ACK frame (a lone 0x06), a command-echo frame ("OK"), and then a +# result frame (an operation-specific "...DONE"/"...ERROR" word, a "Status..." +# word, or "CommandIgnore" when the command is a no-op or refused). Motions run +# asynchronously: the status word is "StatusBUSY" while moving, then changes to +# the operation-specific terminal state. +STX = b"\x02" +ETX = b"\x03" +ACK = "\x06" + +# Single-character commands the decapper understands. +STATUS = "a" +DECAP_START = "h" +RECAP_START = "i" +WASTE = "b" +OPEN_TRAY = "f" +CLOSE_TRAY = "g" +HOME_ALL = "Z" +INITIALIZE_KEEP_CAPS = "z" +STANDBY = "j" +READY = "k" +CARTRIDGE_EJECT = "c" +CARTRIDGE_LOAD = "C" +# Newer-firmware tray opcodes, intentionally not sent until verified there: +# TRAY_EXTEND = "t" +# TRAY_RETRACT = "T" +TRAY_STEP_OUT = "s" +TRAY_STEP_IN = "S" +CARTRIDGE_COUNTER_RESET = "X" +FIRMWARE_QUERY = "V" +CARTRIDGE_QUERY = "N" +EJECT_CAPS = "5" +HEAD_UP = "6" +SAFETY_DOOR_OPEN = "7" +ERROR_QUERY = "8" +SAFETY_DOOR_DISABLE = "-" +SAFETY_DOOR_ENABLE = "+" +ERROR_DETECTION_OFF = "l" +ERROR_DETECTION_ON = "L" +DRY_RUN_ON = "d" +DRY_RUN_OFF = "D" +EXTENDED_STATUS = "e" +PROFILE_QUERY = "E" +RETRY_DECAP = "Q" + +COMMAND_IGNORE = "CommandIgnore" + +# "ErrorDetectON" and "ErrorDetectOFF" are ordinary success replies to the +# error-detection commands, so a bare "ERROR" substring is not a fault. A fault +# frame ends the word there: "DecapERROR", "StatusERROR", "tERROR". +_ERROR_FRAME_RE = re.compile(r"ERROR\b") + +# Fault descriptions as reported by the instrument firmware. +ERROR_MESSAGES = { + "StatusNotOK": ( + "Status is not ok. The device is in an error state. Check whether the plate is already " + "decapped (if so, recap and initialize again); otherwise clear the error on the device and " + "cycle the power." + ), + "NeedToRecap": "Decap operation is already done. Select the recap operation on the device.", + "NeedToDecap": "Recap operation is already executed.", + "CapsOnPins": "Caps are held on the pins. Recap or waste them first.", + "DecapWasNotSuccesful": ( + "Decapping was not successful. Fix the error on the device manually and check whether any " + "tubes are still on the head." + ), + "RecapWasNotSuccesful": "Recapping was not successful. Fix the error on the device manually.", + "StoreWasNotSuccesful": "Storing was not successful. Fix the error on the device manually.", + "OpenTrayWasNotSuccesful": ( + "Opening the tray was not successful. Fix the error on the device manually." + ), + "CloseTrayWasNotSuccesful": ( + "Closing the tray was not successful. Fix the error on the device manually." + ), + "TrayMoveWasNotSuccesful": ( + "Moving the tray was not successful. The tray can only travel between the load position " + "(setpoint 3) and the extended position (setpoint 127)." + ), + "HomeNotSuccesful": ( + "Device was not able to reach the home position. The device is in an error state. " + "Restart the device." + ), + "CartridgeEjectWasNotSuccesful": ( + "Ejecting the cartridge was not successful. The tray must be empty and no caps may be held " + "on the pins." + ), + "CartridgeLoadWasNotSuccesful": ( + "Loading the cartridge was not successful. Check that a cartridge is present on the tray and " + "seated at the expected height." + ), + "EjectCapsWasNotSuccesful": "Ejecting the held caps was not successful.", + "HeadUpWasNotSuccesful": "Homing the cap head was not successful.", + "SafetyDoorWasNotSuccesful": "Operating the safety door was not successful.", + "RetryDecapWasNotSuccesful": "The forced decap retry was not successful.", + "CannotGoInStandbyMode": "Cannot go to standby mode. Check the errors on the device.", + "NotInManualMode": ( + "This command is only available while the device is in manual recovery mode (StatusMANUAL)." + ), + "StatusManual": ( + "The device is in manual recovery mode. Inspect the rack and cap head, confirm that axis " + "motion is safe, then recover with reset_error(), or with head_up(), eject_caps() or " + "initialize_keeping_caps_on_pins(), before sending another motion command." + ), + "CommandIgnore": "Command was ignored by the device.", + "NoAck": "Device did not acknowledge the command.", +} + +# Fault descriptions per numeric error code, as read back with the error query. +# +# Source: Azenta IntelliXcap User Manual, part 319430 Rev. E, pp. 88-91: +# https://web.azenta.com/hubfs/azenta-files/resources/manuals-guides/319430-IXC-User-Manual.pdf +ERROR_CODE_MESSAGES: Dict[int, str] = { + 100: ( + "M1 top switch not detected during homing sequence. Could get overwritten by other error " + "codes within higher level sequencing logic, so it is most likely during startup." + ), + 101: "M2 initial homing failure. Likely to override other M1 homing error codes.", + 102: "M1 top switch stuck closed during homing sequence.", + 103: "M1 top switch second trigger not detected during homing sequence.", + 104: "M4 homing error: top switch not detected.", + 105: ( + "M3 top switch not detected during homing sequence. Could get overwritten by other error " + "codes within higher level sequencing logic, so it is most likely during startup." + ), + 106: "M3 stop switch stuck closed during homing sequence.", + 107: "M3 top switch second trigger not detected during homing sequence.", + 108: "M3 initial homing failure. Likely to override other M3 homing error codes.", + 109: ( + "M2 top switch not detected during homing sequence. Could get overwritten by other error " + "codes within higher level sequencing logic, so it is most likely during startup." + ), + 110: "M2 top switch stuck closed during homing sequence.", + 111: "M2 top switch second trigger not detected during homing sequence.", + 112: "Door close failure.", + 113: ( + "M1 moved to M1_SAFETY_LOW_POS (S33): no light curtain trigger was detected while " + "scanning for caps. Reported by the decap sequence." + ), + 114: "Invalid tube height detected. Reported by the decap sequence.", + 115: "Door open failure.", + 116: "Door close failure at start of sequence.", + 117: ( + "M1 moved to M1_SAFETY_LOW_POS (S33): no light curtain trigger was detected while " + "scanning for caps. Reported by the recap sequence." + ), + 118: "Invalid tube height detected. Reported by the recap sequence.", + 119: "Open door failure.", + 120: "Open door failure on entry to manual mode.", + 121: "Door close failure.", + 122: "M3 limit switch timeout on cartridge eject.", + 123: "Door open failure at end of cartridge eject sequence.", + 124: "Door close failure at end of cartridge eject sequence.", + 125: "M1 failed to reach the waste position within S4 during the auto-waste sequence.", + 133: "M1 homing error.", + 134: "Open door failure.", + 135: ( + "Cap detected at valid height. Suppressed while light curtain error detection is off; " + "in dry-run mode the instrument halts here and waits for the operator." + ), + 136: ( + "Maximum decap attempts exceeded (S46). Suppressed while light curtain error detection is " + "off; in dry-run mode the instrument halts here and waits for the operator." + ), + 137: "Maximum recap attempts exceeded (S45).", + 138: ( + "M3 bottom switch closed while the motor was still moving; extended-stage lead-screw " + "protection activated." + ), + 139: "Open tray failure; no cartridge detected after initial homing.", + 140: ( + "Cartridge-ejected notification; or the door should be up but the top switch was not detected." + ), + 141: "The door should be down but the bottom switch was not detected.", + 142: "Unexpected object on tray during cartridge eject.", + 143: "Cartridge not detected during cartridge load sequence.", + 144: ( + "Cartridge detection height incorrect during cartridge load sequence: detected height " + "was less than S73 - S59." + ), + 145: "Light curtain calibration max retries exceeded.", + 146: "Light curtain calibration max retries exceeded.", + 147: "Light curtain calibration max retries exceeded.", + 148: "Tray open failure.", + 150: "M3 homing error during auto-waste sequence.", + 151: "Tray close failure.", + 152: "Tube detected after decap retry; caps were screwed back on.", + 153: "Close tray failure; M3 homing error.", + 154: "Close tray failure.", + 155: "Open door failure.", + 156: "M1 homing error.", + 157: "M2 homing error.", + 158: "M3 homing error.", + 159: "M2 homing error.", + 160: "Door close failure at end of sequence; tray open failure.", + 161: "M4 homing error.", + 164: "Tray open failure.", + 165: "Sequence-state error; the same firmware logic may report error 167.", + 166: "M2 homing error during tray decap-quit.", + 167: "Door-open or tray-close failure during decap-quit.", + 200: "Light curtain communications failure: no Modbus data received.", + 201: "Light curtain signal failure; check wiring between controller and light curtain.", + 202: ( + "Conflicting limit switches: top and bottom switches both appear closed. This usually " + "indicates a power-supply failure or a faulty switch." + ), + 238: "Emergency stop engaged or motor voltage low.", +} + +# Codes the command list pins to StatusERROR, which homing clears, rather than +# to StatusMANUAL, which halts the instrument until an operator has inspected +# it: decap reports 113/114, recap 117/118, cartridge eject 142, and cartridge +# load 143/144. The command list states this only for those four commands, and +# it is inconsistent elsewhere (the tray and standby entries name StatusMANUAL +# in their state rows but StatusERROR in their comments), so a code outside this +# set is not proof that the instrument halted. +RECOVERABLE_ERROR_CODES: FrozenSet[int] = frozenset({113, 114, 117, 118, 142, 143, 144}) + + +def get_error_message(code: int) -> str: + """Return the documented meaning of an IntelliXcap error code. + + Some codes have multiple meanings because their interpretation depends on + the firmware sequence that reported them. + """ + message = ERROR_CODE_MESSAGES.get(code) + if message is None: + return "Unknown IntelliXcap error code." + return message + + +def is_recoverable_error(code: int) -> bool: + """Whether the command list pins this code to StatusERROR rather than StatusMANUAL. + + A StatusERROR is cleared by homing, which :meth:`FluidXIntelliXcap96.home` does + and which operations do for themselves when ``auto_recover`` is enabled. A + StatusMANUAL halts the instrument until an operator inspects it. False means + the command list does not pin the code to StatusERROR, not that the instrument + is certainly halted: it documents the mapping only for decap, recap, cartridge + eject and cartridge load. Read :meth:`FluidXIntelliXcap96.request_status` for + the state the instrument is actually in. + """ + return code in RECOVERABLE_ERROR_CODES + + +class FluidXError(Exception): + """Exceptions raised by a FluidX IntelliXcap 96 decapper.""" + + def __init__( + self, + title: str, + message: Optional[str] = None, + error_code: Optional[int] = None, + ) -> None: + self.title = title + self.message = message + self.error_code = error_code + + @classmethod + def from_error_code(cls, code: int, detail: Optional[str] = None) -> "FluidXError": + """Build an exception from a numeric IntelliXcap error code.""" + meaning = get_error_message(code) + message = f"{meaning} {detail}" if detail else meaning + return cls( + title=f"IntelliXcap error {code}", + message=message, + error_code=code, + ) + + @property + def recoverable(self) -> bool: + """Whether homing clears the reported error. False when there is no error code.""" + return self.error_code is not None and is_recoverable_error(self.error_code) + + def __str__(self) -> str: + return f"{self.title}: {self.message}" if self.message else self.title + + +class FirmwareVersions(NamedTuple): + """Firmware versions of the three IntelliXcap subsystems, each four digits.""" + + unit: str + touchscreen: str + light_curtain: str + + +class CartridgeInfo(NamedTuple): + """Identity and usage of the installed IntelliCartridge.""" + + profile: int + cycle_count: int + serial: str + + +@dataclasses.dataclass(frozen=True) +class CartridgeProfile: + """Settings of the cartridge profile the instrument is running.""" + + number: int + communication_protocol: int + decap_max_retry: int + recap_max_retry: int + raw: str + + @classmethod + def from_raw(cls, raw: str) -> "CartridgeProfile": + """Parse the five-digit profile reply: 2-digit number then three 1-digit fields.""" + digits = raw.strip() + if len(digits) != 5 or not digits.isdigit(): + raise FluidXError( + title="Malformed profile reply", + message=f"expected five digits, got {raw!r}", + ) + return cls( + number=int(digits[0:2]), + communication_protocol=int(digits[2]), + decap_max_retry=int(digits[3]), + recap_max_retry=int(digits[4]), + raw=digits, + ) + + +# The extended status bitmask, most significant bit first. +# +# The command list is inconsistent about the width: it names these twelve flags +# but shows an eleven-character answer field. Which end a short reply is missing +# is therefore unknown, and guessing costs correctness at the most significant +# bit, which is CAPS_ON_PINS. A reply of any other width is rejected rather than +# padded, so the discrepancy surfaces instead of silently reading caps as absent. +EXTENDED_STATUS_FLAGS: Tuple[str, ...] = ( + "caps_on_pins", + "dry_run_enabled", + "light_curtain_disabled", + "safety_door_rs232_disabled", + "stage_enabled", + "screw_caps_on", + "cartridge_installed", + "standby_active", + "stage_pos", + "recover_mode", + "estop_active", + "time_for_service", +) + + +@dataclasses.dataclass(frozen=True) +class ExtendedStatus: + """Instrument state flags that the plain status word does not carry. + + ``caps_on_pins`` reports directly what the status word only implies through + ``StatusRECAP``, and ``cartridge_installed``, ``estop_active`` and + ``recover_mode`` explain states that otherwise only show up as a refused + command. + + ``stage_pos`` is the command list's ``STAGE_POS`` bit. Which position each + value means is not documented. + """ + + caps_on_pins: bool + dry_run_enabled: bool + light_curtain_disabled: bool + safety_door_rs232_disabled: bool + stage_enabled: bool + screw_caps_on: bool + cartridge_installed: bool + standby_active: bool + stage_pos: bool + recover_mode: bool + estop_active: bool + time_for_service: bool + raw: str + + @classmethod + def from_raw(cls, raw: str) -> "ExtendedStatus": + bits = raw.strip() + if not bits or any(character not in "01" for character in bits): + raise FluidXError( + title="Malformed extended status reply", + message=f"expected a string of 0s and 1s, got {raw!r}", + ) + width = len(EXTENDED_STATUS_FLAGS) + if len(bits) != width: + raise FluidXError( + title="Unexpected extended status width", + message=( + f"got {len(bits)} bits, expected {width}: {bits!r}. The command list names {width} " + f"flags but shows an {width - 1}-character answer, so which end is missing cannot be " + "guessed without misreading CAPS_ON_PINS. Map this reply against the instrument's " + "known state and fix EXTENDED_STATUS_FLAGS." + ), + ) + values = {flag: bits[index] == "1" for index, flag in enumerate(EXTENDED_STATUS_FLAGS)} + return cls(raw=bits, **values) + + +def _fault(key: str, detail: Optional[str] = None) -> FluidXError: + """Build a FluidXError carrying the firmware's own description for ``key``.""" + return FluidXError(title=ERROR_MESSAGES.get(key, key), message=detail) + + +def _is_error_frame(frame: str) -> bool: + """Whether a reply frame reports a fault rather than a success or a setting.""" + return _ERROR_FRAME_RE.search(frame.upper()) is not None + + +def _error_code(frames: List[str]) -> Optional[int]: + """Extract a known three-digit error code from serial reply frames.""" + for frame in frames: + for value in re.findall(r"(? Optional[int]: + """Read the profile number out of a cartridge load reply's ``onnOK`` frame.""" + for frame in frames: + match = re.fullmatch(r"o(\d+)OK", frame) + if match is not None: + return int(match.group(1)) + return None + + +class FluidXIntelliXcap96: + """FluidX IntelliXcap 96 automated screw-cap decapper. + + A benchtop instrument that decaps and recaps a 96-format rack of screw-cap + tubes in a single stroke. It holds one nest; a plate mover loads the rack, the + decapper unscrews all 96 caps (``decap``), holds them, and screws them back on + (``recap``). Held caps can also be released into a separately positioned cap + carrier (``waste``), and the loading tray opened and closed. ``waste`` does + not verify that a carrier is present: remove the tube rack and position the + correct carrier before using it. If the rack is left beneath the head, the + released caps can fall back onto the tubes without being properly recapped. + + Serial settings: + 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake. Replies are + framed between STX (0x02) and ETX (0x03). + + The instrument only speaks this protocol once setpoint 86 is set to 2 + ("IntelliXcap mode"), which is done at the instrument touchscreen; there is no + serial command for it. + + Tube type and volume are not sent over the serial protocol. The installed + IntelliCartridge and its firmware profile define the supported tube/cap + geometry and motion settings. Fit and configure the cartridge specified for + the exact tube family; volume alone (for example, 0.5 mL) is not sufficient + to select a compatible cartridge. + + Single-character commands, each written followed by ETX: + a request status + b release held caps into a carrier + c eject the cartridge onto the tray + d dry-run mode on + D dry-run mode off + e extended status bitmask + E cartridge profile settings + f open the tray + g close the tray + h start decapping + i start recapping + j enter standby + k leave standby (ready) + l light curtain detection off + L light curtain detection on + N cartridge profile/cycles/serial + Q force a decap retry + s step the tray out by S88 + S step the tray in by S88 + V firmware versions + X reset the cartridge cycle counter + Z home all axes + z initialize keeping caps on pins + + safety door on + - safety door off + 5 eject held caps (manual mode) + 6 home the cap head (manual mode) + 7 open the safety door (manual mode) + 8 latched error code + + A command is answered with an ACK frame (0x06), a ``OK`` echo frame, and a + result frame. The status word, in the priority the firmware reports it, is + ``StatusMANUAL`` (halted, needs inspection), ``StatusERROR`` (error code + latched), ``StatusSLEEP`` (standby), ``StatusBUSY`` (motion running), + ``StatusRECAP`` (decapped, caps held on the pins) or ``StatusOK`` (idle). + Cartridge ejection reports ``StatusCAREJECT``. A refused or no-op command + answers with ``CommandIgnore``. Motions complete when the status word returns + from ``StatusBUSY`` to the operation's terminal state. + + Which state a failure lands in depends on the error code: 113/114 for decap, + 117/118 for recap, 142 for cartridge eject and 143/144 for cartridge load + latch ``StatusERROR``, and the command list says anything else halts those + four commands in ``StatusMANUAL``. It does not document the mapping for the + other commands. :meth:`request_error_code` reads the latched code, and every + raised :class:`FluidXError` carries it in ``error_code`` when the instrument + reported one. + + Homing clears a ``StatusError``. With ``auto_recover`` enabled (the default), + an operation issued while the device is latched in error homes to recover and + then proceeds. + + Newer firmware documents ``t``/``T`` commands for absolute tray travel + between S3 and S127. Unit firmware V49 ignored both commands completely, so + :meth:`extend_tray` and :meth:`retract_tray` raise + :class:`NotImplementedError` without writing to the instrument. + + Verified against hardware: connection, queries, tray open/close, home, + standby/ready, settings, cartridge eject/load, the decap error/recovery path, + forced decap retry, and decap/recap with a loaded 0.5 mL rack, including + release of held caps with ``waste``. Unit firmware V49 did not acknowledge + the four tray travel commands. The cartridge counter reset and manual + recovery commands have not been exercised on an instrument. + + See the Azenta IntelliXcap user manual for the required carrier and physical + setup: + https://web.azenta.com/hubfs/azenta-files/resources/manuals-guides/319430-IXC-User-Manual.pdf + """ + + def __init__( + self, + port: str, + timeout: float = 5.0, + command_delay: float = 0.3, + frame_gap: float = 0.5, + poll_interval: float = 1.0, + auto_recover: bool = True, + recover_timeout: float = 30.0, + ) -> None: + """ + Args: + port: serial port the decapper is connected to. + timeout: serial read/write timeout in seconds. + command_delay: pause after writing a command before reading its reply. + frame_gap: how long to wait for another reply frame before concluding the + reply is complete. + poll_interval: pause between status polls while a motion runs. + auto_recover: when an operation finds the device latched in StatusError, + home it to clear the error and continue. A latched error is only cleared + by homing. Disable to make a latched error raise instead. + recover_timeout: timeout in seconds for the recovery home. + """ + self.command_delay = command_delay + self.frame_gap = frame_gap + self.poll_interval = poll_interval + self.auto_recover = auto_recover + self.recover_timeout = recover_timeout + self.io = Serial( + human_readable_device_name="FluidX IntelliXcap 96", + port=port, + baudrate=9600, + bytesize=8, + parity="N", + stopbits=1, + timeout=timeout, + ) + + async def setup(self) -> None: + await self.io.setup() + status = await self.request_status() + up = status.upper() + if "BUSY" in up: + # At connect there is no motion in flight, so StatusBUSY means the + # instrument is locked out, commonly by an engaged e-stop; the safety + # guard/hood and other interlocks do the same. The extended status carries + # an e-stop bit, so read it before giving up. + estop = await self._estop_hint() + logger.error( + "[IntelliXcap96 %s] reports StatusBUSY at connect and will ignore commands. %s " + "Also check the safety guard/hood and interlocks, and retry.", + self.io.port, + estop, + ) + raise FluidXError( + title="Decapper is not ready (StatusBUSY)", + message=( + f"The device reports BUSY and ignores commands. {estop} Also check the safety " + "guard/hood and interlocks, then retry." + ), + ) + if "MANUAL" in up: + raise await self._latched_fault("StatusManual", status) + if "ERROR" in up: + raise await self._latched_fault("StatusNotOK", status) + logger.info("[IntelliXcap96 %s] connected: %s", self.io.port, status) + + async def stop(self) -> None: + """Close the serial connection.""" + await self.io.stop() + + # === Framed command layer === + + async def _send(self, command: str) -> None: + """Discard pending input, write a command with its terminator, then pace.""" + await self.io.reset_input_buffer() + await self.io.write(command.encode("ascii") + ETX) + await asyncio.sleep(self.command_delay) + + async def _read_frame(self) -> Optional[str]: + """Read one STX..ETX frame and return its payload, or None if none arrives.""" + while True: + byte = await self.io.read(1) + if byte == b"": + return None + if byte == STX: + break + buf = bytearray() + while True: + byte = await self.io.read(1) + if byte in (b"", ETX): + break + buf += byte + return buf.decode("ascii", errors="replace") + + async def send_command(self, command: str) -> List[str]: + """Send a raw command and collect every reply frame until the reply goes quiet.""" + await self._send(command) + frames: List[str] = [] + with self.io.temporary_timeout(self.frame_gap): + while True: + frame = await self._read_frame() + if frame is None: + break + frames.append(frame) + logger.debug("[IntelliXcap96] %r -> %r", command, frames) + return frames + + @staticmethod + def _status_frame(frames: List[str]) -> Optional[str]: + return next((f for f in frames if f.upper().startswith("STATUS")), None) + + @staticmethod + def _payload_frame(frames: List[str], command: str) -> Optional[str]: + """Return the data frame of a query reply, skipping the ack and command echo.""" + echo = f"{command}OK" + for frame in frames: + if frame == ACK or frame == echo or frame.upper().startswith("STATUS"): + continue + return frame + return None + + def _require_accepted( + self, + command: str, + frames: List[str], + name: str, + idempotent: bool = False, + echo: Optional[str] = None, + ) -> bool: + """Check a command's reply. Return True if it started a motion. + + Raises if the device did not ack and echo the command. A ``CommandIgnore`` + reply means the command was a no-op (the device is already in the requested + state): for an ``idempotent`` command that is success and returns False (no + motion to wait for); otherwise it is raised. ``echo`` overrides the expected + echo frame for commands the firmware acknowledges under another name. + """ + if ACK not in frames or f"{echo or command}OK" not in frames: + raise _fault("NoAck", f"{name}: {frames!r}") + if any(COMMAND_IGNORE in f for f in frames): + if idempotent: + return False + raise _fault("CommandIgnore", f"{name}: device already in that state or not ready") + return True + + @staticmethod + def _require_answer(frames: List[str], expected: str, name: str) -> None: + """Raise unless the device confirmed a setting with its documented reply frame.""" + if expected not in frames: + raise _fault("NoAck", f"{name}: expected {expected!r}, got {frames!r}") + + async def _try_request_error_code(self) -> Optional[int]: + """Read the latched error code, returning None if the query itself fails.""" + try: + return await self.request_error_code() + except FluidXError as exception: + logger.debug("[IntelliXcap96 %s] error code query failed: %s", self.io.port, exception) + return None + + async def _latched_fault( + self, fail_key: str, detail: str, frames: Optional[List[str]] = None + ) -> FluidXError: + """Build the error for a failed operation, enriched with the latched error code. + + The code is taken from the reply frames when the firmware inlined it there, + and otherwise read back with the error query, which is the documented way to + find out why an operation failed. + """ + code = _error_code(frames) if frames else None + if code is None: + code = await self._try_request_error_code() + if code is not None: + return FluidXError.from_error_code(code, detail=detail) + return _fault(fail_key, f"{detail}. The device did not report a numeric error code.") + + async def _estop_hint(self) -> str: + """Describe the e-stop state for a lockout message, if the device will say.""" + try: + extended = await self.request_extended_status() + except FluidXError: + return "The E-STOP could not be read; it is a common cause." + if extended.estop_active: + return "The E-STOP is engaged; release it." + return "The E-STOP reads as released." + + async def _wait_for_answer( + self, + frames: List[str], + done_frame: str, + timeout: float, + name: str, + fail_key: str, + ) -> None: + """Wait for an operation's own answer frame instead of polling status. + + Some operations do not announce completion through the status word: the + status word is ``StatusMANUAL`` throughout a manual recovery command because + the halt outranks ``StatusBUSY``, and the tray travel commands change no + status at all. Their answer frame is the only completion signal, so this + keeps reading frames rather than sending status polls, which would reset the + input buffer and drop the answer. + + ``frames`` is the command's own reply, which often already carries the + answer. + """ + done = done_frame.upper() + if any(_is_error_frame(f) for f in frames): + raise await self._latched_fault(fail_key, f"{name}: {frames!r}", frames) + if any(f.upper() == done for f in frames): + return + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + failure: Optional[List[str]] = None + with self.io.temporary_timeout(self.frame_gap): + while loop.time() < deadline: + frame = await self._read_frame() + if frame is None: + await asyncio.sleep(self.poll_interval) + continue + if _is_error_frame(frame): + failure = [frame] + break + if frame.upper() == done: + return + if failure is not None: + raise await self._latched_fault(fail_key, f"{name}: {failure!r}", failure) + raise FluidXError( + title=f"{name} timed out", + message=f"did not answer with {done_frame!r} within {timeout}s", + ) + + async def _wait_for_idle( + self, + timeout: float, + name: str, + fail_key: str, + terminal_statuses: Tuple[str, ...] = ("StatusOK",), + done_frames: Tuple[str, ...] = (), + ) -> List[str]: + """Poll status until it reaches an expected idle state. + + ``fail_key`` names the firmware error message to raise if the status word + reports an error while waiting. ``terminal_statuses`` accounts for + operation state retained while the instrument is idle: hardware reports + ``StatusRECAP`` after decapping and after tray motion with caps held. + ``done_frames`` are the operation's own completion frames, which end the + wait as soon as one is seen. + + Returns every frame seen while waiting, so a caller can pick up an answer + the instrument sends alongside its completion. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + expected = {status.upper() for status in terminal_statuses} + done = {frame.upper() for frame in done_frames} + seen: List[str] = [] + while loop.time() < deadline: + frames = await self.send_command(STATUS) + seen.extend(frames) + for frame in frames: + if frame.upper() in ("RETRYDECAP", "RETRYRECAP"): + # The instrument retries a failed stroke by itself; report the attempt. + logger.info("[IntelliXcap96 %s] %s: instrument reports %s", self.io.port, name, frame) + if any(_is_error_frame(f) for f in frames): + raise await self._latched_fault(fail_key, f"{name}: {frames!r}", frames) + if any(f.upper() in done for f in frames): + return seen + status = self._status_frame(frames) + if status is not None: + up = status.upper() + if up in expected: + return seen + if "MANUAL" in up: + # A halt needs an operator, so waiting out the timeout only delays the report. + raise await self._latched_fault("StatusManual", f"{name}: {frames!r}", frames) + await asyncio.sleep(self.poll_interval) + raise FluidXError( + title=f"{name} timed out", + message=f"did not reach {terminal_statuses!r} within {timeout}s", + ) + + # === Status and queries === + + async def request_status(self) -> str: + """Poll the device and return its status word (e.g. ``StatusOK``).""" + frames = await self.send_command(STATUS) + status = self._status_frame(frames) + if status is None: + raise FluidXError(title="No status reply", message=repr(frames)) + return status + + async def request_error_code(self) -> Optional[int]: + """Read the error code the instrument has latched, or None if there is none. + + This is the documented way to find out *why* an operation failed: + ``StatusERROR`` and ``StatusMANUAL`` say only that something went wrong. + :func:`get_error_message` turns the code into its documented meaning, and + :func:`is_recoverable_error` says whether homing will clear it. + + The command list documents the reply only as a three-digit error code and + does not say how "no error" is expressed; a code of 0 is read as none. + """ + frames = await self.send_command(ERROR_QUERY) + self._require_accepted(ERROR_QUERY, frames, "Reading the error code", idempotent=True) + payload = self._payload_frame(frames, ERROR_QUERY) + if payload is None or not payload.strip().isdigit(): + raise FluidXError(title="No error code reply", message=repr(frames)) + code = int(payload.strip()) + return code if code != 0 else None + + async def request_extended_status(self) -> ExtendedStatus: + """Read the extended status bitmask. + + Reports state the status word omits, including whether caps are held on the + pins, whether a cartridge is installed and whether the e-stop is engaged. + """ + frames = await self.send_command(EXTENDED_STATUS) + self._require_accepted(EXTENDED_STATUS, frames, "Reading the extended status", idempotent=True) + payload = self._payload_frame(frames, EXTENDED_STATUS) + if payload is None: + raise FluidXError(title="No extended status reply", message=repr(frames)) + return ExtendedStatus.from_raw(payload) + + async def request_firmware_versions(self) -> FirmwareVersions: + """Read the unit, touchscreen and light curtain firmware versions. + + Dry-run mode requires touchscreen firmware V14 or above. The store operation + is broken in V44; the command list does not say which of the three + firmwares that version refers to. + """ + frames = await self.send_command(FIRMWARE_QUERY) + self._require_accepted(FIRMWARE_QUERY, frames, "Reading the firmware versions", idempotent=True) + payload = self._payload_frame(frames, FIRMWARE_QUERY) + parts = [part.strip() for part in payload.split(",")] if payload else [] + if len(parts) != 3: + raise FluidXError(title="Malformed firmware reply", message=repr(frames)) + return FirmwareVersions(unit=parts[0], touchscreen=parts[1], light_curtain=parts[2]) + + async def request_cartridge_info(self) -> CartridgeInfo: + """Read the installed cartridge's profile, cycle count and serial. + + The serial always reads ``"00000000"``: the firmware does not implement it. + """ + frames = await self.send_command(CARTRIDGE_QUERY) + self._require_accepted( + CARTRIDGE_QUERY, frames, "Reading the cartridge details", idempotent=True + ) + payload = self._payload_frame(frames, CARTRIDGE_QUERY) + parts = [part.strip() for part in payload.split(",")] if payload else [] + if len(parts) != 3 or not parts[0].isdigit() or not parts[1].isdigit(): + raise FluidXError(title="Malformed cartridge reply", message=repr(frames)) + return CartridgeInfo(profile=int(parts[0]), cycle_count=int(parts[1]), serial=parts[2]) + + async def request_profile(self) -> CartridgeProfile: + """Read the active profile number and its decap/recap retry limits.""" + frames = await self.send_command(PROFILE_QUERY) + self._require_accepted(PROFILE_QUERY, frames, "Reading the profile", idempotent=True) + payload = self._payload_frame(frames, PROFILE_QUERY) + if payload is None: + raise FluidXError(title="No profile reply", message=repr(frames)) + return CartridgeProfile.from_raw(payload) + + async def caps_on_pins(self) -> bool: + """Whether caps are currently held on the ejector pins. + + Reads the ``CAPS_ON_PINS`` bit. The decap, recap and waste guards instead + gate on the ``StatusRECAP`` word, which the command list ties to the same + condition and which is hardware-verified. + """ + return (await self.request_extended_status()).caps_on_pins + + # === Operations === + + async def open_tray(self, timeout: float = 15.0) -> None: + """Open the loading tray. Also opens the safety door.""" + await self._ensure_ready() + frames = await self.send_command(OPEN_TRAY) + if self._require_accepted(OPEN_TRAY, frames, "Opening the tray", idempotent=True): + await self._wait_for_idle( + timeout, + "Opening the tray", + "OpenTrayWasNotSuccesful", + ("StatusOK", "StatusRECAP"), + done_frames=("OpenDONE",), + ) + logger.info("[IntelliXcap96 %s] tray open", self.io.port) + + async def close_tray(self, timeout: float = 15.0) -> None: + """Close the loading tray, moving it to the decap/recap position.""" + await self._ensure_ready() + frames = await self.send_command(CLOSE_TRAY) + if self._require_accepted(CLOSE_TRAY, frames, "Closing the tray", idempotent=True): + await self._wait_for_idle( + timeout, + "Closing the tray", + "CloseTrayWasNotSuccesful", + ("StatusOK", "StatusRECAP"), + done_frames=("CloseDONE",), + ) + logger.info("[IntelliXcap96 %s] tray closed", self.io.port) + + async def _tray_move(self, command: str, name: str, timeout: float) -> None: + """Run one of the tray travel commands. + + These acknowledge and answer with the same ``OK`` frame, so a completed + move is a second echo and a failed one is ``ERROR``. + + Unit firmware V49 does not acknowledge these commands. + """ + await self._ensure_ready() + echo = f"{command}OK" + frames = await self.send_command(command) + self._require_accepted(command, frames, name) + if frames.count(echo) < 2: + await self._wait_for_answer( + [f for f in frames if f != echo], echo, timeout, name, "TrayMoveWasNotSuccesful" + ) + logger.info("[IntelliXcap96 %s] %s", self.io.port, name.lower()) + + async def extend_tray(self, timeout: float = 15.0) -> None: + """Move the tray from the load position (S3) out to the extended position (S127). + + This command is documented for newer firmware but is not implemented here: + unit firmware V49 ignored it completely. The method raises without writing + to the instrument. + """ + raise NotImplementedError( + "extend_tray() uses the newer-firmware 't' command, which is not supported by " + "IntelliXcap unit firmware V49" + ) + + async def retract_tray(self, timeout: float = 15.0) -> None: + """Move the tray from the extended position (S127) back to the load position (S3). + + This command is documented for newer firmware but is not implemented here: + unit firmware V49 ignored it completely. The method raises without writing + to the instrument. + """ + raise NotImplementedError( + "retract_tray() uses the newer-firmware 'T' command, which is not supported by " + "IntelliXcap unit firmware V49" + ) + + async def step_tray_out(self, timeout: float = 15.0) -> None: + """Move the tray further out by the step distance in setpoint 88. + + Relative, so calling it twice moves the tray twice. The instrument offers no + way to command an intermediate position directly, and no way to read the + current one back, so stepping is the only access to the range between the + two ends; :meth:`extend_tray` and :meth:`retract_tray` are the absolute + moves. Travel is bounded by the load position (S3) and the extended position + (S127); a step that would leave that range fails. + """ + await self._tray_move(TRAY_STEP_OUT, "Stepping the tray out", timeout) + + async def step_tray_in(self, timeout: float = 15.0) -> None: + """Move the tray back in by the step distance in setpoint 88. + + Relative, so calling it twice moves the tray twice. See + :meth:`step_tray_out`. Travel is bounded by the load position (S3) and the + extended position (S127); a step that would leave that range fails. + """ + await self._tray_move(TRAY_STEP_IN, "Stepping the tray in", timeout) + + async def _home_sequence(self, timeout: float, name: str) -> None: + """Send the home command and wait for it to finish. Also clears a latched error.""" + frames = await self.send_command(HOME_ALL) + self._require_accepted(HOME_ALL, frames, name) + await self._wait_for_idle(timeout, name, "HomeNotSuccesful", done_frames=("ZDONE",)) + + async def _ensure_ready(self) -> str: + """Return the current status, first clearing a latched error by homing. + + A ``StatusError`` is only cleared by homing. With ``auto_recover`` enabled, + an operation that finds the device latched in error homes to recover and + then proceeds; otherwise the latched error is raised. + """ + status = await self.request_status() + up = status.upper() + if "MANUAL" in up: + raise await self._latched_fault("StatusManual", status) + if "ERROR" not in up: + return status + if not self.auto_recover: + raise await self._latched_fault("StatusNotOK", status) + logger.warning( + "[IntelliXcap96 %s] latched in StatusError; homing to recover before continuing.", + self.io.port, + ) + await self._home_sequence(self.recover_timeout, "Homing (error recovery)") + status = await self.request_status() + if "ERROR" in status.upper(): + raise await self._latched_fault("StatusNotOK", "error persisted after recovery home") + return status + + async def _require_manual_mode(self, name: str) -> None: + """Raise unless the instrument is in manual recovery mode.""" + status = await self.request_status() + if "MANUAL" not in status.upper(): + raise _fault("NotInManualMode", f"{name}: device reports {status}") + + async def _manual_command( + self, command: str, name: str, fail_key: str, done_frame: str, timeout: float + ) -> None: + """Run a command that is only accepted while the instrument is halted. + + Manual recovery keeps the status word at ``StatusMANUAL`` for the whole + operation, so completion is read from the answer frame. + """ + await self._require_manual_mode(name) + frames = await self.send_command(command) + self._require_accepted(command, frames, name) + await self._wait_for_answer(frames, done_frame, timeout, name, fail_key) + + async def reset_error(self, timeout: Optional[float] = None) -> None: + """Recover from ``StatusError`` or ``StatusMANUAL`` by homing all axes. + + Hardware testing confirmed that the home-all command transitions + ``StatusMANUAL`` through ``StatusBUSY`` to ``StatusOK``. Call this only after + inspecting the rack and cap head and confirming that axis motion is safe. + + What homing does to caps held on the pins is unsettled. The command list + says the home sequence "will not drop caps", yet it also carries a separate + command to initialize without dropping them, which that would make + redundant. Neither behaviour has been checked on an instrument. With caps + held, prefer :meth:`initialize_keeping_caps_on_pins`, which is the command + documented for that case. + + This method is a no-op when the instrument is not in an error or manual + recovery state. + + Args: + timeout: maximum recovery time in seconds. Defaults to + ``recover_timeout`` configured on this instance. + """ + status = await self.request_status() + up = status.upper() + if "ERROR" not in up and "MANUAL" not in up: + return + await self._home_sequence( + self.recover_timeout if timeout is None else timeout, + "Resetting error", + ) + logger.info("[IntelliXcap96 %s] error reset by homing", self.io.port) + + async def home(self, timeout: float = 30.0) -> None: + """Home all axes. Also clears all errors and a latched StatusError.""" + await self._home_sequence(timeout, "Homing") + logger.info("[IntelliXcap96 %s] homed", self.io.port) + + async def initialize_keeping_caps_on_pins(self, timeout: float = 30.0) -> None: + """Clear the error state and home without dropping caps held on the pins. + + For recovering an instrument that stopped mid-cycle with caps held, after + which :meth:`recap` can put them back. The firmware marks this command + deprecated, and it is only accepted in manual recovery mode. + """ + await self._manual_command( + INITIALIZE_KEEP_CAPS, + "Initializing while keeping caps on pins", + "HomeNotSuccesful", + "zDONE", + timeout, + ) + logger.info("[IntelliXcap96 %s] initialized with caps on pins", self.io.port) + + async def decap(self, timeout: float = 60.0) -> None: + """Unscrew and hold all 96 caps. + + Args: + timeout: maximum time in seconds to wait for the stroke to finish. + """ + status = (await self._ensure_ready()).upper() + if "RECAP" in status: + raise _fault("NeedToRecap") + frames = await self.send_command(DECAP_START) + self._require_accepted(DECAP_START, frames, "Decapping") + await self._wait_for_idle( + timeout, + "Decapping", + "DecapWasNotSuccesful", + ("StatusRECAP",), + done_frames=("DecapDONE",), + ) + logger.info("[IntelliXcap96 %s] decap complete", self.io.port) + + async def retry_decap(self, timeout: float = 60.0) -> None: + """Force one more decap stroke on tubes that a completed decap left capped. + + Only useful with light curtain error detection off. With detection on the + instrument sees the tubes that failed to decap and retries inside + :meth:`decap` by itself, up to the profile's decap retry limit, so there is + nothing left to force. With detection off it cannot see them, ends the + stroke as successful, and this is the only way to ask for another attempt: + :meth:`decap` refuses while caps are held. + :meth:`set_error_detection_enabled` controls detection. + + The command list documents only that this command is used "after a + successful Decap" and "requires lightcurtain OFF". That those are the same + condition is inferred from the automatic-retry and error-detection commands + rather than stated by the vendor. The instrument finishes in + ``StatusRECAP`` because it is still holding the caps for a subsequent + :meth:`recap`. + """ + await self._ensure_ready() + frames = await self.send_command(RETRY_DECAP) + # The firmware acknowledges a forced retry as if it were a fresh decap. + self._require_accepted(RETRY_DECAP, frames, "Retrying the decap", echo=DECAP_START) + await self._wait_for_idle( + timeout, + "Retrying the decap", + "RetryDecapWasNotSuccesful", + ("StatusRECAP",), + done_frames=("DecapDONE",), + ) + logger.info("[IntelliXcap96 %s] decap retry complete", self.io.port) + + async def recap(self, timeout: float = 60.0) -> None: + """Screw the held caps back on. + + Args: + timeout: maximum time in seconds to wait for the stroke to finish. + """ + status = (await self._ensure_ready()).upper() + if "RECAP" not in status: + raise _fault("NeedToDecap") + frames = await self.send_command(RECAP_START) + self._require_accepted(RECAP_START, frames, "Recapping") + await self._wait_for_idle( + timeout, + "Recapping", + "RecapWasNotSuccesful", + ("StatusOK",), + done_frames=("RecapDONE",), + ) + logger.info("[IntelliXcap96 %s] recap complete", self.io.port) + + async def waste(self, timeout: float = 60.0) -> None: + """Release the currently held caps into a separately positioned cap carrier. + + This is irreversible. Before calling, remove the sample-tube rack and + position the correct cap carrier/collection vessel as specified in the + Azenta IntelliXcap user manual. The instrument does not detect or verify the + carrier. If the tube rack remains beneath the head, released caps can fall + back onto the tube openings and look recapped even though they may not be + threaded or torqued. + + The instrument picks the store sequence from the height of the cap carrier + and otherwise performs a recap. Firmware V44 does not implement this command + -- the command list does not say which of the three firmwares it means -- so + on that version load a store rack and use :meth:`decap` and :meth:`recap`, + which select the store sequence themselves. A store that times out presents + the tray, answering ``LoadOK`` alongside the ``StoreERROR``. + + User manual: + https://web.azenta.com/hubfs/azenta-files/resources/manuals-guides/319430-IXC-User-Manual.pdf + + Args: + timeout: maximum time in seconds to wait for the stroke to finish. + """ + status = (await self._ensure_ready()).upper() + if "RECAP" not in status: + raise _fault("NeedToDecap", "waste requires caps held after decapping") + frames = await self.send_command(WASTE) + self._require_accepted(WASTE, frames, "Wasting caps") + await self._wait_for_idle( + timeout, + "Wasting caps", + "StoreWasNotSuccesful", + ("StatusOK",), + done_frames=("StoreDONE",), + ) + logger.info("[IntelliXcap96 %s] waste complete", self.io.port) + + async def standby(self, timeout: float = 15.0) -> None: + """Put the decapper into low-power standby (sleep) mode. + + A no-op when the instrument is already asleep. The instrument requires + StatusOK to sleep, and refuses while caps are held on the pins. + """ + if "SLEEP" in (await self.request_status()).upper(): + return + frames = await self.send_command(STANDBY) + self._require_accepted(STANDBY, frames, "Entering standby") + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + if "SLEEP" in (await self.request_status()).upper(): + logger.info("[IntelliXcap96 %s] standby", self.io.port) + return + await asyncio.sleep(self.poll_interval) + raise _fault("CannotGoInStandbyMode", "standby timed out") + + async def ready(self, timeout: float = 30.0) -> None: + """Wake the decapper from standby if it is asleep.""" + if "SLEEP" not in (await self.request_status()).upper(): + return + frames = await self.send_command(READY) + self._require_accepted(READY, frames, "Waking from standby") + await self._wait_for_idle( + timeout, "Waking from standby", "StatusNotOK", done_frames=("ReadyDONE",) + ) + logger.info("[IntelliXcap96 %s] ready", self.io.port) + + # === Cartridge handling === + + async def eject_cartridge(self, timeout: float = 60.0) -> None: + """Eject the installed IntelliCartridge onto the tray. + + A no-op when no cartridge is installed. The tray must be empty and no caps + may be held on the pins. + """ + extended = await self.request_extended_status() + if not extended.cartridge_installed: + return + if extended.caps_on_pins: + raise _fault("CapsOnPins", "the cartridge cannot be ejected while caps are held") + await self._ensure_ready() + frames = await self.send_command(CARTRIDGE_EJECT) + self._require_accepted(CARTRIDGE_EJECT, frames, "Ejecting the cartridge") + await self._wait_for_idle( + timeout, + "Ejecting the cartridge", + "CartridgeEjectWasNotSuccesful", + ("StatusCAREJECT",), + done_frames=("CarEjectDONE",), + ) + logger.info("[IntelliXcap96 %s] cartridge ejected", self.io.port) + + async def load_cartridge(self, timeout: float = 60.0) -> Optional[int]: + """Pick up the cartridge resting on the tray. + + A no-op when a cartridge is already installed. Loading a stored profile + (16-96) answers ``ProfileLoadERROR`` if that profile does not exist or is + invalid. + + Returns: + The loaded profile number when the instrument reports one, else None. It + answers with ``onnOK`` at the end of the motion, so this is None if that + frame never arrives. + """ + if (await self.request_extended_status()).cartridge_installed: + return None + frames = await self.send_command(CARTRIDGE_LOAD) + self._require_accepted(CARTRIDGE_LOAD, frames, "Loading the cartridge") + if any(_is_error_frame(f) for f in frames): + raise await self._latched_fault( + "CartridgeLoadWasNotSuccesful", f"Loading the cartridge: {frames!r}", frames + ) + seen = await self._wait_for_idle( + timeout, + "Loading the cartridge", + "CartridgeLoadWasNotSuccesful", + ("StatusOK",), + done_frames=("CarLoadDONE", "ExtCarLoadDONE"), + ) + profile = _loaded_profile(frames + seen) + logger.info("[IntelliXcap96 %s] cartridge loaded (profile %s)", self.io.port, profile) + return profile + + async def reset_cartridge_counter(self) -> None: + """Reset the installed cartridge's cycle counter to zero.""" + name = "Resetting the cartridge counter" + frames = await self.send_command(CARTRIDGE_COUNTER_RESET) + if self._require_accepted(CARTRIDGE_COUNTER_RESET, frames, name, idempotent=True): + self._require_answer(frames, "CarResetDONE", name) + logger.info("[IntelliXcap96 %s] cartridge counter reset", self.io.port) + + # === Settings === + + async def set_error_detection_enabled(self, enabled: bool) -> None: + """Turn light curtain error detection during decap and recap on or off. + + With detection off the instrument finishes every decap and recap stroke + instead of stopping on error 135 or 136, and :meth:`retry_decap` becomes + available. Detection is on at power-up. + """ + command = ERROR_DETECTION_ON if enabled else ERROR_DETECTION_OFF + name = "Enabling error detection" if enabled else "Disabling error detection" + frames = await self.send_command(command) + if self._require_accepted(command, frames, name, idempotent=True): + self._require_answer(frames, "ErrorDetectON" if enabled else "ErrorDetectOFF", name) + logger.info("[IntelliXcap96 %s] error detection %s", self.io.port, "on" if enabled else "off") + + async def set_dry_run_enabled(self, enabled: bool) -> None: + """Turn dry-run mode on or off. + + In dry-run mode the instrument stops and waits for the operator when the + light curtain reports error 114, 118, 135 or 136 instead of failing the + operation. Requires touchscreen firmware V14 or above, which + :meth:`request_firmware_versions` reports. + """ + command = DRY_RUN_ON if enabled else DRY_RUN_OFF + name = "Enabling dry-run mode" if enabled else "Disabling dry-run mode" + frames = await self.send_command(command) + if self._require_accepted(command, frames, name, idempotent=True): + self._require_answer(frames, "DryRunON" if enabled else "DryRunOFF", name) + logger.info("[IntelliXcap96 %s] dry-run mode %s", self.io.port, "on" if enabled else "off") + + async def set_safety_door_enabled(self, enabled: bool) -> None: + """Turn safety door operation on or off. + + Disabling it opens the door and keeps it open. Call this with ``True`` to + re-enable the door; it is also enabled again at power-up. + """ + command = SAFETY_DOOR_ENABLE if enabled else SAFETY_DOOR_DISABLE + name = "Enabling the safety door" if enabled else "Disabling the safety door" + frames = await self.send_command(command) + if self._require_accepted(command, frames, name, idempotent=True): + self._require_answer(frames, "DoorONDONE" if enabled else "DoorOFFDONE", name) + logger.info("[IntelliXcap96 %s] safety door %s", self.io.port, "on" if enabled else "off") + + # === Manual recovery === + + async def eject_caps(self, timeout: float = 30.0) -> None: + """Home the cap head, dropping any caps held on the cap drivers. + + Closes the tray first if it is open, and opens the safety door. Only + available in manual recovery mode, and the instrument stays there + afterwards. The caps fall wherever the head is standing, so clear the deck + and read :meth:`waste` before using this to get rid of caps deliberately. + """ + await self._manual_command( + EJECT_CAPS, "Ejecting the caps", "EjectCapsWasNotSuccesful", "EjectDONE", timeout + ) + logger.info("[IntelliXcap96 %s] caps ejected", self.io.port) + + async def head_up(self, timeout: float = 30.0) -> None: + """Home the Z axis, keeping any held caps on the pins. + + Only available in manual recovery mode, and the instrument stays there + afterwards. + """ + await self._manual_command( + HEAD_UP, "Homing the cap head", "HeadUpWasNotSuccesful", "HeadDONE", timeout + ) + logger.info("[IntelliXcap96 %s] cap head homed", self.io.port) + + async def open_safety_door(self, timeout: float = 15.0) -> None: + """Open the safety door to reach into the instrument. + + Only available in manual recovery mode, and the instrument stays there + afterwards. :meth:`open_tray` opens the door as part of presenting the tray. + """ + await self._manual_command( + SAFETY_DOOR_OPEN, "Opening the safety door", "SafetyDoorWasNotSuccesful", "DoorDONE", timeout + ) + logger.info("[IntelliXcap96 %s] safety door open", self.io.port) diff --git a/pylabrobot/azenta/fluidx/intellixcap96_tests.py b/pylabrobot/azenta/fluidx/intellixcap96_tests.py new file mode 100644 index 00000000000..4bc38bf3506 --- /dev/null +++ b/pylabrobot/azenta/fluidx/intellixcap96_tests.py @@ -0,0 +1,679 @@ +import contextlib +import unittest +from typing import Iterator, List +from unittest.mock import AsyncMock, patch + +from pylabrobot.azenta.fluidx import ( + CartridgeProfile, + ExtendedStatus, + FluidXError, + FluidXIntelliXcap96, + get_error_message, + is_recoverable_error, +) + +ACK = "\x06" + + +class FakeXcapSerial: + """In-memory serial stand-in for the STX/ETX framed decapper protocol. + + ``script`` is a list of turns, one per ``write``. Each turn is the list of + frame payloads the device emits in response to that write; every payload is + queued wrapped in STX (0x02) .. ETX (0x03), exactly as the firmware frames it. + """ + + def __init__(self, script: List[List[str]]) -> None: + self.port = "FAKE" + self.written: List[str] = [] + self._script = list(script) + self._rx = bytearray() + + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def reset_input_buffer(self) -> None: + self._rx.clear() + + def get_read_timeout(self) -> float: + return 5.0 + + def set_read_timeout(self, timeout: float) -> None: + pass + + @contextlib.contextmanager + def temporary_timeout(self, timeout: float) -> Iterator[None]: + yield + + async def write(self, data: bytes) -> None: + self.written.append(data.decode("ascii").rstrip("\x03")) + turn = self._script.pop(0) if self._script else [] + for payload in turn: + self._rx += b"\x02" + payload.encode("ascii") + b"\x03" + + async def read(self, num_bytes: int = 1) -> bytes: + if not self._rx: + return b"" + out = bytes(self._rx[:num_bytes]) + del self._rx[:num_bytes] + return out + + +def status(word: str, echo: str = "a") -> List[str]: + """A full status reply: ACK, command echo, and the status word.""" + return [ACK, f"{echo}OK", word] + + +def query(command: str, payload: str) -> List[str]: + """A full query reply: ACK, command echo, and the data frame.""" + return [ACK, f"{command}OK", payload] + + +def extended( + caps_on_pins: bool = False, + cartridge_installed: bool = False, + estop_active: bool = False, +) -> List[str]: + """An extended status reply built from the flags a test cares about.""" + bits = ["0"] * 12 + bits[0] = "1" if caps_on_pins else "0" + bits[6] = "1" if cartridge_installed else "0" + bits[10] = "1" if estop_active else "0" + return query("e", "".join(bits)) + + +class TestIntelliXcap96(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + patcher = patch("pylabrobot.azenta.fluidx.intellixcap96.asyncio.sleep", new_callable=AsyncMock) + patcher.start() + self.addCleanup(patcher.stop) + + def _make(self, script: List[List[str]], auto_recover: bool = True) -> FluidXIntelliXcap96: + device = FluidXIntelliXcap96(port="FAKE", auto_recover=auto_recover) + device.io = FakeXcapSerial(script) # type: ignore[assignment] + return device + + def _written(self, device: FluidXIntelliXcap96) -> List[str]: + return device.io.written # type: ignore[attr-defined,no-any-return] + + # === Connection === + + async def test_setup_ok(self): + device = self._make([status("StatusOK")]) + await device.setup() + self.assertEqual(self._written(device), ["a"]) + + async def test_setup_busy_reports_engaged_estop(self): + device = self._make([status("StatusBUSY"), extended(estop_active=True)]) + with self.assertRaises(FluidXError) as ctx: + await device.setup() + self.assertIn("E-STOP is engaged", str(ctx.exception)) + + async def test_setup_busy_still_warns_when_estop_unreadable(self): + device = self._make([status("StatusBUSY")]) + with self.assertRaises(FluidXError) as ctx: + await device.setup() + self.assertIn("E-STOP", str(ctx.exception)) + + async def test_setup_error_reports_error_code(self): + device = self._make([status("StatusError"), query("8", "137")]) + with self.assertRaises(FluidXError) as ctx: + await device.setup() + self.assertEqual(ctx.exception.error_code, 137) + self.assertIn("Maximum recap attempts exceeded", str(ctx.exception)) + + async def test_setup_error_raises_without_error_code(self): + device = self._make([status("StatusError")]) + with self.assertRaises(FluidXError) as ctx: + await device.setup() + self.assertIsNone(ctx.exception.error_code) + + async def test_setup_manual_recovery_raises(self): + device = self._make([status("StatusMANUAL")]) + with self.assertRaises(FluidXError) as ctx: + await device.setup() + self.assertIn("manual recovery", str(ctx.exception)) + + async def test_request_status_returns_status_word(self): + device = self._make([status("StatusOK")]) + self.assertEqual(await device.request_status(), "StatusOK") + + # === Error codes === + + def test_get_error_message(self): + self.assertEqual( + get_error_message(145), + "Light curtain calibration max retries exceeded.", + ) + self.assertEqual(get_error_message(999), "Unknown IntelliXcap error code.") + + def test_fluidx_error_from_error_code(self): + error = FluidXError.from_error_code(145) + self.assertEqual(error.error_code, 145) + self.assertEqual( + str(error), + "IntelliXcap error 145: Light curtain calibration max retries exceeded.", + ) + + def test_recoverable_errors_are_the_ones_homing_clears(self): + self.assertTrue(is_recoverable_error(113)) + self.assertTrue(is_recoverable_error(144)) + self.assertFalse(is_recoverable_error(137)) + self.assertTrue(FluidXError.from_error_code(117).recoverable) + self.assertFalse(FluidXError.from_error_code(137).recoverable) + self.assertFalse(FluidXError(title="no code").recoverable) + + async def test_request_error_code_returns_latched_code(self): + device = self._make([query("8", "142")]) + self.assertEqual(await device.request_error_code(), 142) + self.assertEqual(self._written(device), ["8"]) + + async def test_request_error_code_is_none_when_clear(self): + device = self._make([query("8", "000")]) + self.assertIsNone(await device.request_error_code()) + + async def test_request_error_code_raises_on_garbage(self): + device = self._make([query("8", "nope")]) + with self.assertRaises(FluidXError): + await device.request_error_code() + + # === Queries === + + async def test_request_extended_status(self): + device = self._make([extended(caps_on_pins=True, cartridge_installed=True)]) + result = await device.request_extended_status() + self.assertTrue(result.caps_on_pins) + self.assertTrue(result.cartridge_installed) + self.assertFalse(result.estop_active) + self.assertFalse(result.time_for_service) + + async def test_caps_on_pins(self): + device = self._make([extended(caps_on_pins=True)]) + self.assertTrue(await device.caps_on_pins()) + + def test_extended_status_rejects_an_unexpected_width(self): + # The command list names 12 flags but shows an 11-character answer. Padding + # either end would guess at CAPS_ON_PINS, so the mismatch is surfaced. + with self.assertRaises(FluidXError) as ctx: + ExtendedStatus.from_raw("00000100000") + self.assertIn("11 bits, expected 12", str(ctx.exception)) + + def test_extended_status_rejects_a_non_bitmask(self): + with self.assertRaises(FluidXError): + ExtendedStatus.from_raw("10201") + + async def test_request_firmware_versions(self): + device = self._make([query("V", "0044,0014,0003")]) + versions = await device.request_firmware_versions() + self.assertEqual(versions.unit, "0044") + self.assertEqual(versions.touchscreen, "0014") + self.assertEqual(versions.light_curtain, "0003") + + async def test_request_cartridge_info(self): + device = self._make([query("N", "016,000123,00000000")]) + info = await device.request_cartridge_info() + self.assertEqual(info.profile, 16) + self.assertEqual(info.cycle_count, 123) + self.assertEqual(info.serial, "00000000") + + async def test_request_profile(self): + device = self._make([query("E", "16032")]) + profile = await device.request_profile() + self.assertEqual(profile.number, 16) + self.assertEqual(profile.communication_protocol, 0) + self.assertEqual(profile.decap_max_retry, 3) + self.assertEqual(profile.recap_max_retry, 2) + + def test_profile_rejects_a_malformed_reply(self): + with self.assertRaises(FluidXError): + CartridgeProfile.from_raw("160") + + # === Tray === + + async def test_open_tray_moves_then_idle(self): + device = self._make( + [ + status("StatusOK"), # _ensure_ready: not in error + [ACK, "fOK"], # f accepted + status("StatusBUSY"), # still moving + status("StatusOK"), # settled + ] + ) + await device.open_tray() + self.assertEqual(self._written(device), ["a", "f", "a", "a"]) + + async def test_open_tray_finishes_on_its_own_done_frame(self): + device = self._make([status("StatusOK"), [ACK, "fOK"], [ACK, "aOK", "OpenDONE"]]) + await device.open_tray() + self.assertEqual(self._written(device), ["a", "f", "a"]) + + async def test_open_tray_already_open_is_noop(self): + # CommandIgnore = tray already open = success; no status wait afterwards. + device = self._make([status("StatusOK"), [ACK, "fOK", "CommandIgnore"]]) + await device.open_tray() + self.assertEqual(self._written(device), ["a", "f"]) + + async def test_close_tray_moves_then_idle(self): + device = self._make( + [status("StatusOK"), [ACK, "gOK"], status("StatusBUSY"), status("StatusOK")] + ) + await device.close_tray() + self.assertEqual(self._written(device), ["a", "g", "a", "a"]) + + async def test_close_tray_preserves_caps_held_state(self): + device = self._make( + [status("StatusRECAP"), [ACK, "gOK"], status("StatusBUSY"), status("StatusRECAP")] + ) + await device.close_tray() + self.assertEqual(self._written(device), ["a", "g", "a", "a"]) + + async def test_step_tray_out_and_in(self): + device = self._make( + [ + status("StatusOK"), + [ACK, "sOK", "sOK"], + status("StatusOK"), + [ACK, "SOK", "SOK"], + ] + ) + await device.step_tray_out() + await device.step_tray_in() + self.assertEqual(self._written(device), ["a", "s", "a", "S"]) + + async def test_tray_move_out_of_range_raises(self): + device = self._make([status("StatusOK"), [ACK, "sOK", "sERROR"], query("8", "148")]) + with self.assertRaises(FluidXError) as ctx: + await device.step_tray_out() + self.assertEqual(ctx.exception.error_code, 148) + + async def test_tray_move_times_out_without_a_second_echo(self): + device = self._make([status("StatusOK"), [ACK, "sOK"]]) + with self.assertRaises(FluidXError) as ctx: + await device.step_tray_out(timeout=0.01) + self.assertIn("timed out", str(ctx.exception)) + + # === Homing and recovery === + + async def test_home_moves_then_idle(self): + device = self._make([[ACK, "ZOK"], status("StatusBUSY"), status("StatusOK")]) + await device.home() + self.assertEqual(self._written(device), ["Z", "a", "a"]) + + async def test_reset_error_recovers_manual_state_by_homing(self): + device = self._make( + [ + status("StatusMANUAL"), + [ACK, "ZOK"], + status("StatusBUSY"), + status("StatusOK"), + ] + ) + await device.reset_error() + self.assertEqual(self._written(device), ["a", "Z", "a", "a"]) + + async def test_reset_error_recovers_error_state_by_homing(self): + device = self._make( + [ + status("StatusError"), + [ACK, "ZOK"], + status("StatusBUSY"), + status("StatusOK"), + ] + ) + await device.reset_error() + self.assertEqual(self._written(device), ["a", "Z", "a", "a"]) + + async def test_reset_error_is_noop_when_healthy(self): + device = self._make([status("StatusOK")]) + await device.reset_error() + self.assertEqual(self._written(device), ["a"]) + + async def test_initialize_keeping_caps_on_pins(self): + device = self._make([status("StatusMANUAL"), [ACK, "zOK", "zDONE"]]) + await device.initialize_keeping_caps_on_pins() + self.assertEqual(self._written(device), ["a", "z"]) + + async def test_initialize_keeping_caps_on_pins_reports_a_home_failure(self): + device = self._make([status("StatusMANUAL"), [ACK, "zOK", "HomeERROR"], query("8", "156")]) + with self.assertRaises(FluidXError) as ctx: + await device.initialize_keeping_caps_on_pins() + self.assertEqual(ctx.exception.error_code, 156) + + async def test_initialize_keeping_caps_on_pins_requires_manual_mode(self): + device = self._make([status("StatusOK")]) + with self.assertRaises(FluidXError) as ctx: + await device.initialize_keeping_caps_on_pins() + self.assertIn("manual recovery mode", str(ctx.exception)) + self.assertEqual(self._written(device), ["a"]) + + async def test_operation_auto_recovers_from_latched_error(self): + device = self._make( + [ + status("StatusError"), # _ensure_ready: latched in error + [ACK, "ZOK"], # recovery home accepted + status("StatusBUSY"), # homing + status("StatusOK"), # homed + status("StatusOK"), # _ensure_ready re-check: clear + [ACK, "fOK"], # open accepted + status("StatusBUSY"), + status("StatusOK"), + ] + ) + await device.open_tray() + self.assertEqual( + self._written(device), + ["a", "Z", "a", "a", "a", "f", "a", "a"], + ) + + async def test_latched_error_raises_when_auto_recover_disabled(self): + device = self._make([status("StatusError"), query("8", "113")], auto_recover=False) + with self.assertRaises(FluidXError) as ctx: + await device.open_tray() + self.assertEqual(ctx.exception.error_code, 113) + self.assertEqual(self._written(device), ["a", "8"]) + + async def test_operation_blocked_during_manual_recovery(self): + device = self._make([status("StatusMANUAL")]) + with self.assertRaises(FluidXError) as ctx: + await device.open_tray() + self.assertIn("manual recovery", str(ctx.exception)) + self.assertEqual(self._written(device), ["a", "8"]) + + # === Decap / recap === + + async def test_decap_success(self): + device = self._make( + [ + status("StatusOK"), # precheck: not recapped, no error + [ACK, "hOK"], # h accepted + status("StatusBUSY"), + status("StatusRECAP"), # hardware terminal state: caps held + ] + ) + await device.decap() + self.assertEqual(self._written(device), ["a", "h", "a", "a"]) + + async def test_decap_blocked_when_already_decapped(self): + device = self._make([status("StatusRecap")]) + with self.assertRaises(FluidXError): + await device.decap() + + async def test_decap_reports_error_during_motion(self): + device = self._make([status("StatusOK"), [ACK, "hOK"], [ACK, "aOK", "DecapERROR"]]) + with self.assertRaises(FluidXError): + await device.decap() + + async def test_decap_decodes_error_code_when_firmware_includes_it(self): + device = self._make([status("StatusOK"), [ACK, "hOK"], [ACK, "aOK", "DecapERROR 145"]]) + with self.assertRaises(FluidXError) as ctx: + await device.decap() + self.assertEqual(ctx.exception.error_code, 145) + self.assertIn("Light curtain calibration max retries exceeded", str(ctx.exception)) + + async def test_decap_queries_the_error_code_when_the_reply_omits_it(self): + device = self._make( + [ + status("StatusOK"), + [ACK, "hOK"], + [ACK, "aOK", "DecapERROR"], + query("8", "114"), + ] + ) + with self.assertRaises(FluidXError) as ctx: + await device.decap() + self.assertEqual(ctx.exception.error_code, 114) + self.assertTrue(ctx.exception.recoverable) + self.assertIn("Invalid tube height", str(ctx.exception)) + self.assertEqual(self._written(device), ["a", "h", "a", "8"]) + + async def test_manual_halt_during_motion_fails_immediately(self): + device = self._make( + [status("StatusOK"), [ACK, "hOK"], status("StatusMANUAL"), query("8", "167")] + ) + with self.assertRaises(FluidXError) as ctx: + await device.decap() + self.assertEqual(ctx.exception.error_code, 167) + self.assertFalse(ctx.exception.recoverable) + + async def test_retry_decap_finishes_with_caps_held(self): + device = self._make( + [ + status("StatusRECAP"), + [ACK, "hOK"], + status("StatusBUSY"), + status("StatusRECAP"), + ] + ) + await device.retry_decap() + self.assertEqual(self._written(device), ["a", "Q", "a", "a"]) + + async def test_recap_blocked_when_not_decapped(self): + device = self._make([status("StatusDecap")]) + with self.assertRaises(FluidXError): + await device.recap() + + async def test_recap_success(self): + device = self._make( + [ + status("StatusRECAP"), # precheck: caps held + [ACK, "iOK"], # i accepted + status("StatusBUSY"), + status("StatusOK"), + ] + ) + await device.recap() + self.assertEqual(self._written(device), ["a", "i", "a", "a"]) + + async def test_waste_blocked_when_no_caps_are_held(self): + device = self._make([status("StatusOK")]) + with self.assertRaises(FluidXError): + await device.waste() + self.assertEqual(self._written(device), ["a"]) + + async def test_waste_success(self): + device = self._make( + [ + status("StatusRECAP"), # precheck: caps held + [ACK, "bOK"], # b accepted + status("StatusBUSY"), + status("StatusOK"), + ] + ) + await device.waste() + self.assertEqual(self._written(device), ["a", "b", "a", "a"]) + + # === Standby === + + async def test_standby_reaches_sleep(self): + device = self._make([status("StatusOK"), [ACK, "jOK"], status("StatusSLEEP")]) + await device.standby() + self.assertEqual(self._written(device), ["a", "j", "a"]) + + async def test_standby_is_noop_when_asleep(self): + device = self._make([status("StatusSLEEP")]) + await device.standby() + self.assertEqual(self._written(device), ["a"]) + + async def test_ready_noop_when_awake(self): + device = self._make([status("StatusOK")]) + await device.ready() + self.assertEqual(self._written(device), ["a"]) + + async def test_ready_wakes_from_sleep(self): + device = self._make( + [ + status("StatusSLEEP"), # request_status: asleep + [ACK, "kOK"], # k accepted + status("StatusBUSY"), # waking + status("StatusOK"), # awake + ] + ) + await device.ready() + self.assertEqual(self._written(device), ["a", "k", "a", "a"]) + + # === Cartridge === + + async def test_eject_cartridge(self): + device = self._make( + [ + extended(cartridge_installed=True), + status("StatusOK"), + [ACK, "cOK"], + status("StatusCAREJECT"), + ] + ) + await device.eject_cartridge() + self.assertEqual(self._written(device), ["e", "a", "c", "a"]) + + async def test_eject_cartridge_is_noop_without_a_cartridge(self): + device = self._make([extended(cartridge_installed=False)]) + await device.eject_cartridge() + self.assertEqual(self._written(device), ["e"]) + + async def test_eject_cartridge_blocked_while_caps_are_held(self): + device = self._make([extended(cartridge_installed=True, caps_on_pins=True)]) + with self.assertRaises(FluidXError): + await device.eject_cartridge() + self.assertEqual(self._written(device), ["e"]) + + async def test_load_cartridge_returns_the_profile(self): + device = self._make( + [extended(cartridge_installed=False), [ACK, "COK", "o16OK"], status("StatusOK")] + ) + self.assertEqual(await device.load_cartridge(), 16) + self.assertEqual(self._written(device), ["e", "C", "a"]) + + async def test_load_cartridge_is_noop_when_already_loaded(self): + device = self._make([extended(cartridge_installed=True)]) + self.assertIsNone(await device.load_cartridge()) + self.assertEqual(self._written(device), ["e"]) + + async def test_reset_cartridge_counter(self): + device = self._make([[ACK, "XOK", "CarResetDONE"]]) + await device.reset_cartridge_counter() + self.assertEqual(self._written(device), ["X"]) + + async def test_reset_cartridge_counter_requires_its_answer(self): + device = self._make([[ACK, "XOK"]]) + with self.assertRaises(FluidXError): + await device.reset_cartridge_counter() + + async def test_reset_cartridge_counter_is_idempotent(self): + device = self._make([[ACK, "XOK", "CommandIgnore"]]) + await device.reset_cartridge_counter() + self.assertEqual(self._written(device), ["X"]) + + async def test_load_cartridge_reads_the_profile_from_the_end_of_the_motion(self): + # onnOK is documented as an end-of-motion answer, not part of the ack. + device = self._make( + [ + extended(cartridge_installed=False), + [ACK, "COK"], + [ACK, "aOK", "o24OK", "StatusOK"], + ] + ) + self.assertEqual(await device.load_cartridge(), 24) + + async def test_load_cartridge_reports_a_missing_profile(self): + device = self._make( + [extended(cartridge_installed=False), [ACK, "COK", "ProfileLoadERROR"], query("8", "143")] + ) + with self.assertRaises(FluidXError) as ctx: + await device.load_cartridge() + self.assertEqual(ctx.exception.error_code, 143) + + # === Settings === + + async def test_set_error_detection_off_is_not_read_as_a_fault(self): + device = self._make([[ACK, "lOK", "ErrorDetectOFF"]]) + await device.set_error_detection_enabled(False) + self.assertEqual(self._written(device), ["l"]) + + async def test_set_error_detection_on(self): + device = self._make([[ACK, "LOK", "ErrorDetectON"]]) + await device.set_error_detection_enabled(True) + self.assertEqual(self._written(device), ["L"]) + + async def test_set_error_detection_is_idempotent(self): + device = self._make([[ACK, "LOK", "CommandIgnore"]]) + await device.set_error_detection_enabled(True) + self.assertEqual(self._written(device), ["L"]) + + async def test_set_dry_run_enabled(self): + device = self._make([[ACK, "dOK", "DryRunON"]]) + await device.set_dry_run_enabled(True) + self.assertEqual(self._written(device), ["d"]) + + async def test_set_dry_run_disabled(self): + device = self._make([[ACK, "DOK", "DryRunOFF"]]) + await device.set_dry_run_enabled(False) + self.assertEqual(self._written(device), ["D"]) + + async def test_set_dry_run_is_idempotent(self): + device = self._make([[ACK, "DOK", "CommandIgnore"]]) + await device.set_dry_run_enabled(False) + self.assertEqual(self._written(device), ["D"]) + + async def test_setting_raises_when_the_device_does_not_confirm(self): + device = self._make([[ACK, "dOK"]]) + with self.assertRaises(FluidXError): + await device.set_dry_run_enabled(True) + + async def test_set_safety_door_enabled(self): + device = self._make([[ACK, "+OK", "DoorONDONE"]]) + await device.set_safety_door_enabled(True) + self.assertEqual(self._written(device), ["+"]) + + async def test_set_safety_door_disabled(self): + device = self._make([[ACK, "-OK", "DoorOFFDONE"]]) + await device.set_safety_door_enabled(False) + self.assertEqual(self._written(device), ["-"]) + + async def test_set_safety_door_is_idempotent(self): + device = self._make([[ACK, "+OK", "CommandIgnore"]]) + await device.set_safety_door_enabled(True) + self.assertEqual(self._written(device), ["+"]) + + # === Manual recovery commands === + + async def test_eject_caps_requires_manual_mode(self): + device = self._make([status("StatusOK")]) + with self.assertRaises(FluidXError): + await device.eject_caps() + self.assertEqual(self._written(device), ["a"]) + + async def test_eject_caps(self): + # Manual recovery pins the status word to StatusMANUAL, so the answer frame + # is the only completion signal: no status polling here. + device = self._make([status("StatusMANUAL"), [ACK, "5OK", "EjectDONE"]]) + await device.eject_caps() + self.assertEqual(self._written(device), ["a", "5"]) + + async def test_eject_caps_reports_a_failure(self): + device = self._make([status("StatusMANUAL"), [ACK, "5OK", "EjectERROR"], query("8", "133")]) + with self.assertRaises(FluidXError) as ctx: + await device.eject_caps() + self.assertEqual(ctx.exception.error_code, 133) + + async def test_head_up(self): + device = self._make([status("StatusMANUAL"), [ACK, "6OK", "HeadDONE"]]) + await device.head_up() + self.assertEqual(self._written(device), ["a", "6"]) + + async def test_open_safety_door(self): + device = self._make([status("StatusMANUAL"), [ACK, "7OK", "DoorDONE"]]) + await device.open_safety_door() + self.assertEqual(self._written(device), ["a", "7"]) + + async def test_manual_command_times_out_without_an_answer(self): + device = self._make([status("StatusMANUAL"), [ACK, "6OK"]]) + with self.assertRaises(FluidXError) as ctx: + await device.head_up(timeout=0.01) + self.assertIn("timed out", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/azenta/xpeel.py b/pylabrobot/azenta/xpeel.py new file mode 100644 index 00000000000..7ee309c4f09 --- /dev/null +++ b/pylabrobot/azenta/xpeel.py @@ -0,0 +1,260 @@ +import logging +import time +from dataclasses import dataclass +from typing import List, Literal, Optional, Tuple + +try: + import serial # type: ignore + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e + +from pylabrobot.io.serial import Serial + + +class XPeel: + """Serial driver for the Azenta XPeel automated plate seal remover (RS-232). + + Owns the hardware connection and provides generic send/receive plus device-level operations + (status, reset, conveyor/elevator movement, tape, seal sensor). + """ + + BAUDRATE = 9600 + RESPONSE_TIMEOUT = 20.0 + + @dataclass(frozen=True) + class ErrorInfo: + code: int + description: str + + _ERROR_DEFINITIONS = { + 0: ErrorInfo(0, "No error"), + 1: ErrorInfo(1, "Conveyor motor stalled"), + 2: ErrorInfo(2, "Elevator motor stalled"), + 3: ErrorInfo(3, "Take up spool stalled"), + 4: ErrorInfo(4, "Seal not removed"), + 5: ErrorInfo(5, "Illegal command"), + 6: ErrorInfo(6, "No plate found (only when plate check is enabled)"), + 7: ErrorInfo(7, "Out of tape or tape broke"), + 8: ErrorInfo(8, "Parameters not saved"), + 9: ErrorInfo(9, "Stop button pressed while running"), + 10: ErrorInfo(10, "Seal sensor unplugged or broke"), + 20: ErrorInfo(20, "Less than 30 seals left on supply roll"), + 21: ErrorInfo(21, "Room for less than 30 seals on take-up spool"), + 51: ErrorInfo(51, "Emergency stop: cover open or hardware problem"), + 52: ErrorInfo(52, "Circuitry fault detected: remove power"), + } + + def __init__(self, port: str, timeout: Optional[float] = None): + if not HAS_SERIAL: + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) + super().__init__() + self.logger = logging.getLogger(__name__) + self.port = port + self.response_timeout = timeout if timeout is not None else self.RESPONSE_TIMEOUT + + self.io = Serial( + human_readable_device_name="XPeel", + port=self.port, + baudrate=self.BAUDRATE, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=self.response_timeout, + write_timeout=self.response_timeout, + rtscts=False, + ) + + async def setup(self): + await self.io.setup() + + async def stop(self): + await self.io.stop() + + @classmethod + def describe_error(cls, code: int) -> str: + info = cls._ERROR_DEFINITIONS.get(code) + if info: + return info.description + return f"Unknown error code {code}" + + @classmethod + def parse_ready_line(cls, line: str): + if not line.startswith("*ready:"): + return None + try: + parts = line.split(":")[1].split(",") + code = int(parts[0]) + return code, cls.describe_error(code) + except Exception: + return None + + async def send_command( + self, cmd, expect_ack=False, wait_for_ready=False, clear_buffer=True + ) -> List[str]: + full_cmd = cmd if cmd.endswith("\r\n") else f"{cmd}\r\n" + + self.logger.debug("Sending command: %s", full_cmd.strip()) + if clear_buffer: + await self.io.reset_input_buffer() + await self.io.write(full_cmd.encode("ascii")) + + responses: List[str] = [] + start = time.time() + while time.time() - start < self.response_timeout: + raw = await self.io.readline() + line = raw.decode("ascii", errors="ignore").strip() + if not line: + continue + + display_line = line + if line.startswith("*ready:"): + parsed = self.parse_ready_line(line) + if parsed: + code, desc = parsed + display_line = f"{line} [{desc}]" + + responses.append(display_line) + self.logger.info("Received: %s", display_line) + + if line.startswith("*ack"): + if not wait_for_ready: + break + continue + + if wait_for_ready and line.startswith("*ready"): + break + + if not wait_for_ready and not expect_ack: + break + + if time.time() - start >= self.response_timeout: + self.logger.warning( + "Timed out waiting for response to %s after %.2fs", + full_cmd.strip(), + self.response_timeout, + ) + + return responses + + async def request_status(self) -> Tuple[int, int, int]: + """Request instrument status; returns three error codes.""" + resp = await self.send_command("*stat") + return tuple([int(x) for x in resp[-1].split(":")[1].split(",")]) # type: ignore + + async def request_version(self): + """Request firmware version.""" + return await self.send_command("*version") + + async def reset(self): + """Request reset.""" + return await self.send_command("*reset", expect_ack=True, wait_for_ready=True) + + async def seal_check(self) -> Literal["seal_detected", "no_seal", "plate_not_detected"]: + """Check for seal presence.""" + resp = await self.send_command("*sealcheck", expect_ack=True, wait_for_ready=True) + ready_line = resp[-1] + parsed = self.parse_ready_line(ready_line) + if parsed is None: + raise RuntimeError(f"Could not parse ready line: {ready_line}") + code, _ = parsed + if code == 0: + return "no_seal" + if code == 4: + return "seal_detected" + if code == 6: + return "plate_not_detected" + raise RuntimeError( + f"Unexpected seal check code: {code}, interpreted as: {self.describe_error(code)}" + ) + + async def request_tape_remaining(self): + """Query remaining tape. Returns (supply_remaining, takeup_remaining) in number of deseals.""" + resp = await self.send_command("*tapeleft", expect_ack=True, wait_for_ready=True) + tape_line = resp[-1] + parts = tape_line.split(":")[1].split(",") + supply_remaining = int(parts[0]) * 10 + takeup_remaining = int(parts[1]) * 10 + return supply_remaining, takeup_remaining + + async def enable_plate_check(self, enabled=True): + """Enable or disable plate presence check.""" + flag = "y" if enabled else "n" + return await self.send_command(f"*platecheck:{flag}", expect_ack=True, wait_for_ready=True) + + async def request_seal_sensor_status(self): + """Get seal sensor threshold value (0-999).""" + return await self.send_command("*sealstat", expect_ack=True, wait_for_ready=True) + + async def set_seal_threshold_upper(self, value: int): + """Set the upper seal detected threshold (0-999).""" + if not 0 <= value <= 999: + raise ValueError("value must be between 0 and 999") + return await self.send_command(f"*sealhigher:{value:03d}", expect_ack=True, wait_for_ready=True) + + async def set_seal_threshold_lower(self, value: int): + """Set the lower seal detected threshold (0-999).""" + if not 0 <= value <= 999: + raise ValueError("value must be between 0 and 999") + return await self.send_command(f"*seallower:{value:03d}", expect_ack=True, wait_for_ready=True) + + async def move_conveyor_out(self): + """Move conveyor out.""" + return await self.send_command("*moveout", expect_ack=True, wait_for_ready=True) + + async def move_conveyor_in(self): + """Move conveyor in.""" + return await self.send_command("*movein", expect_ack=True, wait_for_ready=True) + + async def move_elevator_down(self): + """Move elevator down.""" + return await self.send_command("*movedown", expect_ack=True, wait_for_ready=True) + + async def move_elevator_up(self): + """Move elevator up.""" + return await self.send_command("*moveup", expect_ack=True, wait_for_ready=True) + + async def advance_tape(self): + """Advance tape / move spool.""" + return await self.send_command("*movespool", expect_ack=True, wait_for_ready=True) + + async def peel( + self, + begin_location: Literal[-2, 0, 2, 4] = 0, + fast: bool = False, + adhere_time: float = 2.5, + ): + """Run an automated de-seal cycle. + + Args: + begin_location: Starting roller position offset in mm. Must be one of -2, 0, 2, or 4. Default 0. + fast: If True, uses faster peel speed. Default False. + adhere_time: Time in seconds for the roller to press on the seal before peeling. Must be one of 2.5, 5.0, 7.5, or 10.0. Default 2.5. + """ + if adhere_time not in {2.5, 5.0, 7.5, 10.0}: + raise ValueError("adhere_time must be one of: 2.5, 5.0, 7.5, 10.0") + if begin_location not in {-2, 0, 2, 4}: + raise ValueError("begin_location must be one of: -2, 0, 2, 4") + + parameter_set = { + (-2, True): 1, + (-2, False): 2, + (0, True): 3, + (0, False): 4, + (2, True): 5, + (2, False): 6, + (4, True): 7, + (4, False): 8, + }.get((begin_location, fast), 9) + + cmd = f"*xpeel:{parameter_set}{adhere_time}" + return await self.send_command(cmd, expect_ack=True, wait_for_ready=True) + + async def restart(self): + """Request restart with full homing sequence.""" + return await self.send_command("*restart", expect_ack=True, wait_for_ready=True) diff --git a/pylabrobot/barcode_scanners/__init__.py b/pylabrobot/barcode_scanners/__init__.py index befd981f9a9..1f0dd6e06a7 100644 --- a/pylabrobot/barcode_scanners/__init__.py +++ b/pylabrobot/barcode_scanners/__init__.py @@ -1,3 +1,10 @@ -from .backend import BarcodeScannerBackend, BarcodeScannerError -from .barcode_scanner import BarcodeScanner -from .keyence import KeyenceBarcodeScannerBackend +import warnings + +warnings.warn( + "Importing from pylabrobot.barcode_scanners is deprecated. " + "Use pylabrobot.legacy.barcode_scanners instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.barcode_scanners import * # noqa: F401,F403,E402 diff --git a/pylabrobot/big_bear/__init__.py b/pylabrobot/big_bear/__init__.py new file mode 100644 index 00000000000..d9424eab9e2 --- /dev/null +++ b/pylabrobot/big_bear/__init__.py @@ -0,0 +1 @@ +from .orbital_shaker import BigBearError, BigBearOrbitalShaker diff --git a/pylabrobot/big_bear/orbital_shaker.py b/pylabrobot/big_bear/orbital_shaker.py new file mode 100644 index 00000000000..073dc66c044 --- /dev/null +++ b/pylabrobot/big_bear/orbital_shaker.py @@ -0,0 +1,272 @@ +import asyncio +import logging +from typing import Literal, Optional + +from pylabrobot.io.serial import Serial + +Sequence = Literal["CW", "CCW"] + +logger = logging.getLogger(__name__) + + +# Line terminator: commands and replies are carriage-return terminated. +CR = "\r" + +# Reply returned when a command is rejected or the shaker cannot be reached. +ERROR_REPLY = "?:" + +MIN_RPM = 60 +MAX_RPM = 3570 +MIN_ACCELERATION = 1 +MAX_ACCELERATION = 10 +MIN_SHAKERS = 1 +MAX_SHAKERS = 16 + + +class BigBearError(Exception): + """Exceptions raised by a BigBear orbital shaker.""" + + def __init__(self, title: str, message: Optional[str] = None) -> None: + self.title = title + self.message = message + + def __str__(self) -> str: + return f"{self.title}: {self.message}" if self.message else self.title + + +class BigBearOrbitalShaker: + """BigBear orbital shaker. + + A daisy-chained orbital shaker: a single serial line drives up to 16 shaker + positions ("nests"), each addressed independently by a 1-based id sent as a + hexadecimal digit. + + Serial settings: + 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake, "\\r" + terminator. + + Commands (each addressed command is "@" followed by the command chars, + where is the nest number in hex; a value-setting command is followed by + a "@?" read-back query): + + :: + + U enter daisy-chain mode (unaddressed) + ~ report the number of shakers on the chain (unaddressed) + @V set speed (60..3570 rpm) + @A set acceleration (1..10, device scale) + @+ set direction clockwise + @- set direction counter-clockwise + @G start shaking + @S stop shaking + @F find home + @Q poll status (reply is read back) + @Y/X/Z serial-number fields + + A rejected command or a comms failure replies "?:". + + Not verified: has NOT been tested against hardware in PyLabRobot. A warning is + emitted at setup. + """ + + def __init__( + self, + port: str, + num_shakers: int = 1, + timeout: float = 10.0, + command_delay: float = 0.1, + daisy_chain_settle: float = 5.0, + stop_settle: float = 6.0, + ): + if not MIN_SHAKERS <= num_shakers <= MAX_SHAKERS: + raise ValueError(f"num_shakers must be {MIN_SHAKERS}..{MAX_SHAKERS}") + self.num_shakers = num_shakers + self.command_delay = command_delay + self.daisy_chain_settle = daisy_chain_settle + self.stop_settle = stop_settle + self.io = Serial( + human_readable_device_name="BigBear Orbital Shaker", + port=port, + baudrate=9600, + bytesize=8, + parity="N", + stopbits=1, + timeout=timeout, + ) + + async def setup(self) -> None: + logger.warning( + "BigBearOrbitalShaker has NOT been tested against hardware in PyLabRobot. " + "Please make a PR to remove this message if you have verified it on your hardware." + ) + await self.io.setup() + await self._enter_daisy_chain() + await self._check_num_shakers() + logger.info("[BigBear %s] connected: num_shakers=%d", self.io.port, self.num_shakers) + + async def stop(self) -> None: + """Stop all shakers and close the serial connection.""" + try: + await self.stop_all() + finally: + await self.io.stop() + + # === Command layer === + + @staticmethod + def _address(device_id: int) -> str: + return "@" + format(device_id, "X") + + def _validate_device_id(self, device_id: int) -> None: + if not 1 <= device_id <= self.num_shakers: + raise ValueError(f"device_id must be 1..{self.num_shakers}") + + async def _send(self, command: str) -> None: + """Write a command and its terminator, then pace the bus.""" + await self.io.write((command + CR).encode("ascii")) + logger.debug("[BigBear] send: %s", command) + await asyncio.sleep(self.command_delay) + + async def _read_reply(self, timeout: Optional[float] = None) -> str: + """Read one CR-terminated reply, skipping empty lines. + + Returns the trimmed reply, or "" if the device sends nothing within the + serial read timeout. + """ + original = self.io.get_read_timeout() + if timeout is not None: + self.io.set_read_timeout(timeout) + try: + buf = bytearray() + while True: + char = await self.io.read(1) + if char == b"": # read timed out + break + if char == b"\r": + if buf: + break + continue # skip a bare terminator / empty line + if char == b"\n": + continue + buf += char + finally: + if timeout is not None: + self.io.set_read_timeout(original) + reply = buf.decode("ascii", errors="replace").strip() + logger.debug("[BigBear] recv: %s", reply) + return reply + + async def _poll(self, device_id: int, read_timeout: Optional[float] = None) -> str: + """Issue the status poll ("Q") for a nest and return its reply.""" + await self._send(self._address(device_id) + "Q") + return await self._read_reply(timeout=read_timeout) + + # === Parameters === + + async def _set_speed(self, device_id: int, rpm: int) -> None: + if not MIN_RPM <= rpm <= MAX_RPM: + raise ValueError(f"rpm must be {MIN_RPM}..{MAX_RPM}") + address = self._address(device_id) + await self._send(f"{address}V{rpm}") + await self._send(f"{address}?V{rpm}") + await self._read_reply() + + async def _set_acceleration(self, device_id: int, acceleration: int) -> None: + if not MIN_ACCELERATION <= acceleration <= MAX_ACCELERATION: + raise ValueError(f"acceleration must be {MIN_ACCELERATION}..{MAX_ACCELERATION}") + address = self._address(device_id) + await self._send(f"{address}A{acceleration}") + await self._send(f"{address}?A{acceleration}") + await self._read_reply() + + async def _set_sequence(self, device_id: int, sequence: Sequence) -> None: + if sequence not in ("CW", "CCW"): + raise ValueError('sequence must be "CW" or "CCW"') + address = self._address(device_id) + direction = "+" if sequence == "CW" else "-" + await self._send(f"{address}{direction}") + await self._send(f"{address}?{direction}") + await self._read_reply() + + # === Public API === + + async def start_shaking( + self, + rpm: int, + acceleration: int = 1, + sequence: Sequence = "CW", + device_id: int = 1, + ) -> None: + """Set the parameters for a nest and start it shaking. + + Args: + rpm: shaking speed (60..3570). + acceleration: acceleration on the device's 1..10 scale. + sequence: rotation direction, "CW" (clockwise) or "CCW" (counter-clockwise). + device_id: nest to address (1..num_shakers). + """ + self._validate_device_id(device_id) + await self._set_acceleration(device_id, acceleration) + await self._set_speed(device_id, rpm) + await self._set_sequence(device_id, sequence) + await self._send(self._address(device_id) + "G") + if await self._poll(device_id) == ERROR_REPLY: + raise BigBearError(title="Starting the shaker failed", message=f"nest {device_id}") + logger.info("[BigBear %s] nest %d shaking at %d rpm", self.io.port, device_id, rpm) + + async def stop_shaking(self, device_id: int = 1) -> None: + """Stop shaking on a single nest.""" + self._validate_device_id(device_id) + await self._send(self._address(device_id) + "S") + if await self._poll(device_id, read_timeout=self.stop_settle) == ERROR_REPLY: + raise BigBearError(title="Stopping the shaker failed", message=f"nest {device_id}") + logger.info("[BigBear %s] nest %d stopped", self.io.port, device_id) + + async def stop_all(self) -> None: + """Stop shaking on every nest on the chain.""" + for device_id in range(1, self.num_shakers + 1): + await self._send(self._address(device_id) + "S") + await self._poll(device_id, read_timeout=self.stop_settle) + + async def find_home(self, device_id: int = 1) -> None: + """Home a single nest.""" + self._validate_device_id(device_id) + await self._send(self._address(device_id) + "F") + await self._poll(device_id) + logger.info("[BigBear %s] nest %d homed", self.io.port, device_id) + + async def home_all(self) -> None: + """Home every nest on the chain.""" + for device_id in range(1, self.num_shakers + 1): + await self._send(self._address(device_id) + "F") + await self._poll(device_id) + + async def get_serial_number(self, device_id: int = 1) -> str: + """Read the serial-number fields (Y, X, Z) of a nest and join them. + + The reply format is device-defined; the raw fields are returned as-is. + """ + self._validate_device_id(device_id) + address = self._address(device_id) + fields = [] + for command in ("Y", "X", "Z"): + await self._send(address + command) + fields.append(await self._read_reply()) + return "".join(fields) + + # === Setup helpers === + + async def _enter_daisy_chain(self) -> None: + await self._send("U") + await asyncio.sleep(self.daisy_chain_settle) + await self._read_reply() + + async def _check_num_shakers(self) -> None: + await self._send("U") + await self._send("~") + reply = await self._read_reply() + if str(self.num_shakers) not in reply: + raise BigBearError( + title="Shaker count mismatch", + message=f"expected {self.num_shakers}, device reported {reply!r}", + ) diff --git a/pylabrobot/brooks/__init__.py b/pylabrobot/brooks/__init__.py new file mode 100644 index 00000000000..f45ae3238b8 --- /dev/null +++ b/pylabrobot/brooks/__init__.py @@ -0,0 +1 @@ +from .precise_flex import PreciseFlex diff --git a/pylabrobot/brooks/precise_flex/__init__.py b/pylabrobot/brooks/precise_flex/__init__.py new file mode 100644 index 00000000000..533368e1b8b --- /dev/null +++ b/pylabrobot/brooks/precise_flex/__init__.py @@ -0,0 +1,58 @@ +"""Brooks PreciseFlex robots. + +Why one package for the family - every PreciseFlex arm runs the same Guidance/TCS controller and +speaks the same GPL command protocol, DataIDs, and error codes, so they share the bulk of this +driver. They differ only in kinematics (per geometry, e.g. the c10's R-P-R-R joint order, the c8A's +six axes) and gripper, which are handled by per-model device classes and per-geometry kinematics +modules within the package. Grouping by the shared controller keeps that common driver in one place +rather than duplicated per arm model. + +Scope - the PreciseFlex robot line. Implemented: + +- PreciseFlex 400 (PF400) +- PreciseFlex 3400 (PF3400) + +To be added here: + +- PreciseFlex 100 / 1400 (PF100 / PF1400) +- c-series: c3, c5, c8A, c10 +- direct-drive: DD4, DD6 +- linear rail + +Everything here is PreciseFlex-specific, including the TCS controller protocol (``tcs_modules``), +``error_codes``, and the controller DataIDs (``data_ids``) - the PreciseFlex line is the only user of +the Guidance/TCS controller, so they live with it. A future, genuinely different Brooks device family +would get its own sibling package under ``brooks/``, and anything shared would be lifted up then. + +Re-exports the public classes so ``from pylabrobot.brooks.precise_flex import PreciseFlex400`` keeps +working. +""" + +from pylabrobot.brooks.precise_flex.config import ( + Axis, + PreciseFlexConfiguration, +) +from pylabrobot.brooks.precise_flex.errors import OutOfRangeOfMotionError, PreciseFlexError +from pylabrobot.brooks.precise_flex.kinematics import ( + ElbowOrientation, + PreciseFlexCartesianPose, + WorkEnvelope, + Wrist, +) +from pylabrobot.brooks.precise_flex.precise_flex import ( + MotionProfile, + PreciseFlex, +) + +__all__ = [ + "Axis", + "ElbowOrientation", + "MotionProfile", + "OutOfRangeOfMotionError", + "PreciseFlex", + "PreciseFlexCartesianPose", + "PreciseFlexConfiguration", + "PreciseFlexError", + "WorkEnvelope", + "Wrist", +] diff --git a/pylabrobot/brooks/precise_flex/config.py b/pylabrobot/brooks/precise_flex/config.py new file mode 100644 index 00000000000..70caecd7a0e --- /dev/null +++ b/pylabrobot/brooks/precise_flex/config.py @@ -0,0 +1,124 @@ +"""Per-arm configuration resolved from the controller during setup, and the axis enumeration. + +The identity, limit, and envelope fields are read from the controller once at setup into a single +immutable `PreciseFlexConfiguration` record; the kinematics/flags tier is supplied or derived. The +backend holds it as `Optional[PreciseFlexConfiguration]` (None pre-setup). +""" + +import dataclasses +from dataclasses import dataclass +from enum import IntEnum +from typing import Dict, Literal + +from pylabrobot.brooks.precise_flex.kinematics import JointPose + +from . import kinematics +from .kinematics import WorkEnvelope + +# -- axis addressing ------------------------------------------------------- + + +class Axis(IntEnum): + BASE = 1 + SHOULDER = 2 + ELBOW = 3 + WRIST = 4 + GRIPPER = 5 + RAIL = 6 + + +# --------------------------------------------------------------------------- +# Configuration - resolved once at setup +# --------------------------------------------------------------------------- + + +# -- device configuration -------------------------------------------------- + + +@dataclass(frozen=True) +class PreciseFlexConfiguration: + """Device configuration resolved once at setup; immutable afterwards. + + The identity/limit/envelope fields are read from the controller (`pd ` + via ``request_parameter`` and the ``version`` command). The kinematics/flags + tier is supplied at construction or derived: link lengths are not on the arm, + ``has_rail`` comes from the joint set, ``is_dual_gripper`` from the axis_mask + ``&H80`` bit, ``is_vision_gripper`` from the model name, and ``reach_class`` from the + controller-read link lengths. + """ + + # --- identity / version (DataIDs 100-110, 2002, 116; version command) --- + manufacturer: str + controller_model: str + hardware_version: str + gpl_version: str + controller_serial: str + robot_name: str + robot_type: int + tcs_version: str + modules: tuple + # --- axes / limits / motion envelope --- + num_axes: int + extra_axes: int + axis_mask: int + soft_limits: Dict[Axis, tuple] + hard_limits: Dict[Axis, tuple] + # Effective per-joint maxima (reference x the global percent cap, already applied). + max_joint_speed: Dict[Axis, float] + max_joint_acceleration: Dict[Axis, float] + max_joint_deceleration: Dict[Axis, float] + max_cartesian_speed: float + max_cartesian_acceleration: float + power_state: int + # --- supplied / derived --- + kinematics: "kinematics.PF400Params" = dataclasses.field(default_factory=kinematics.PF400Params) + kinematics_source: Literal["device", "provided", "default"] = "default" + has_rail: bool = False + is_dual_gripper: bool = False + is_vision_gripper: bool = False + # "unknown" if the controller-read link lengths match neither known arm; defaults to "extended" + # to match the default PF400Params (the extended/XR link lengths) + reach_class: Literal["standard", "extended", "unknown"] = "extended" + + @property + def gripper_width_range(self) -> tuple: + return self.soft_limits[Axis.GRIPPER] + + @property + def z_range(self) -> tuple: + return self.soft_limits[Axis.BASE] + + @property + def work_envelope(self) -> WorkEnvelope: + """Reachable tool-tip annulus, swept from the shoulder/elbow soft limits. + + Sweeps the two planar joints across their soft-limit range (Z held constant - + it is an independent axis on a SCARA), takes the base->wrist radius at each + sample, and brackets it by +/- the tool length (the wrist can orient the tool + radially either way). This respects the joint limits rather than assuming full + extension, so the outer radius is the real reach, not l1 + l2 + tool. + """ + wrist_only = dataclasses.replace(self.kinematics, gripper_length=0.0) + tool = self.kinematics.gripper_length + sh_lo, sh_hi = self.soft_limits[Axis.SHOULDER] + el_lo, el_hi = self.soft_limits[Axis.ELBOW] + steps = 60 + outer, inner = 0.0, float("inf") + for i in range(steps + 1): + shoulder = sh_lo + (sh_hi - sh_lo) * i / steps + for j in range(steps + 1): + elbow = el_lo + (el_hi - el_lo) * j / steps + joints: JointPose = { + Axis.BASE: 0.0, + Axis.SHOULDER: shoulder, + Axis.ELBOW: elbow, + Axis.WRIST: 0.0, + Axis.GRIPPER: 0.0, + Axis.RAIL: 0.0, + } + wrist = kinematics.fk(joints, wrist_only).location + radius = (wrist.x * wrist.x + wrist.y * wrist.y) ** 0.5 + outer = max(outer, radius + tool) + inner = min(inner, abs(radius - tool)) + zmin, zmax = self.z_range + return WorkEnvelope(inner=inner, outer=outer, zmin=zmin, zmax=zmax) diff --git a/pylabrobot/brooks/precise_flex/confirmed_firmware_versions.py b/pylabrobot/brooks/precise_flex/confirmed_firmware_versions.py new file mode 100644 index 00000000000..cdf443d92a5 --- /dev/null +++ b/pylabrobot/brooks/precise_flex/confirmed_firmware_versions.py @@ -0,0 +1,108 @@ +"""PreciseFlex software stacks confirmed with this driver. + +This tracks the digital setup only - the GPL firmware, the TCS app, and the loaded +TCS modules - not the physical configuration (links, rail, gripper), which is +discovered into ``PreciseFlexConfiguration``. At setup the driver checks the +discovered stack here: + +- ``SUPPORTED_ROBOT_TYPES`` gates the client-side kinematics. This driver's FK/IK + is the PreciseFlex 400 two-link SCARA geometry, so other models would get + silently-wrong joint targets and are flagged. +- ``CONFIRMED_FIRMWARE_VERSIONS`` is the set of full (model, GPL, TCS, module-set) + software stacks validated against this driver; an unlisted one logs a warning + asking for a report so it can be added. + +(Which modules the driver *requires* lives in ``tcs_modules.py``, a separate concern.) + +Version strings keep the name and version but drop the trailing build date, which +varies by build without changing behaviour. +""" + +import re +from dataclasses import dataclass + +# -- supported models & confirmed stacks ----------------------------------- + +# robot_type (DataID 116) -> model name, for the models whose kinematics this driver implements. +SUPPORTED_ROBOT_TYPES = { + 12: "PreciseFlex 400", +} + + +@dataclass(frozen=True) +class ConfirmedFirmware: + """A full software stack validated against this driver (build dates stripped).""" + + robot_type: int + gpl_version: str # e.g. "GPL 5.1D4" + tcs_version: str # e.g. "TCP Command Server 3.0D4" + modules: tuple # one "name version" per loaded TCS module + + +CONFIRMED_FIRMWARE_VERSIONS = frozenset( + [ + ConfirmedFirmware( + robot_type=12, + gpl_version="GPL 5.1D4", + tcs_version="TCP Command Server 3.0D4", + modules=( + "IntelliGuide 1.0", + "Load-Save Module 3.0B2", + "PARobot Auto Center Module 3.0D3", + "PARobot Module 3.0D4", + "SSGrip Module 3.0D4", + ), + ), + ] +) + +# -- checks & formatting --------------------------------------------------- + +# Trailing build date (e.g. "10-25-2024" or "Apr 25 2025") and anything after it. +_DATE = re.compile(r",?\s*(\d{1,2}[-/]\d{1,2}[-/]\d{2,4}|[A-Za-z]{3,9}\.?\s+\d{1,2},?\s+\d{4}).*$") + + +def strip_build_date(version: str) -> str: + """Reduce a version string to its name + version, dropping the build date.""" + return _DATE.sub("", version).strip().rstrip(",").strip() + + +def is_supported_model(robot_type: int) -> bool: + """Whether this driver's kinematics cover the given robot_type (DataID 116).""" + return robot_type in SUPPORTED_ROBOT_TYPES + + +def is_confirmed(robot_type: int, gpl_version: str, tcs_version: str, modules: tuple) -> bool: + """Whether the full (model, GPL, TCS, module-set) combination has been validated.""" + target = ( + robot_type, + strip_build_date(gpl_version), + strip_build_date(tcs_version), + tuple(sorted(strip_build_date(m) for m in modules)), + ) + return any( + ( + c.robot_type, + strip_build_date(c.gpl_version), + strip_build_date(c.tcs_version), + tuple(sorted(strip_build_date(m) for m in c.modules)), + ) + == target + for c in CONFIRMED_FIRMWARE_VERSIONS + ) + + +def suggest_entry(robot_type: int, gpl_version: str, tcs_version: str, modules: tuple) -> str: + """Format a discovered stack as a ``ConfirmedFirmware(...)`` literal to paste into + ``CONFIRMED_FIRMWARE_VERSIONS`` (build dates stripped, modules sorted).""" + module_lines = ",\n ".join(f'"{m}"' for m in sorted(strip_build_date(m) for m in modules)) + return ( + " ConfirmedFirmware(\n" + f" robot_type={robot_type},\n" + f' gpl_version="{strip_build_date(gpl_version)}",\n' + f' tcs_version="{strip_build_date(tcs_version)}",\n' + " modules=(\n" + f" {module_lines},\n" + " ),\n" + " )," + ) diff --git a/pylabrobot/brooks/precise_flex/data_ids.py b/pylabrobot/brooks/precise_flex/data_ids.py new file mode 100644 index 00000000000..98c076b116e --- /dev/null +++ b/pylabrobot/brooks/precise_flex/data_ids.py @@ -0,0 +1,99 @@ +"""PreciseFlex controller parameter-database identifiers (DataIDs). + +The Guidance controller exposes a large numbered parameter database, read with +the TCS ``pd `` command (wrapped by ``PreciseFlexArmBackend.request_parameter``). +Naming the IDs here keeps configuration reads self-describing rather than bare +numbers, and gives a single place to add more as the database is mapped. It also +holds enums for the *values* of selected items (e.g. ``PowerState`` for the +power/system-state word) so a reply can be decoded, not just located. +""" + +from enum import IntEnum + + +class DataID(IntEnum): + """Controller parameter-database items, read via ``request_parameter`` (``pd``). + + Each item's reply shape is noted below: a single value, descriptive text, or an + array with one element per axis (parse with ``_parse_per_axis``). + """ + + # Identity / state - single value or descriptive text. + MANUFACTURER = 100 + CONTROLLER_MODEL = 101 + HARDWARE_VERSION = 102 + GPL_VERSION = 103 + SOFTWARE_VERSION = 104 # 104-108: software version / revision / edit / date / qualifier + SOFTWARE_REVISION = 105 + SOFTWARE_EDIT = 106 + SOFTWARE_DATE = 107 + SOFTWARE_QUALIFIER = 108 + CONTROLLER_NAME = 109 # free-text controller name + CONTROLLER_SERIAL = 110 + ROBOT_TYPE = 116 + SAFETY_MODE = 117 + ROBOT_POWER_ON_HOURS = 136 # cumulative robot (motor) power-on hours + SERVO_NODE_ID = 151 # servo network node id (NOT the controller serial) + AUTO_EXECUTE_STATE = 230 # auto-execution state word (pairs with POWER_STATE) + POWER_STATE = 234 + RESET_FATAL_ERROR = 247 # set to 1 to clear a latched fatal/severe error blocking power-on + ROBOT_HOMED = 2800 # 1 = all axes homed; 0 = not homed (commanded motion blocked) + NUM_AXES = 2000 + ROBOT_NAME = 2002 + AXIS_MASK = 2003 + EXTRA_AXES = 2004 + # Network. + LOCAL_IP_ADDRESS = 420 + # Per-axis arrays - one value per joint (e.g. speed differs J1..J5). + REFERENCE_SPEED = 2700 + REFERENCE_ACCEL = 2702 + HARD_LIMIT_MAX = 16075 + HARD_LIMIT_MIN = 16076 + SOFT_LIMIT_MAX = 16077 + SOFT_LIMIT_MIN = 16078 + ROBOT_SERIAL_NUMBER = 16000 # the robot's serial (distinct from CONTROLLER_SERIAL, 110) + # Kinematic geometry. LINK_LENGTHS is per-axis (l1 at the shoulder, l2 at the + # elbow); TOOL_OFFSET is a wrist-frame (x, y, z) transform, z = wrist->TCP. + LINK_LENGTHS = 16050 + TOOL_OFFSET = 16051 + PAYLOAD_FEEDFORWARD_PERCENT = 16071 # dynamic feedforward default payload % + # Cartesian reference - (translation, rotation, ...), not per-joint. + REFERENCE_CARTESIAN_SPEED = 2701 + REFERENCE_CARTESIAN_ACCEL = 2703 + # Global motion caps - one percentage applied to the whole profile (all joints + # the same); per-joint maxima come from REFERENCE_* x these. + MAX_SPEED_PERCENT = 2704 + MAX_ACCEL_PERCENT = 2705 + MAX_DECEL_PERCENT = 2706 + + +class PowerState(IntEnum): + """Values of the controller power/system-state word - the ``sysState`` command, equal to + ``Controller.PowerState`` (== ``DataID.POWER_STATE``, 234). Read via + ``PreciseFlexDriver.request_system_state``. + + ``OFF_HARD_ESTOP`` (15) means a hard E-stop is engaged; ``ON_ATTACHED`` (21) is the normal + running state. Values 25-27 (master/slave and CANopen modes) are unused on this controller. + """ + + POWERING_OFF = 3 + POWERING_OFF_AFTER_FAULT = 4 + OFF_FAULT_MUST_CLEAR = 5 + OFF_WAITING_HARDWARE_ENABLE = 6 + OFF_WAITING_ENABLE_SIGNAL = 7 + COMING_UP_ENABLING_AMPLIFIERS = 8 + ON_COMMUTATING = 9 + COMING_UP_ENABLING_SERVOS = 10 + ON_IDLE = 11 + ON_AUTO_EXECUTION = 12 + OFF_HARD_ESTOP = 15 + COMING_UP_SAFETY_DIAGNOSTICS = 16 + ON_READY_FOR_ATTACH = 20 + ON_ATTACHED = 21 + ON_DIO_MOTIONBLOCKS = 22 + ON_ANALOG_VELOCITY = 23 + ON_ANALOG_TORQUE = 24 + ON_HOMING = 28 + ON_VIRTUAL_MCP_JOG = 29 + ON_EXTERNAL_TRAJECTORY = 30 + ON_HARDWARE_MCP_JOG = 31 diff --git a/pylabrobot/brooks/precise_flex/errors.py b/pylabrobot/brooks/precise_flex/errors.py new file mode 100644 index 00000000000..17f503123d8 --- /dev/null +++ b/pylabrobot/brooks/precise_flex/errors.py @@ -0,0 +1,1979 @@ +"""PreciseFlex controller reply codes (the error-code table) and the driver's exception type.""" + +ERROR_CODES = { + 0: {"text": "Success", "description": "Operation completed successfully without an error."}, + 1: { + "text": "Warning", + "description": "Operation completed without an error, but an anomaly occurred that should be brought to the attention of the system operator.", + }, + -200: { + "text": "No memory available", + "description": "There is not sufficient memory to perform the requested operation. Large amounts of memory may be used by the following: loaded GPL procedures, arrays, strings, and the Data Logger. Check if any of these items are unusually large.", + }, + -201: { + "text": "System internal consistency error", + "description": "This error indicates that a condition has been detected that should not be possible if the controller and its system software are operating properly. Most likely, this indicates that a software bug has been encountered. Please report this message along with the process necessary to generate this problem to Precise.", + }, + -202: { + "text": "Invalid argument", + "description": "The value of an argument used in a GPL instruction, built-in method, or console command is not allowed.", + }, + -203: { + "text": "FIFO overflowed", + "description": "An overflow has occurred in a system data structure. Most likely, this indicates that a software bug has been encountered. Please report this message along with the process necessary to generate this problem to Precise.", + }, + -204: { + "text": "Not implemented", + "description": "You have attempted to use a feature of GPL or the Precise Controller that is planned but not implemented.", + }, + -205: { + "text": "Missing argument", + "description": "A required argument for a GPL instruction, built-in method, or console command was not supplied.", + }, + -206: { + "text": "Invalid auto execute mode", + "description": 'The "Auto execution mode" specifies the major motion control function that the controller should perform. For example, the execution of the DIOMotion Blocks and GPL projects are examples of two "Auto execution modes". This error indicates that an invalid mode has been specified. Most likely, the value of "Automatic execution mode" (DataID 200) has been set incorrectly.', + }, + -207: { + "text": "Too many", + "description": "The current operation has attempted to create more items of an internal data structure than is allowed. Most likely, this indicates that a software bug has been encountered. Please report this message along with the process necessary to generate this problem to Precise.", + }, + -208: { + "text": "Protection error", + "description": "You have attempted to read a hidden value, access a secured data area without proper authorization, or use the Data Logger on data that cannot be logged. This error is often generated by GPL application programs that attempt to write to Parameter Database values that cannot be modified when motor power is enabled. In these cases, disable motor power and retry the operation.", + }, + -209: { + "text": "Read only", + "description": "This error message indicates that you have attempted to alter a value that can only be read. For example, this error is generated if you attempt to change the value of an analog input.", + }, + -210: { + "text": "Operating system error code", + "description": "This error message indicates that the underlying real-time operating system (the RTOS) or one of its communications drivers has signaled an error that does not correspond to a standard GPL error. The numeric code contains the RTOS error number.", + }, + -211: { + "text": "Option not enabled", + "description": """This error is generated if you attempt to execute or enable a software or hardware option that is either not available in your system or has not been enabled. Typically, this indicates that your system software does not support the desired feature or a parameter has not be properly set. This is different from missing a required license bit, which is indicated by the "License not installed" error message. Normally, this error includes one of the following codes that further defines the problem. + + 1: Split axis capability is not supported by kinematic module. + 2: External trajectory interface is not support by system software. + 3: Ethernet is not supported by the system software. + 4: TCP network protocol is not supported by system software, so Telnet and GDE may not be used. + 5: UDP network protocol is not supported by system software. + 6: Vision interface is not supported by system software. + 7: Motor linearity compensation is either not supported by the kinematic module or is not enabled." + """, + }, + -212: { + "text": "License not installed", + "description": """A required software license is not installed on your controller. The name of the missing license is shown after this error message. The license names include: + + (1) Guidance Programming Language + (2) Motion control + (3) Conveyor tracking + (4) Advanced kinematics + (5) Complex kinematics + (6) Advanced controls + (7) Enhanced encoder + (8) Encoder latching + (9) RIO motion control + (10) Software application + (11) G&M code support + (12) Motion no kinematics support + (13) External trajectory + (14) XY compliance + (15) Z height detection + (16) GuidanceMotion application + (17) EtherCAT Master + +Older GPL systems may display the license number shown above in ( ) rather than the name. The currently installed licenses can be viewed via the web interface by selecting Utilities>Controller Options or by accessing DataID 112, "Software license option bits". To obtain a required license, contact your system administrator or Brooks.""", + }, + -213: { + "text": "Invalid password", + "description": "An invalid password was entered for a protected operation or facility. Please re-enter the correct password or ask your system administrator for the correct password.", + }, + -214: { + "text": "Cancelled", + "description": "An operation was initiated that was subsequently cancelled. No further action is required.", + }, + -215: { + "text": "No system clock interrupts", + "description": "Many of the major subsystems of the controller (e.g. the servo code, the trajectory generator, the GPL projects) are serviced by different threads of software that execute independently. This thread execution is governed in part by the ticking of a master system clock. When the system restarts, it automatically tests the system clock to ensure that it is ticking at the correct rate. These errors signify that the system clock is not operating properly. Most likely, this indicates a hardware problem with the main processor board, the MIDS.", + }, + -216: { + "text": "System clock interrupts too slow", + "description": "Many of the major subsystems of the controller (e.g. the servo code, the trajectory generator, the GPL projects) are serviced by different threads of software that execute independently. This thread execution is governed in part by the ticking of a master system clock. When the system restarts, it automatically tests the system clock to ensure that it is ticking at the correct rate. These errors signify that the system clock is not operating properly. Most likely, this indicates a hardware problem with the main processor board, the MIDS.", + }, + -217: { + "text": "System clock interrupts too fast", + "description": "Many of the major subsystems of the controller (e.g. the servo code, the trajectory generator, the GPL projects) are serviced by different threads of software that execute independently. This thread execution is governed in part by the ticking of a master system clock. When the system restarts, it automatically tests the system clock to ensure that it is ticking at the correct rate. These errors signify that the system clock is not operating properly. Most likely, this indicates a hardware problem with the main processor board, the MIDS.", + }, + -218: { + "text": "Invalid task configuration", + "description": "An invalid parameter has been entered associated with one of the major tasks of the system. For example, the update period of the trajectory generator must be specified as a number that is a power of two.", + }, + -219: { + "text": "Incompatible FPGA version", + "description": 'The firmware in the FPGA is not compatible with the hardware or software configuration options in this controller. The FPGA ("Field Programmable Gate Array") controls the robot power sequencing, encoder interfaces, motor current loop and a host of other critical functions. There are multiple versions of FPGA firmware that support various motors, encoders, and other options. Please obtain a compatible version of the FPGA firmware data or modify your configuration parameters. FPGA firmware may be loaded by following the instructions in the FAQ section of the PreciseFlex™ PreciseFlex Library.', + }, + -220: { + "text": "Not configured", + "description": "You have attempted to access some aspect of the system that has not been configured. For example, you have attempted to set a parameter for an axis that was not defined in the configuration database.", + }, + -221: { + "text": "Invalid robot type", + "description": 'A kinematic robot module does not exist for the specified robot type. This typically indicates that a value specified in the "Robot types" (DataID 116) is incorrect. Please review this list of values and look at the information on the available robot kinematic modules.', + }, + -222: { + "text": "Remote software incompatible", + "description": "In a Servo Network system, the indicated slave controller contains software that is not compatible with the master controller. Update the slave controller's software to a compatible version. Normally the master and all slaves should run the identical GPL software version.", + }, + -223: {"text": "Incompatible system", "description": ""}, + -224: {"text": "FPGA load failed", "description": ""}, + -300: { + "text": "Invalid device", + "description": "The device specified for a file or I/O operation is of the wrong type for that operation. Check the spelling of the device name and verify what types of devices are appropriate for the operation you are attempting.", + }, + -301: { + "text": "Undefined device", + "description": "The device specified for a file or I/O operation is not recognized. Check the spelling of the device name. Verify that the device (e.g. remote I/O board) is installed in your system.", + }, + -302: { + "text": "Invalid device unit", + "description": "The unit for a device specified for a file or I/O operation is not valid for that operation. Verify what device's unit is appropriate for the operation you are attempting.", + }, + -303: { + "text": "Undefined device unit", + "description": "The unit for a device specified for a file or I/O operation is not recognized. Verify that the unit (e.g. serial port) is present in your system.", + }, + -304: { + "text": "Device already in use", + "description": "An attempt to open or attach a device has failed because the device is already in use. For example, an attempt has been made to open the /dev/com2 serial port from a GPL program while the hardware MCP that uses the same port is active. Check to make sure that the device you are accessing is available for use.", + }, + -305: {"text": "Logical device already in use", "description": ""}, + -306: {"text": "Duplicate logical device", "description": ""}, + -307: {"text": "Duplicate physical device", "description": ""}, + -308: { + "text": "Too many devices", + "description": "This error indicates that an internal software configuration bug has been encountered. Please report this message along with the process that generated this problem to Precise.", + }, + -309: { + "text": "No physical device mapped", + "description": "This error indicates that an internal software configuration bug has been encountered. Please report this message along with the process that generated this problem to Precise.", + }, + -310: { + "text": "Timeout waiting for device", + "description": "A device has failed to respond within the expected time period. This message may indicate a real error or the timeout may be expected if the system is testing for the presence of a device. Verify that the device is present. Increase the timeout value for the I/O operation.", + }, + -311: { + "text": "Date/time not set", + "description": "An attempt to read the system date or time has failed because the internal clock has not been initialized. Verify that your system contains a real-time clock. If so, this error may indicate a hardware failure such as a dead clock battery.", + }, + -312: { + "text": "Invalid date/time specification", + "description": "An attempt to set the system date or time has failed because the specification does not represent a valid date or time. Enter a valid specification.", + }, + -313: {"text": "CANOpen driver initialization failure", "description": ""}, + -314: { + "text": "Device not found", + "description": "An I/O device is not responding. This message may indicate a real error or the error may be expected if the system is testing for the presence of a device.", + }, + -315: { + "text": "NVRAM not responding", + "description": "A device connected to the I2C bus is not responding. This device may be the system NVRAM or it may be a different device. This message may indicate a real error or it may be expected if the system is testing for the presence of a device.", + }, + -316: { + "text": "NVRAM invalid response", + "description": "A device connected to the I2C bus is not communicating properly. This message indicates a hardware problem. Please report this message along with the process that generated this problem to Precise.", + }, + -317: { + "text": "Configuration area invalid", + "description": "The data in the system configuration area is not valid. The configuration data may have been corrupted. This error should not be seen on a control board that has been initialized. Please report this message along with the process that generated this problem to technical support.", + }, + -318: { + "text": "Real-time-clock disabled", + "description": "The real-time clock is disabled internally. This error should never be seen and indicates a hardware problem. Please report this message to Precise.", + }, + -319: { + "text": "Real-time-clock battery failed", + "description": "The real-time clock battery has failed. This error indicates a hardware problem. Please report this message to Precise.", + }, + -320: { + "text": "Device not ready", + "description": "An attempt was made to access a device that is busy. Depending on the device, it may be attached to a different thread, or it may have not been properly initialized. Try the operation again. Make sure the procedure used to access the device is correct.", + }, + -321: { + "text": "Invalid device command", + "description": "A device has rejected a command because it was not recognized. Check to make sure you are issuing the proper command for the device.", + }, + -322: { + "text": "i2c device failure", + "description": 'Robot power has been turned off because an unrecoverable error has occurred for an I2C device. I2C is used for a number of purposes including to communicate with the remote Z-axis digital I/O for the PrecisePlace 1300/1400 robots. This failure may be due to excessive electrical noise in your system. Check that your motor wiring and cable routing follows Precise guidelines. If your system is not a PP1300/1400, disable this error by setting parameter "Disable i2c errors" (DataID 920) to 1.', + }, + -323: { + "text": "Device full", + "description": "You have attempted to write data to a device that is full. No more data can be written until some of the existing data or files are deleted.", + }, + -324: { + "text": "MCP not recognized", + "description": "The hardware Manual Control Pendant connected to the Front Panel connector is not recognized as a Precise MCP. Consequently, the system cannot communicate with the device and the MCP driver is terminated. Please obtain an authorized MCP or contact Precise to repair your existing MCP.", + }, + -325: { + "text": "SIO device failure", + "description": 'Robot power has been turned off or has been inhibited from turning on because an SIO (RS-485) device has failed to respond to polling requests as expected. Verify that all required SIO devices are connected properly. Verify that only existing SIO devices are enabled in the "SIO mode flags" (DataID 571).', + }, + -326: { + "text": "Device not enabled", + "description": "An I/O operation has failed for a device because it is not enabled. The method for enabling a device depends on the particular device. Verify that any enable flags for the device are set in the configuration database.", + }, + -327: { + "text": "Invalid device configuration", + "description": "Some device configuration parameters are invalid. Check the values of the DataID reporting this error and verify that they are correct.", + }, + -328: { + "text": "PMIC not responding", + "description": "The power management controller is not responding as expected during system startup. This error should never be seen. Please report this message along with the process that generated this problem to technical support.", + }, + -500: { + "text": "Device I/O error", + "description": "An I/O operation has failed. This is a generic error code that is used when an I/O operation fails but no specific information is available.", + }, + -501: { + "text": "No I/O pending", + "description": "A read data operation was initiated in a situation where I/O is normally expected, but no data is available.", + }, + -502: { + "text": "No message buffers available", + "description": "An I/O operation cannot allocate internal buffers in a situation where buffers should always be available.", + }, + -503: { + "text": "Node not found", + "description": "An Ethernet slave controller node or a RS-485 serial bus board is configured but could not be found.", + }, + -504: { + "text": "Less data than expected", + "description": "An I/O operation has returned less data than required. This error should normally not be seen.", + }, + -505: { + "text": "Unexpected end of file", + "description": "The end of a data file has been encountered before it was expected. Normally this means the data file has been corrupted.", + }, + -506: { + "text": "Input too long", + "description": "An I/O operation or the GPL compiler has received more input than it can handle.", + }, + -507: { + "text": "Output too long", + "description": "Indicates that the output generated by the command being executed contained too much data.", + }, + -508: { + "text": "File not found", + "description": "You have specified a file that does not exist. Verify that the file name is spelled correctly.", + }, + -509: { + "text": "Can't open file for write", + "description": "You have specified a file for writing that cannot be opened.", + }, + -510: { + "text": "File already exists", + "description": "You have attempted to create a file that already exists.", + }, + -511: { + "text": "Can't create directory", + "description": "An attempt to create a directory has failed. Normally this error should not occur.", + }, + -512: { + "text": "Error writing file", + "description": "A write operation to a device or file has failed.", + }, + -513: { + "text": "Error reading file", + "description": "A read operation from a device or a file has failed.", + }, + -514: { + "text": "Buffer already in use", + "description": "An attempt has been made to use an I/O buffer that is already in use.", + }, + -515: { + "text": "Invalid input character", + "description": "An invalid ASCII character has been encountered while compiling a GPL program.", + }, + -516: { + "text": "Invalid file name", + "description": "A file name string is invalid. It may contain invalid characters or may not have the proper directory path format.", + }, + -517: { + "text": "File path too long", + "description": "The total file path and name string you have specified is too long.", + }, + -518: { + "text": "Invalid data checksum", + "description": "A data file or message that is validated by a checksum does not have a correct checksum value.", + }, + -519: { + "text": "Invalid file format", + "description": "The format of a data file does not match what is expected.", + }, + -520: { + "text": "File not open", + "description": "You have attempted to perform a file operation on a file that is not open.", + }, + -521: { + "text": "Invalid file type", + "description": "You have attempted to access a file in a way that is not allowed for the type of file.", + }, + -522: { + "text": "File interlocked", + "description": "You have attempted to access a file that is already in use.", + }, + -523: { + "text": "No data available", + "description": "A polling I/O request has failed because no data is available.", + }, + -524: { + "text": "No timestamp available", + "description": "A request has been made to read the timestamp for an I/O request when none is available.", + }, + -525: { + "text": "Latch input overrun", + "description": "The hardware latch circuit has detected that edges in the latch input signal are occurring too quickly to be processed.", + }, + -526: { + "text": "Latch data overrun", + "description": "Latch events are occurring too quickly for the Precise Controller to service them.", + }, + -527: { + "text": "Invalid latch configuration", + "description": "A value in DataID 16100 'Latch DIN' has been specified that is not valid for the controller configuration.", + }, + -700: { + "text": "Thread execution aborted", + "description": "A GPL user thread has been stopped by request (for example from a Stop command) or a robot error occurred while the robot was attached.", + }, + -702: { + "text": "Undefined thread", + "description": "The thread name specified in a Thread Class method or console command is not known to GPL. Verify that the thread name is correct. Verify that the thread has not been stopped and the name removed from the list of threads.", + }, + -704: { + "text": "Missing quote mark", + "description": 'A double quote character (") has not been found where expected at the end of a string specification or another syntax error in the statement prevented the compiler from finding the quote mark. Fix the statement syntax and add a quote mark if needed.', + }, + -705: { + "text": "Value too small", + "description": "A parameter database value, property value, or method parameter value is smaller than allowed. Check the relevant documentation and change the value to be within the proper range.", + }, + -706: { + "text": "Value too large", + "description": "A parameter database value, property value, or method parameter value is larger than allowed. Check the relevant documentation and change the value to be within the proper range.", + }, + -707: { + "text": "Value out-of-range", + "description": "A parameter database value, property value, or method parameter value is outside the range of allowed values. Check the relevant documentation and change the value to be within the proper range.", + }, + -708: { + "text": "Value infinite or NAN", + "description": 'A numeric value has been encountered that is too large for its new data type, or a floating point value has been encountered that is marked as "Not A Number" (NAN). This error can occur when converting from a larger numeric data type to a smaller one, for example Integer to Byte, or when performing a computation that results in a very large or infinite result, for example dividing by zero, or when computing the square root of a negative number. Check your procedure to eliminate these situations, or add checks to detect and handle them.', + }, + -709: { + "text": "Division by 0", + "description": "Indicates a division by zero was attempted. In matrix (Location) operations, this error can occur if the Z-axis orientation vector of a Cartesian Location has a zero length and the Location is being re-normalized. This can be caused by severe round-off error and can be corrected by normalizing the Location value more often.", + }, + -710: { + "text": "Arithmetic overflow", + "description": "This error indicates that a mathematical operation resulted in a number that is too large to represent. Since the majority of the internal computational operations are performed in double precision arithmetic, this typically indicates a result larger than 10^308 and normally indicates a programming error.", + }, + -711: { + "text": "Singular matrix", + "description": "A matrix operation is being performed and the determinate of the matrix is zero, i.e. the matrix is singular. This is typically detected when the system attempts to invert a matrix. For example, the conversion from joint angles to motor encoder values is represented as a matrix if the axes are mechanical coupled. When the system attempts to automatically compute the inverse conversion factors to go from motor encoder values to joint angles, a matrix inversion must be performed. This error indicates that a meaningful inverse relationship does not exist.", + }, + -712: { + "text": "Invalid syntax", + "description": "If encountered during program compilation, this message indicates that the GPL parser does not understand the current instruction. Either the instruction itself has an error, or it is being used in an unexpected situation. If encountered during execution, the arguments to a command, instruction, or method do not follow the expected format.", + }, + -713: { + "text": "Symbol too long", + "description": "An object or variable name exceeds the system's limit of 128 characters.", + }, + -714: { + "text": "Unknown command", + "description": "The command keyword for a console command is not recognized. Check to make sure you have entered the command correctly. Check that the command is supported by your version of GPL.", + }, + -715: { + "text": "Invalid procedure step", + "description": "An attempt has been made to execute a procedure step that is not executable. For example, during debugging, you have attempted to specify a comment line as the next step to execute.", + }, + -716: { + "text": "Ambiguous abbreviation", + "description": "A command keyword or parameter keyword has been entered that is an abbreviation for more than one command or parameter. Reenter the command specifying a longer abbreviation that matches only one keyword.", + }, + -717: { + "text": "Invalid number format", + "description": 'A numeric constant has been entered that does not match the expected format. For example, a floating point value has been entered with an empty exponent: "2.0E".', + }, + -718: { + "text": "Missing parentheses", + "description": 'After a left parenthesis "(" was encountered, the matching right parenthesis ")" was not found where expected. This error is sometimes reported when a syntax error occurs in the argument list for a procedure call, even if the right parenthesis is present. Add the missing parenthesis or fix any syntax errors.', + }, + -719: { + "text": "Illegal use of keyword", + "description": "A GPL keyword has been encountered in a place where it is invalid. For example, a keyword occurs in a declaration where it is not allowed, or an attempt was made to create a variable with the same name as a GPL keyword. Remove the keyword or rename the variable.", + }, + -720: { + "text": "Unexpected character in expression", + "description": "An unexpected character was encountered while evaluating a numeric or string expression. For example an operator was found in an unexpected place. Correct the expression syntax.", + }, + -721: {"text": "Conflict with reserved keyword", "description": ""}, + -722: { + "text": "Unexpected text at end of line", + "description": "Unexpected characters were found at the end of a statement. There may have been a previous syntax error that confused the compiler, or a missing comment character. Correct the line.", + }, + -723: { + "text": "Invalid statement label", + "description": "A statement label has been placed where it is not allowed, for example outside a procedure, or a Goto statement refers to a statement label that was not defined. Move or define the label.", + }, + -724: {"text": "Invalid End keyword", "description": ""}, + -725: { + "text": "Unknown data type", + "description": "During XML processing, a node with an unknown data type has been encountered. Correct the XML document.", + }, + -726: { + "text": "Data type required", + "description": 'A variable or procedure declaration is missing the "As" clause that specifies the data type. Add the appropriate clause and data type.', + }, + -727: { + "text": "Cannot redefine symbol", + "description": "An attempt has been made to define a symbol that already exists in the same context. This symbol may be the name of a project, module, class, procedure or variable. Change the name or scope of the symbol.", + }, + -728: {"text": "Procedure too long", "description": ""}, + -729: { + "text": "Undefined symbol", + "description": "A symbol has been encountered that is not declared within the current context. This symbol may be the name of a project, module, class, procedure or variable. Declare the symbol.", + }, + -730: { + "text": "Invalid symbol type", + "description": "A known symbol has been encountered in a statement but its type is not valid where it is being used. For example a Sub procedure name is used in an expression as if it were a function. Correct the statement.", + }, + -731: { + "text": "Unmatched parentheses", + "description": 'A right parenthesis ")" was encountered without being preceded by a left parenthesis "(". Add a "(" where appropriate, or remove the extra ")".', + }, + -732: { + "text": "Invalid procedure end", + "description": "When compiling a procedure, a top-level statement has been found other than the expected matching procedure end statement. Verify that the correct matching end statement is present.", + }, + -733: { + "text": "Not a top-level statement", + "description": "A statement has been encountered outside of a Module or Class definition block. Move the statement inside the block.", + }, + -734: { + "text": "Object not bound to class", + "description": 'A variable had been declared to be of type "Object" which is not supported by GPL. Correct the declaration.', + }, + -735: { + "text": "Too many nested blocks", + "description": "This typically indicates that a individual GPL procedure contains too many control structures, e.g. If..Then..Else or For loops, that are embedded within each other. The system currently has a limit of 100 nested control structures. To correct this problem, the procedure must be rewritten to reduce the nesting depth.", + }, + -736: { + "text": "Duplicate statement label", + "description": "An identical duplicate statement label has been encountered when compiling either a GPL program or a command script. Labels must be unique.", + }, + -737: { + "text": "Too many errors, compile cancelled", + "description": "The compiler has encountered more than 8 errors and is stopping the compile operation. Fix the errors already listed and compile again.", + }, + -738: { + "text": "Invalid data type", + "description": "A data type keyword has been encountered where it is not allowed. For example, a variable has been declared as type Function.", + }, + -739: { + "text": "Cannot change built-in symbol", + "description": "An attempt has been made to add to a built-in class or module. These built-in classes cannot be changed.", + }, + -740: {"text": "Empty procedure", "description": ""}, + -741: { + "text": "Argument mismatch", + "description": "The arguments in a statement, console command or procedure call do not match the parameters for that statement, console command or procedure call. Change the arguments so that they match the required parameters.", + }, + -742: { + "text": "Compilation errors", + "description": "A compilation has failed because of detected errors. The specific errors are listed during the compilation operation. Or, an attempt has been made to start a project that contains compilation errors. Fix the errors and recompile.", + }, + -743: { + "text": "Invalid project file", + "description": "The Project.gpr file in your project folder is not valid. This file is normally automatically generated and managed by GDE. If you edited this file by hand, double-check that the format is valid. Otherwise use GDE to rebuild the project or restore your project from a backup.", + }, + -744: { + "text": "Invalid start procedure", + "description": 'The "start" procedure specified for your project or by a Thread.Start method could not be found or is of the wrong type. Start procedures must be Public and of type Sub or Function. Correct the start procedure specification.', + }, + -745: { + "text": "Project already exists", + "description": "An attempt was made to load a project with the same name as a project currently loaded. Change the name of the second project, or unload the first project.", + }, + -746: { + "text": "Interlocked for read", + "description": "An attempt was made to change a system resource that is currently in use. Wait a short time and try again in case the lock was temporary. Otherwise, determine the thread that is using the resource and stop it. Then, try accessing the resource again.", + }, + -747: { + "text": "Interlocked for write", + "description": "An attempt was made to change a system resource that is actively being changed. For example two threads might be attempting to delete the same project simultaneously. Wait a short time and try again. Write locks are normally temporary", + }, + -748: { + "text": "No matching control structure", + "description": "A GPL procedure is missing statements that are necessary to properly terminate one or more control structures. For example, a For statement might be missing its matching Next statement or an If instruction might not be properly terminated by an End If statement or a Case statement may not immediately follow a Select statement.", + }, + -749: { + "text": "Thread already exists", + "description": "An attempt was made to create a thread with the same name as one that already exists. Unload the first thread, or rename the second thread.", + }, + -750: { + "text": "Invalid when thread active", + "description": "An attempt was made to perform an operation that cannot be executed while a thread is active. For example, you may have attempted to start a thread that is already active or you may have attempted to delete a project with an active thread.", + }, + -751: { + "text": "Timeout starting thread", + "description": "A thread has not started or restarted within 1 second of being requested to execute. This error often occurs when restarting a thread that is still winding down because it is taking longer than expected to perform its cleanup procedures. Stop or pause the thread and repeat the operation.", + }, + -752: { + "text": "Timeout stopping thread", + "description": "A thread has not stopped within 3 seconds of when a request to stop it occurred. The thread may be waiting for some operation to complete before it can stop. For example, it may be waiting for a robot motion or I/O operation to complete. This is not a critical error and the thread will stop when it completes whatever it is doing. This error is commonly seen if a thread stop request occurs after a thread has started a long robot motion. To stop a thread quickly in this case, issue a soft E-Stop just prior to requesting the thread to stop.", + }, + -753: { + "text": "Project not compiled", + "description": "An attempt was made to access a project that is not compiled. The project may not exist, or it may be loaded but not compiled. Load the project if not loaded and then compile it.", + }, + -754: { + "text": "Thread execution complete", + "description": "An attempt was made to continue execution of a thread that has run to completion. You must restart the thread with a Start command or Thread Start method.", + }, + -755: { + "text": "Thread stack too small", + "description": "An attempt was made to allocate more data on a thread stack than the stack is able to accommodate. You may have more nested procedure calls than expected or you may have allocated too many procedure-local variables. Verify that you do not have a program recursion bug. Check the stack usage with the Show Stack command. If required, specify a larger thread stack size with the Start command or the Thread Class constructor.", + }, + -756: { + "text": "Member not shared", + "description": "You have attempted to associate a Delegate object with a non-shared class procedure, but you have not provided an object reference in the Delegate New clause. Change your New clause to provide an object reference, or change your Delegate to refer to a shared procedure.", + }, + -757: { + "text": "Object not instantiated", + "description": 'An object is being assigned to on the left-hand side of an equal sign or is being referenced in an expression and the object value block has not been allocated. To correct this problem, use the "New" qualifier in the DIM statement that declared the object to allocate its value block.', + }, + -758: { + "text": "No Get Property defined", + "description": "An attempt has been made to read the value of a write-only property (that does not support Get). This can occur by attempting to assign a value to a write-only property with an assignment operator such as +=. Eliminate the read of the property.", + }, + -759: { + "text": "Undefined value", + "description": "An attempt was made to read a variable or procedure argument that does not have any value assigned. Be sure to assign a value to a variable before referring to it.", + }, + -760: { + "text": "Invalid assignment", + "description": "An attempt was made to assign a value to a variable with an incompatible data type. For example you cannot assign an object of one class to a variable for another class.", + }, + -761: { + "text": "Cannot have list of variables", + "description": "A declaration with an initializer may define only one variable. A list of variables is not allowed in this situation. Break your declaration into multiple statements.", + }, + -762: { + "text": "Location not a Cartesian type", + "description": 'A property or a method of a Location object requires a Cartesian type value, but the Location is an Angles type instead. For example, it is invalid to reference the "X" property of a Location defined as an Angles type.', + }, + -763: { + "text": "Location not an angles type", + "description": 'A property or a method of a Location object requires an Angles type value, but the Location is a Cartesian type instead. For example, it is invalid to reference the "Angle" property of a Location defined as a Cartesian type.', + }, + -764: { + "text": "Invalid procedure overload", + "description": "An attempt was made to declare a procedure with the same name as an existing procedure, and with arguments that match too closely so that it cannot be considered an overload. Change the arguments so that the two procedures can be distinguished by the compiler.", + }, + -765: { + "text": "Array index required", + "description": "An array reference was found that does not have the correct number of index arguments specified for the number of array dimensions. Specify the correct number.", + }, + -766: { + "text": "Array index mismatch", + "description": "An array argument does not have the same number of dimensions as an corresponding array parameter. Change the arrays so that the dimensions match.", + }, + -767: { + "text": "Invalid array index", + "description": "An array index value is negative or greater than the maximum permitted by its declaration. Or, an array argument was specified that does not contain sufficient elements for the matching array parameter.", + }, + -768: { + "text": "Unsupported array access", + "description": "An attempt was made to access an array in a way that is not supported. This error should never occur in GPL 3.1 or later.", + }, + -769: { + "text": "Reference frame wrong type", + "description": "A property or a method of a RefFrame object requires a particular type of reference frame value and the type is incorrectly set. For example, the PalletIndex property is only valid when the RefFrame Type is set to pallet.", + }, + -770: { + "text": "Reference frame undefined data", + "description": "When the position of a reference frame is evaluated either directly or as part of the absolute value of a Location that is relative to the reference frame or when a property of a reference frame is being accessed, this error is generated if the Loc of the reference frame is not defined. To correct this problem, assign a defined Cartesian Location value to the refframe.Loc property.", + }, + -771: { + "text": "Stack frame does not exist", + "description": 'A command that accepts a stack frame number has specified a stack frame that does not exist. Use the "show stack" command with no frame argument to display all frames and determine the maximum valid frame number.', + }, + -772: { + "text": "Ambiguous Public reference", + "description": "References to top-level public variables do not need to be qualified by their containing module or class provided that they are unique. However if the same public variable appears in more than one module or class, you must precede its name with the name of its module or class.", + }, + -773: { + "text": "Missing module end", + "description": 'An "End Module" or "End Class" statement was not found where it was expected. Add the appropriate statement.', + }, + -774: { + "text": "Too many breakpoints", + "description": 'More than 32 breakpoints have been set in GPL procedures. Remove some of the existing breakpoints or issue "Clear All Breakpoints" and set fewer breakpoints.', + }, + -775: { + "text": "Duplicate breakpoint", + "description": 'A breakpoint was set on a line that already contains a breakpoint. If GDE does not show a breakpoint on this line, GDE could be out-of-sync with GPL. Try disconnecting and reconnecting GDE. Try issuing "Clear All Breakpoints" and set your breakpoint again.', + }, + -776: { + "text": "No instruction at this line", + "description": 'A breakpoint was set in a procedure that does not exist or on a line that contains an instruction that does not allow breakpoints. In the second case, GPL tries to set a breakpoint on the next valid instruction, but this error will be generated if there is no valid instruction before the end of the procedure.\n\nThe error can occur if you edit your program and re-compile after adding/removing lines while having breakpoints set. It could also occur if GDE is out-of-sync with GPL. Try disconnecting and reconnecting GDE. Try issuing "Clear All Breakpoints" and set your breakpoints again.', + }, + -778: { + "text": "Objects not allowed for class", + "description": "An attempt was made to use the New clause to allocate an object for a built-in class that does not allow objects.", + }, + -779: { + "text": "Thread paused in eval", + "description": 'A console command such as "Show Variable" has invoked a procedure that has paused due to an error or breakpoint. The command cannot continue. Normally this error is not seen.', + }, + -780: { + "text": "Unsupported procedure reference", + "description": "A console command or a Const statement has attempted to call a user-defined function or property. This type of access is not supported.", + }, + -781: { + "text": "Missing string", + "description": 'The system is expecting a string value, such as following a concatenation operator ("&") but a string value was not found.', + }, + -782: { + "text": "Object value is Nothing", + "description": 'An object is being referenced in an expression of some type, and its value is not allocated and therefore undefined. To correct this problem, use the "New" qualifier in the DIM statement to allocate the value, and then define the required properties.', + }, + -783: { + "text": "Short string", + "description": "The number of characters stored in a string variable has been found to be less than is indicated by the string length. This is an abnormal condition and might occur if two threads are writing to the same string variable at the same time.", + }, + -784: { + "text": "Invalid property", + "description": "An object property is being accessed that is not valid given the settings of the other properties of the object. For example, an Exception object can be marked as a general error or a robot error. Depending upon this setting, certain properties are not accessible.", + }, + -785: { + "text": "Branch not permitted", + "description": "A GoTo instruction specifies a branch to a instruction that is not permitted. This typically means that a GoTo is attempting to branch into or out of a Try...Catch...Finally...End Try block that is not permitted. See the section on Exception handling for more information.", + }, + -786: { + "text": "Project generated error", + "description": "This error is never generated automatically and is provided solely as a convenience for GPL application programs. GPL projects can use the Throw instruction to emit this error code to indicate special application errors. The exception Qualifier can be used to provide additional information.", + }, + -787: { + "text": "Invalid in shared procedure", + "description": "An attempt has been made to access a non-shared and non-constant class variable from within a shared class procedure. Shared class procedures are not associated with an object instance, so they cannot access object variables. Either the procedure should not be declared Shared, or the class variable of interest should be declared Shared or Const.", + }, + -788: { + "text": "Inconsistent MOVE.TRIGGER mode", + "description": "Most likely, this indicates that a MOVE.TRIGGER instruction was executed that specified that the signal should be triggered a distance from the start or the end of the next motion, but the motion was not a straight-line or arc motion. To correct this problem, change the trigger mode or the motion type.", + }, + -789: { + "text": "Procedure exception", + "description": "A GPL procedure instruction has detected an exception that interrupts normal program execution. This error is normally handled internally by GPL and is not seen by the user.", + }, + -790: { + "text": "Invalid static initializer", + "description": "An attempt has been made to call a user-defined method in the initializer of a statically allocated variable or Const value. These variables include Module level variables, Shared class variables, and Static procedure variables. User-defined methods include constructors (New method) for user-defined classes.", + }, + -791: { + "text": "Conveyor must be base RefFrame", + "description": "Conveyor type RefFrame objects cannot themselves have a defined RefFrame value. That is, a Conveyor reference frame must always be the last (base) reference frame in a series of relative reference frames. However, other types of RefFrame objects can be relative to (above) Conveyor reference frames.", + }, + -792: { + "text": "undefined", + "description": "Before a Conveyor RefFrame can be used, its ConveyorRobot property must be set to specify the number of the Robot whose first axis provides the encoder signal for the conveyor. Without this information, the system has no way to determine the position of a conveyor.", + }, + -793: { + "text": "Arc cannot transition conveyors", + "description": "Circular interpolated motions can be performed relative to a conveyor belt. However, this type of motion cannot be used to accelerate on to a conveyor or off of a conveyor. That is, all three points that define a circular interpolated motion must all be relative to the same conveyor belt. To transition on to or off of a conveyor, use a straight-line Cartesian motion.", + }, + -794: { + "text": "ZClearance property not set", + "description": "A Move.Approach instruction was executed that referenced a Location whose ZClearance property has not been set to a realistic value. This instruction attempts to move the robot's tool to \"ZClearance\" mm above the Location's position. When a Location is first created, its ZClearance property is set to a very large number that cannot be reached. To correct this problem, set the Location's ZClearance property to the desired value in mm before executing the Approach instruction.", + }, + -795: { + "text": "XML documents do not match", + "description": "An attempt was made to define a parameter that is already defined. Only occurs in CommandProgram class methods.", + }, + -796: { + "text": "No delegate defined", + "description": "A reference has been made to an undefined delegate. Verify that any referenced delegate has been properly defined.", + }, + -797: { + "text": "Object not up-to-date", + "description": "An object that depends on another object or data structure has been referenced after the underlying data structure has been changed. Only occurs in CommandProgram class methods.", + }, + -798: { + "text": "No module defined", + "description": "An attempt has been made to declare a variable when no containing module has been defined. Only occurs in CommandProgram class methods.", + }, + -799: { + "text": "XML error", + "description": "An XML library error has occurred while parsing, accessing, or storing an XML document. This error is accompanied by an error code which further qualifies the error. See the XmlDoc.Message method for a string value that shows the error details.", + }, + -800: { + "text": "No XML document", + "description": "An attempt has been made to access an XmlDoc class object that is not associated with any document. Use the XmlDoc LoadString, LoadFile, or New methods to create a document.", + }, + -801: { + "text": "No XML node", + "description": "An attempt has been made to access an XmlNode class object that is not associated with any document node. This dissociation can occur if the referenced node is removed while the XmlNode object is still pointing to it. Check your program flow to see if nodes are being removed when you do not expect it. Remember that removing a parent may remove its children also.", + }, + -802: { + "text": "Undefined XML name", + "description": "A search for a named node using an XmlNode method has failed to find the named node. If you are not sure whether or not the node exists, use the HasElement or HasAttribute methods to check or enclose your search within a Try / Catch block.", + }, + -803: { + "text": "Invalid XML node type", + "description": 'An attempt has been made to use an XML node of a particular type where it is not allowed. For example, the parents of "attribute" nodes must be "element" nodes, and the parents of "element" nodes must be nodes of type "element", "entity", "document" or "htmldocument".', + }, + -804: { + "text": "XML documents do not match", + "description": "An attempt was made to add a XML node that is a member of one document to a second, different document. This is not permitted. Use the XmlNode.Clone method, with the second parameter specified, to create a clone of the node on an alternate document. You can add the clone to the second document.", + }, + -805: { + "text": "Invalid circular reference", + "description": "A Const symbol declaration refers to other Const symbols in a circular manner such that the original symbol references itself. This is not permitted.", + }, + -806: { + "text": "Invalid Const reference", + "description": "A Const symbol declaration refers to non-constant values, such as user variables or functions. This is not permitted. Const symbols may only be defined in terms of constants, other Const symbols, and built-in system functions.", + }, + -807: { + "text": "Invalid exception", + "description": "The exception object parameter to a Throw statement is invalid. Probably the ErrorCode property of the exception is not set to a negative value. Verify that you are setting the ErrorCode property to a negative value after creating the exception object.", + }, + -808: { + "text": "Branch out of Finally block not permitted", + "description": "An attempt has been made to exit from the Finally block of a Try … End control structure by using a statement such as a Goto, End, Exit, or Return. Finally blocks must always continue to the End Try statement.", + }, + -809: { + "text": "Expression too complex", + "description": "An attempt had been made to evaluate an expression that contains more the 32 objects, or that contains object references in a recursive loop. For example, a reference frame that refers to itself. Break the expression into multiple statements or remove any recursive references.", + }, + -810: { + "text": "Unexpected end of line", + "description": "The compiler has encountered the end of a line at an unexpected place, for example in the middle of an incomplete expression. Correct the syntax of the indicated line.", + }, + -811: { + "text": "No Set Property defined", + "description": "A Property definition has been encountered that is not declared ReadOnly and does not contain a Set statement. Either add a Set … End Set block or add the ReadOnly keyword to your Property definition.", + }, + -812: { + "text": "Not allowed in factory test system", + "description": "A special limited functionality version of GPL is loaded into the controller and one of the eliminated functions has been invoked. Replace the GPL system with the latest version available on the Precise Support website.", + }, + -1009: { + "text": "No robot attached", + "description": "A function or method was executed that required that a robot be ATTACHED. This error is often generated if you attempt to execute one of the methods in the Move Class without first attaching the robot. Correct your GPL program by inserting a Robot.Attached method prior to executing the instruction that contains the Move Class method.", + }, + -1000: { + "text": "Invalid robot number", + "description": "A robot number has been specified that is less than 1 or more than the number of configured robots.", + }, + -1001: { + "text": "Undefined robot", + "description": 'A robot number must be specified (1 to N), but no number was defined. For example, this error can occur if you are referencing a Parameter Database value that requires that a robot be specified as the "unit" (second) parameter but this value is left blank.', + }, + -1002: { + "text": "Invalid axis number", + "description": "An axis number has been specified that is less than 1 or more than the number of axes configured in the referenced robot. For example, this error will be generated if you are accessing a location's angle value (location.angle(n)) but the axis number is undefined or set to 0.", + }, + -1003: { + "text": "Undefined axis", + "description": "An axis has been specified that is not configured for the referenced robot. This can occur if an axis bit mask has been specified that references an axis that is not currently configured.", + }, + -1004: { + "text": "Invalid motor number", + "description": "A motor number has been specified that is less than 1 or more than the number of motors configured in the referenced robot.", + }, + -1005: { + "text": "Undefined motor", + "description": "A motor has been specified that is not configured for the referenced robot. This can occur if a motor bit mask has been specified that references a motor that is not currently configured.", + }, + -1006: { + "text": "Robot already attached", + "description": "An Auto Execution task or a GPL project has attempted to gain control of a robot by executing a Robot.Attach method or similar function, but the specified robot is already in use by another task. Alternately, this error is generated if you attempt to Attach or Select a robot, but the task is already attached to a different robot.", + }, + -1007: { + "text": "Robot not ready to be attached", + "description": 'An Auto Execution task or a GPL project has attempted to gain control of a robot by executing a Robot.Attach method or similar function, but the specified robot is not in a state where it can be attached. If this was generated by a GPL project, it might indicate: That the system is configured to execute DIOMotion blocks instead of a GPL motion program (DataID 200) or "Auto start auto execute mode" (DataID 202) is not set to TRUE. If so, please check the Setup>Startup Configuration web page to verify the current setup.', + }, + -1008: { + "text": "Can't detached a moving robot", + "description": "An operation is attempting to Detach a robot that is currently moving. Normally, it is not possible to generate this error condition because the Robot.Attached method automatically waits for the robot to stop before attempting the detach operation.", + }, + -1010: { + "text": "No robot selected", + "description": 'A function or method was executed that required that the robot be SELECTED. By default, the first robot or the Attached robot is set as the selected robot. A number of "readonly" operations require that a robot be selected, e.g. location.Here. Correct your GPL program by inserting a Robot.Selected method prior to executing the instruction that generated the exception.', + }, + -1011: { + "text": "Illegal during special Cartesian mode", + "description": "The robot is performing a special Cartesian trajectory mode such as conveyor tracking, outputting a DAC signal based upon the Cartesian tool tip speed, performing real-time trajectory modification, etc. When the trajectory generator is in this mode, the requested operation that produced this error is not permitted to execute. For example, jogging or moving along a joint interpolated trajectory or changing the tool length cannot be performed while tracking a conveyor belt. You must either terminate the current Cartesian trajectory mode with a Robot.RapidDecel or other means before initiating the new robot control mode or you must modify your program to use a method consistent with the current trajectory mode. For example, if you attempted to initiate a joint interpolated motion, use a Cartesian straight-line motion instead.", + }, + -1012: { + "text": "Joint out-of-range", + "description": "This indicates that the specified robot axes are either beyond or were attempted to be moved beyond their software limits, i.e. outside of their permitted ranges of travel. This error is also generated if you attempt to set the minimum and maximum soft and hard joint limits to inconsistent values. If you are narrowing the limits, you should set the new soft limits first and then change the hard limits. If you attempt to make both changes at the same time with the web interface, the system will process the new hard limits first, determine that they violate the old soft limits, and generate this error message.", + }, + -1013: { + "text": "Motor out-of-range", + "description": "This indicates that the specified robot motors are either beyond or were attempted to be moved beyond their software limits. This error is also generated if you attempt to set the minimum and maximum soft and hard motor limits to inconsistent values. For most robots, this error code will never be generated because the joint limits will be used exclusively. However, some robot's have coupled motors such that it may be possible to not violate a joint limit and still encounter the extreme travel limit of a motor. In these cases, this error message may be generated even when it appears that the robot's joint's are within their permitted ranges of travel.", + }, + -1014: { + "text": "Time out during nulling", + "description": 'At the end of a program generated motion, if the axes of the robot take too long to achieve the "InRange" constraint limits, this error message will be generated and program execution will be terminated. This may indicate that the InRange limit has been set too tightly or that the robot may not be able to get to the specified final position due to an obstruction.', + }, + -1015: { + "text": "Invalid roll over spec", + "description": "The Continuous Turn (encoder roll-over compensation) angle (DataID 2302) was set non-zero for an axis and the axis is not designed to support continuous turning. Please review the information for your kinematic module in the Kinematic Library documentation and ensure that the axis has been designed to support this feature.", + }, + -1016: { + "text": "Torque control mode incorrect", + "description": "Either the system is in torque control mode and should not be for the currently execution instruction or the system should be in torque control mode but is not. This can be generated in the following situations: Torque control mode is active and an instruction is executed to start External Trajectory mode, Jog Control mode, or torque control mode. An instruction is issued to set the torque values, but no motors are in torque control mode.", + }, + -1017: { + "text": "Not in position control mode", + "description": "A motion control instruction was initiated that requires that the robot be in the standard position controlled mode and the robot is in a special control mode, e.g. velocity control or jogging mode. For example, to initiate any of the following, the robot must be in the standard position control mode: torque control mode, velocity control mode, jog mode or any of the Move Class position controlled methods (e.g. Move.Loc).", + }, + -1018: { + "text": "Not in velocity control mode", + "description": "This error is generated if an instruction attempts to set the velocity mode speeds of an axis, but the system is not in velocity control mode.", + }, + -1019: { + "text": "Timeout sending servo setpoint", + "description": "The GPL trajectory generator sends a setpoint to the servos at the time interval determined by DataID 600, (Trajectory Generator update period in sec). This error occurs if a new setpoint is ready but the previous setpoint has not been sent. Verify that the DataID 603 (Servo update period in sec) value is one-half or less than the value of DataID 600 (Trajectory Generator update period in sec). In a servo network system, this error may indicate a network failure or unexpected network congestion. Provided that DataID 600 and 603 are set properly, this error should never be seen in a non-servo-network system. If the problem persists, contact Precise.", + }, + -1020: { + "text": "Timeout reading servo status", + "description": 'The servos send status information to GPL at the time interval determined by DataID 600, (Trajectory Generator update period in sec). This error occurs if no status information has been received by GPL for the past 32 milliseconds. If this error occurs when you are configuring a new controller system, it might indicate that you have changed some system parameters that are marked as "Restart required". However, you have attempted to operate the system without rebooting the controller. This can be corrected by restarting the controller. In a servo network system, this error may indicate a network failure or unexpected network congestion. This error should never be seen in a properly configured non-servo-network system. If the problem persists, contact Precise.', + }, + -1021: { + "text": "Robot not homed", + "description": 'An operation was invoked that requires that the robot\'s motors be homed. If a robot is equipped with incremental encoders, when the controller is restarted, the system does not have any knowledge of where each axes is located in the workspace. Homing establishes a repeatable "zero" position for each axis. The robot can be homed by pressing a button on the web Operator Control Panel, the web Virtual Manual Control Panel or via a program instruction.', + }, + -1022: { + "text": "Invalid homing parameter", + "description": "While executing the homing sequence an invalid parameter was encountered. This indicates that one of the Parameter Database values that controls homing (DataID 28xx) has been incorrect set. For example, an illegal homing method may be specified (DataID 2803) or the homing speed may be zero (DataID 2804).", + }, + -1023: { + "text": "Missed signal during homing", + "description": "During the homing operation with the robot moving, an error was detected prior to finding the signal that was expected. The detected error is most likely caused by an unexpected hardstop encountered or a over-travel limit switch tripping.", + }, + -1024: { + "text": "Encoder index disabled", + "description": 'This is normally generated by the homing routines. If a selected homing method tests for an encoder zero index signal and the encoder index is not enabled, this error is generated. This typically occurs if the "Encoder counts used for resolution calc" (DataID 10203) or the "Encoder revs used for resolution" (DataID 10204) are not properly setup.', + }, + -1025: { + "text": "Timeout enabling power", + "description": 'A request to enable robot power has failed to complete within the timeout period. Either a hardware failure has prevented power from coming on, or the timeout period is too short. Check the System Messages on the web interface Operator Control Panel for additional errors that may indicate a hardware failure. Verify that the controller is properly cabled. Try increasing the value of parameter "Timeout waiting for power to come on in sec" (DataID 262). This error may also occur as a GPL exception if power does not come on when the Controller.PowerEnabled method is used with a non-zero timeout parameter.', + }, + -1026: { + "text": "Timeout enabling amp", + "description": 'Robot power has been turned off during the robot power-on sequence because one or more power amplifiers have not become ready within the timeout period. Please try the following procedures: Check the System Messages on the web interface Operator Control Panel for additional errors that may indicate a hardware failure. Verify that the controller is properly cabled. Try increasing the value of parameter "Timeout waiting for amps to come on in sec" (DataID 264). If this occurs when the controller is first booted, try repeating the enable power operation after delaying for 1 minute. This error message can be generated if the robot includes absolute encoders that take a minute or so to complete their initialize before becoming ready to operate.', + }, + -1027: { + "text": "Timeout starting commutation", + "description": 'Robot power has been turned off during the robot power-on sequence because one or more motors have not completed their commutation sequence within the timeout period. Check the System Messages on the web interface Operator Control Panel for additional errors that may indicate a hardware failure. Try increasing the value of parameter "Timeout waiting for commutation in sec" (DataID 266). Verify that the controller is properly cabled and that the encoders and motors are wired correctly. See documentation section First Time Mechanism Integration and verify that the controller commutation parameters are set correctly for your motor and encoder combination. Verify that the FPGA firmware on the controller supports your motor and encoder combination.', + }, + -1028: { + "text": "Hard E-Stop", + "description": 'A hard E-Stop condition has been detected. Any robot motion in progress is stopped rapidly and robot power is turned off. One the following has occurred: A front panel E-Stop loop ("ESTOP_L 1" or "ESTOP_L 2") has been broken. The digital input signal specified by "Hard E-Stop DIN" (DataID 244) has been asserted. The parameter database item "Hard E-Stop" (DataID 243) has been set to TRUE. A "fatal" or "severe" error is detected and GPL automatically internally asserts an E-stop as a safety precaution to ensure that motor power is disabled. When a hard e-stop is asserted for any reason, if the "E-stop delay" (DataID 267) is set too short, it is possible that a "Amplifier under-voltage" error (-3109) will also be generated due to the DC motor bus voltage dropping before the amplifiers are disabled.', + }, + -1029: { + "text": "Asynchronous error", + "description": "An error signal from the servos or trajectory generator has been received by GPL, but no specific error code has been received. The error log entry immediately following this one normally indicates the actual error. Error signaling within the motion subsystem is a two-step process. When an error is first detected, a signal is sent to GPL immediately so that a controlled deceleration sequence can begin. This first signal generates a -1029 error code. Several milliseconds later, a more specific error code is sent to identify the source of the error. This second error code overwrites the -1029 error. Error -1029 is only seen if the error log is sampled after the initial error signal is received and before the specific error code is received.", + }, + -1030: { + "text": "Fatal asynchronous error", + "description": 'A severe error signal from the servos or trajectory generator has been received by GPL, but no specific error code has been received. The error log entry immediately following this one normally indicates the actual error. Error signaling within the motion subsystem is a two-step process. When a severe error is first detected, a signal is sent to GPL immediately so that a controlled deceleration sequence can begin. This first signal generates a -1030 error code. Several milliseconds later, a more specific error code is sent to identify the source of the error. This second error code overwrites the -1030 error. Error -1030 is only seen if the error log is sampled after the initial error signal is received and before the specific error code is received. Unlike standard errors, a severe error prevents robot power from being enabled until the controller is rebooted or "Reset fatal error" (DataID 247) is set to 1.', + }, + -1031: { + "text": "Analog input value too small", + "description": 'When reading an analog input signal, if after the scale and offset is applied, the value of the signal is lower than the limit set by "Gen AIO In min scaled value" (DataID 526), the analog value is set to the minimum value and this error is generated.', + }, + -1032: { + "text": "Analog input value too big", + "description": 'When reading an analog input signal, if after the scale and offset is applied, the value of the signal is higher than the limit set by "Gen AIO In max scaled value" (DataID 527), the analog value is set to the maximum value and this error is generated.', + }, + -1033: { + "text": "Invalid Cartesian value", + "description": "For robots with less than 6 independent degrees-of-freedom, certain combinations of tool orientations and positions are not possible. This does not indicate an axis limit stop error such as when a program attempts to move a linear axis beyond its end of travel. This error refers to positions and orientations that are not possible even if each axis of the robot has an unlimited range of motion. For example, for a 4-axis Cartesian robot with a theta axis, the tool cannot be rotated about the world X or Y axis. So if an instruction has a destination that requires that the tool be moved from pointing down to pointing up, this error will be generated.", + }, + -1034: { + "text": "Negative overtravel", + "description": "This is generated when the optional negative travel limit hardware switch has been tripped. This error indicates that a specified axis it outside of its permitted range-of-motion.", + }, + -1035: { + "text": "Positive overtravel", + "description": "This is generated when the optional positive travel limit hardware switch has been tripped. This error indicates that a specified axis it outside of its permitted range-of-motion.", + }, + -1036: { + "text": "Kinematics not installed", + "description": 'An operation was invoked that requires that the system be able to convert between joint and Cartesian (XYZ) coordinates. However, the system software has not been configured with a robot kinematics (geometry) module. The kinematic modules are selected using the "Robot types" (DataID 116) and are described in the Kinematics Library section of the PreciseFlex Library. Select an appropriate module and restart the system. If there is not an appropriate kinematics module, contact Precise.', + }, + -1037: { + "text": "Motors not commutated", + "description": "The operation that was attempted may not require that the robot's motors be homed, however, they must have their commutation reference established. Setting the commutation reference provides the controller with the knowledge of how to energize the individual motor phases as the motor rotates. For example, motors can be placed into torque control mode after they have been commutated and without homing the motors.", + }, + -1038: { + "text": "Project generated robot error", + "description": "This error is never generated automatically and is provided solely as a convenience for GPL application programs. GPL projects can use the Throw instruction to generate this special error to indicate special application errors that are robot specific.", + }, + -1039: { + "text": "Position too close", + "description": "This indicates that an XYZ destination is too close to the center of the robot and cannot be reached. For example, your robot may have an inner and an outer rotary link that dictates the radial distance of the gripper. If the outer link is shorter than the inner link, there will be a circular region at the center of the robot that cannot be accessed. In other cases, if the inner and outer link are the same lengths, the robot may be able to reach the center position, but such a position might be a mathematical singularity where two or more joints degenerate into the same motion. These types of conditions are signaled by this error code.", + }, + -1040: { + "text": "Position too far", + "description": "This indicates that an XYZ destination is beyond the robot's reach. This is typically generated by robot's with rotary links where the position is beyond the range of the fully outstretched links. To avoid excessive joint rotation speeds, this error may be signaled a few degrees before the fully extended position in manual jog mode or when the robot is moving along a straight-line path.", + }, + -1041: { + "text": "Invalid Base transform", + "description": "The Base transformation for a robot is required to have a zero pitch value. That is, a Base transform can translate the robot in all three directions and can rotate the robot about the world Z-axis, but it can not rotate the robot about the world X-axis or Y-axis.", + }, + -1042: { + "text": "Can't change robot config", + "description": "A motion was initiated that attempted to change the robot configuration (e.g. Righty vs. Lefty) when such a change is not permitted. For example, you cannot change the robot configuration during a Cartesian straight-line motion. Normally, if you specify a motion destination as a Cartesian Location, any differences in configuration are ignored. However, if you specify a Angles Location as the destination for a Cartesian motion and the Angles correspond to a different configuration, this error will be signaled.", + }, + -1043: { + "text": "Asynchronous soft error", + "description": "A soft error signal from the servos or trajectory generator has been received by GPL, but no specific error code has been received. The error log entry immediately following this one normally indicates the actual error. Error signaling within the motion subsystem is a two-step process. When an error is first detected, a signal is sent to GPL immediately so that a controlled deceleration sequence can begin. This first signal generates a -1043 error code. Several milliseconds later, a more specific error code is sent to identify the source of the error. This second error code overwrites the -1043 error. Error -1043 is only seen if the error log is sampled after the initial error signal is received and before the specific error code is received. Unlike standard errors, a soft error does not disable robot power.", + }, + -1044: { + "text": "Auto mode disabled", + "description": 'This indicates that: (1) the Auto/Manual hardware input signal has been switched to Manual and motor power has been disabled or (2) an automatic program controlled motion was attempted, but the Auto/Manual hardware input signal is set to Manual. When the Auto/Manual signal is set to Manual, only Jog ("Manual control") mode is permitted.', + }, + -1045: { + "text": "Soft E-STOP", + "description": 'A soft E-Stop condition has been detected. Any robot motion in progress is stopped rapidly but robot power is left on. One of the following has occurred: The GPL property Controller.SoftEStop has been set to TRUE. The GPL console command SoftEStop has been executed. The digital input signal specified by "Soft E-Stop DIN" (DataID 246) has been asserted. The parameter database item "Soft E-Stop" (DataID 245) has been set to TRUE.', + }, + -1046: { + "text": "Power not enabled", + "description": "An operation was attempted, such as trying to Attach to a robot, and power to the robot is required but has not yet been enabled. Enable high power and repeat the operation.", + }, + -1047: { + "text": "Virtual MCP in Jog mode", + "description": "An operation was attempted, such as trying to Attach to a robot, but the robot is already attached and is being controlled in Jog control mode via the web based Virtual MCP. Go to the Web Virtual MCP, place the robot into computer control mode, and retry the operation.", + }, + -1048: { + "text": "Hardware MCP in Jog mode", + "description": "An operation was attempted, such as trying to Attach to a robot, but the robot is already attached and is being controlled in Jog control mode via the hardware MCP. Go to the MCP, place the robot into computer control mode, and retry the operation.", + }, + -1049: { + "text": "Timeout on homing DIN", + "description": 'During the homing operation, a digital input signal that is specified for a motor via the "Wait to home axis DIN" (DataID 2812) failed to turn on before the timeout period defined by the "Timeout on home axis, sec" (DataID 2813) expired.', + }, + -1050: { + "text": "Illegal during joint motion", + "description": "An operation or program instruction has been executed that requires that the robot either be stopped or moving in a Cartesian control mode. However, the robot is executing a program controlled joint interpolated motion. Wait for the joint interpolated motion to terminate before executing this instruction.", + }, + -1051: { + "text": "Incorrect Cartesian trajectory mode", + "description": "An operation or program instruction has been executed that requires that the Trajectory Generator be in a specific Cartesian mode, but the mode was incorrect. Some possible problems could be: An instruction attempted to start the Real-time Trajectory Modification mode, but this mode is already active. An instruction attempted to set Real-time Trajectory Modification parameters, but this mode is not active.", + }, + -1052: { + "text": "Beyond conveyor limits", + "description": "Indicates that the position for a motion being planned is beyond the upstream or downstream limits of the referenced conveyor belt. This error is generated in the following circumstances: A motion is being planned that is relative to a conveyor belt and the final position is projected to be out of the conveyors downstream limit before the robot can reach this position. If a position is upstream of the upstream limit, the system pauses thread execution until the position comes within the limits and no error is generated.", + }, + -1053: { + "text": "Beyond conveyor limits while tracking", + "description": "Indicates that a position is beyond the upstream or downstream limits of a conveyor belt while the robot is tracking the belt. This error is generated in the following circumstances: The robot is moving relative to a conveyor belt and the final destination for the motion or the instantaneous position is beyond either limit of the conveyor belt.", + }, + -1054: { + "text": "Can't attach Encoder Only robot", + "description": "An Auto Execution task (such as that for the Manual Control Pendant) or a GPL project has attempted to gain control of a robot by executing a Robot.Attach method or similar function, but the specified robot is an Encoder Only module. This type of kinematic module can be accessed to read the position of an encoder, but cannot be used to drive the encoder. Therefore, it is not legal to Attach this type of robot. Use the Robot.Selected method instead, if you only need read-only access to the robot.", + }, + -1055: { + "text": "Cartesian motion not configured", + "description": 'This error is generated if you attempt to perform a Cartesian motion either in manual control or program control mode, and some key Cartesian motion parameters have not been initialized. Typically, this is caused by: The "100% Cartesian speeds" (DataID 2701) being set to 0 instead of their proper positive non-zero values. The "100% Cartesian accels" (DataID 2703) being set to 0 instead of their proper positive non-zero values.', + }, + -1056: { + "text": "Incompatible robot position", + "description": "The current robot position is not compatible with the requirements of the operation that has been initiated. Situations where this error can be generated include: For the RPRR kinematic module, the two yaw axes have been defined to move at a fixed offset relative to each other. However, at the start of a straight line motion, the yaw axes are not in proper alignment.", + }, + -1057: { + "text": "Timeout waiting for front panel button", + "description": 'For Category 3 (CAT-3) systems, the front panel "High Power Enable" button or the digital input specified by DataID 268 must transition from low to high within 30 seconds of a power-on request for high power to actually be enabled. This error is generated if the low to high transition is not seen within this time period.', + }, + -1058: { + "text": "Commutation disabled by servos", + "description": "This error indicates that the servos have invalidated the motor commutation in response to an error condition. For example, a severe encoder error may have occurred. Check the Error Log for additional error messages that indicate why commutation was disabled. In most cases, re-enabling robot power causes the motor commutation to be re-established and this error to be cleared.", + }, + -1059: { + "text": "Homed state disabled by servos", + "description": "This error indicates that the servos have marked an axis as not homed in response to an error condition. For example, a severe encoder error may have occurred. Check the Error Log for additional error messages that indicate why homing was invalidated. You cannot operate the axis under program control until you re-execute the axis homing procedure. You can use the MCP in joint control mode to move an axis that is not homed.", + }, + -1060: { + "text": "Remote E-STOP (node n)", + "description": 'Indicates that servo network node n is asserting an E-Stop condition. Whether E-Stop is connected to the main controller or to a remote node depends on your robot model and configuration. See the explanation for error -1028 "Hard E-Stop" for possible reasons for this error.', + }, + -1061: { + "text": "Illegal when robot moving", + "description": "This error is generated when a robot is moving under computer controller and an operation is performed that requires that the robot be stopped. For example, this occurs if the Set Payload console command is issued while the robot is moving.", + }, + -1062: { + "text": "Certified Safety Zones not supported", + "description": "This error indicates that the robot's PAC files include the specification of a Safety Certified Safety Zone, but the robot does not support this feature. These safety zones ensure that the robot does not exceed certain speed limits within specified physical areas. Typically, this error occurs if the wrong type of safety zone is specified or if the robot's safety certification functions are not operational but should be.", + }, + -1063: { + "text": "Certified Safety Zones cannot be rotated", + "description": "This error indicates that the robot's PAC files include the specification of a Safety Certified Safety Zone, but the orientation of the zone is rotated. For computational reasons, Certified Safety Zones must always be non-rotated rectangular volumes. Ensure that the Yaw, Pitch, and Roll parameters for the safety zone are zero. Alternately, use an Uncertified Safety Zone if you do not require a safety certified operation and would like to use a rotated safety zone.", + }, + -1064: { + "text": "Tool tip violates Safety Zone", + "description": 'This error indicates that the robot\'s tool tip (TCP) violates one or more defined Uncertified Safety Zones. A violation will occur if the TCP enters a "keep out" or exits a "keep in" safety zone. This error can occur if the end position of a program-controlled motion is in error, anytime during a Cartesian or joint programmed controlled motion, or anytime during a World, Tool, or Joint manual control motion. Once a violation has occurred, the robot must be backed out when high power is disabled or by using manual control Free mode or by using manual control Joint, Tool, or World mode (so long as the manual jog motion reduces the violation of the safety zone).', + }, + -1065: { + "text": "Tool tip speed too high in Safety Zone", + "description": "This error indicates that the robot's tool tip (TCP) is either moving down too fast in Z or moving too fast in the XY plane in a defined speed-limited non-rotated rectangular Safety Certified Safety Zone. Robots like the PreciseFlex 300/400/3400 can operate safely at their highest speed throughout their working volume, except when the tool tip is moving down and might pin an operator's hand to a hard surface. Additionally, robots like the PreciseFlex DD need to limit their horizontal speed when they are on near-vertical surfaces. If this error occurs, reduce the speed of the robot before it enters these safety zones and retry the motion. Please consult the operating manuals for these robots to determine when certified speed-limited safety zones must be used to ensure the safe operation of these robots.", + }, + -1500: { + "text": "Invalid Data ID code", + "description": "The Data ID code and the specified index values do not form a valid data reference. Check that the appropriate index values are specified with this Data ID.", + }, + -1501: { + "text": "Unknown Data ID code", + "description": "The Data ID code specified is not known by GPL. Check that the Data ID is valid. Some Data ID codes only become known if certain software options are enabled or configurations are selected. For example, you cannot specify absolute encoder parameters if you do not have any absolute encoders configured. Check that you are not specifying codes for a component that is not configured.", + }, + -1502: { + "text": "Inconsistent duplicate Data ID code", + "description": "For networked servo systems, the definitions for a Data ID must be the same for the master and all slave nodes. This error indicates some differences were detected. Verify that compatible software is loaded on the master node and all slave nodes.", + }, + -1505: { + "text": "Uninitialized parameter database", + "description": "The parameter database was not initialized properly at system startup. This error indicates an internal system error and should never been seen.", + }, + -1507: { + "text": "Must be master node", + "description": "For networked servo systems, some data and operations are only allowed on the master node. An attempt has been made to access such data or operations directly from a slave node.", + }, + -1509: { + "text": "Invalid data index", + "description": "The data index value specified in a Data ID reference is invalid. Probably an array index has been specified that is greater than the actual array size.", + }, + -1510: { + "text": "Database internal consistency error", + "description": "An internal error has occurred during a database reference. The parameter specified along with the Data ID may not be valid. This error should never be seen.", + }, + -1511: { + "text": "Invalid pointer value", + "description": "An internal error has occurred during a database reference. The parameter specified along with the Data ID may not be valid. This error should never be seen.", + }, + -1512: { + "text": "Undefined callback routine", + "description": "An internal error has occurred during a database reference. This error should never be seen.", + }, + -1513: {"text": "Invalid data from callback routine", "description": ""}, + -1514: { + "text": "Invalid parameter array index", + "description": "A parameter index value specified in a Data ID reference is invalid. Probably the robot number or other index value is too large.", + }, + -1515: { + "text": "Invalid data file format", + "description": 'Some or all of the data in a configuration ".pac" file could not be read because it did not have the expected format. Either the file has become corrupted or an attempt to manually edit the file has introduced some errors.', + }, + -1516: { + "text": "Invalid number of axes or motors", + "description": "The total number of axes or motors specified for a robot violates the number permitted. For example, this can occur if you specify 5 axes for the XYZTheta robot module. More subtly, if you configure the maximum number of motors supported by the system and then attempt to turn on split-axis control for one or axes, this requires that more motors be added.", + }, + -1517: { + "text": "Invalid signal number", + "description": "A specified analog or digital signal number is not within the allowed range of values for the signal type expected. Change the signal number to a valid value.", + }, + -1518: { + "text": "Undefined signal number", + "description": "A specified analog or digital signal is not currently installed in the controller. Remote signals may dynamically change between installed and uninstalled status depending on the state of the remote device. Verify that all remote devices are connected and active.", + }, + -1519: { + "text": "Output signal required", + "description": "An input signal has been specified when an output signal is required. Change the signal number to an appropriate value.", + }, + -1520: { + "text": "Can't open parameter DB file", + "description": "During initialization, one or more of the parameter database files (*.pac files) could not be found on the controller's flash drive in the folder /flash/config. Restore the files and reboot your controller.", + }, + -1521: { + "text": "Invalid parameter DB file not loaded", + "description": "A parameter database file (*.pac file) was found in the controller's flash drive folder /flash/config but the file format is not valid. Restore the file and reboot your controller.", + }, + -1522: { + "text": "Cannot write value when power on", + "description": "An attempt has been made to alter a Parameter Database value when motor power is enabled. However, the parameter can only be modified if motor power is disabled. This is a safety precaution to ensure that the robot does not move erratically when the value is modified. To avoid this error condition, disable motor power and retry modifying the Parameter Database value.", + }, + -1523: { + "text": "Coupled axis must be on same node", + "description": "Two coupled robot axes, for example two axes that are coupled in Split-axis control, have been defined to be driven by amplifiers that are contained on two different physical controllers that are connected on a Servo Network. This is not permitted. The two axes must be driven by amplifiers on the same physical controller.", + }, + -1524: { + "text": "Saving PDB values to flash not allowed", + "description": "A special command has been issued that inhibits any modified Parameter Database values that are in memory from being written to the flash disk. This command is typically issued by system programs that temporarily modify PDB values, which if written to the flash, would corrupt the valid data that is already contained on the flash. If you wish to modify PDB values and permanently save the changes, reboot the controller to clear this special mode.", + }, + -1525: {"text": "Servo network not allowed with EtherCAT", "description": ""}, + -1526: {"text": "EtherCAT configuration mismatch", "description": ""}, + -1527: {"text": "EtherCAT object not supported", "description": ""}, + -1528: {"text": "Too many EtherCAT slaves", "description": ""}, + -1550: { + "text": "Data ID cannot be logged", + "description": "A data log specification refers to a Data ID that cannot be logged. Some values require too much computation to permit real-time logging.", + }, + -1551: { + "text": "Invalid when datalogger enabled", + "description": "An attempt has been made to perform an operation that is not valid while the datalogger is active. Disable the datalogger and try again.", + }, + -1552: { + "text": "Datalogger not initialized", + "description": 'An attempt has been made to start the datalogger before it has been initialized. Normally this error is not seen if the web interface is used for logging. It may be seen if database parameter "Data logger enable" (DataID 753) is set before parameter "Data logger load command" (DataID 752) is set.', + }, + -1553: { + "text": "No data items defined", + "description": "An attempt has been made to start the datalogger before defining any items to log.", + }, + -1554: { + "text": "Trigger Data ID must be logged", + "description": "A datalogger trigger specification refers to a Data ID that is not specified as a logged item. Triggers can only specify Data ID values that are also being logged.", + }, + -1555: { + "text": "Trigger Data IDs on different nodes", + "description": "In a networked servo system, a datalogger trigger specification attempted to compare two Data IDs whose values exist on different network nodes. A trigger can only compare items that are on the same node.", + }, + -1556: { + "text": "Trigger not allowed for Data ID", + "description": "Some Data ID values cannot be used as trigger values. For example Cartesian positions or velocities are computed from logged data after logging is complete, so these values cannot be tested at logging time.", + }, + -1558: { + "text": "Too many remote data items", + "description": "In a servo network or GSB-based system, items collected by the datalogger from slave nodes are streamed from the slave to the master in real time. The amount of data that can be streamed depends on the trajectory period, the datalogger sampling interval, and the size and number of data items being logged from the slave. This error indicates that you have requested more data to be logged than can be streamed. Reduce the number of data items requested from the slave or increase the datalogger sampling interval.", + }, + -1560: { + "text": "Invalid when CPU Monitor enabled", + "description": "An attempt was made to start the CPU Monitor utility or read data from it when it was active. Wait until the current monitor interval is complete, or cancel the current monitor and try again.", + }, + -1561: { + "text": "No CPU Monitor data available", + "description": "An attempt was made to read the CPU Monitor data before the CPU Monitor utility was run. Execute the CPU Monitor utility and try again.", + }, + -1600: { + "text": "Power off requested", + "description": 'Robot power had been turned off by request. One of the following occurred: The GPL property Controller.PowerEnabled has been set to FALSE. The parameter database item "Power enable" (DataID 241) has been set to FALSE.', + }, + -1601: { + "text": "Software Reset: using default settings", + "description": 'When the system was restarted, the "Software Reset" switch on the MCIM Selector Switch was set to the ON state. This forced the system to read in the default configuration files (*.PAC) instead of the standard files. This feature should be used if a configuration files becomes corrupted or a setting inadvertently make the system unusable.', + }, + -1602: { + "text": "External E-STOP", + "description": 'A front panel E-Stop loop ("ESTOP_L 1" or "ESTOP_L 2") has been broken and the status signal "External ESTOP_L" is asserted, indicating that external equipment is the source of the E-Stop.', + }, + -1603: { + "text": "Watchdog timer expired", + "description": "The hardware watchdog timer on the CPU board has expired and robot power is disabled. This error may indicate a hardware failure or software bug and should not normally be seen. If this error persists, please contact customer service to report this problem.", + }, + -1604: { + "text": "Power light failure", + "description": "The robot power-on light interfaced via the front panel connector has burned out. Robot power is turned off and may not be turned back on. Please contact customer service.", + }, + -1605: { + "text": "Unknown power off request", + "description": "Robot power is off or was turned off, but no request was made to turn it off. This error may indicate a hardware failure or software bug and should not normally be seen.", + }, + -1606: { + "text": "E-STOP stuck off", + "description": "When the controller is restarted, a diagnostic program has detected an E-Stop circuit that is stuck in the off state. Robot power cannot be turned on until the stuck E-Stop circuit is repaired and the controller is restarted.", + }, + -1607: { + "text": "Trajectory task overrun", + "description": 'The periodic trajectory generation system task was unable to complete its executing during its allotted period. The parameter "Trajectory Generator update period in sec" (DataID 600) is set too small for the type of robot you are using.', + }, + -1609: { + "text": "E-STOP timer failed", + "description": "When the controller is restarted, a diagnostic program has detected that the hardware E-Stop timer is not triggering as expected. Robot power cannot be turned on and you must restart the controller. If this error persists, please contact customer service to report this problem.", + }, + -1610: { + "text": "Controller overheating", + "description": 'In GPL 3.0 H1 and later, this error is reported in addition to -1617 CPU overheating, -3144 Amplifier overheating or -3145 Motor overheating. These other errors provide detailed information on the specific component that is generating the overheating error condition. Prior to GPL 3.0 H1, this is the only error message that is generated when the CPU or one of the power amplifiers has exceeded its permitted operating temperature. For the CPU, the limit is 90 degrees Celsius. For the amplifiers of the G3xxx and G1xxxA/B controllers (except for the G3x3xA 30A version), the limit is 80C. For the amplifiers of the G3x3xA 30A version, the limit is 100C. For the G2xxx controllers, the amplifier chips are equipped with internal temperature monitoring to protect the power modules, but they do not have temperature sensors that can be read. If the CPU is overheating, it will switch off in 5 minutes and then you will need to reboot the controller. For all versions of software, robot power is automatically turned off and cannot be turned on until the overheated device cools. In a servo network, the overheated device may reside in the master controller or one of the slaves. This error is issued each time a command to enable robot power is received until all temperatures return to acceptable levels. DataIDs 126, 12605 and 12110 contain the "CPU temperature" and "Amp temperature", and "Motor temperatures" respectively, and can be displayed to determine which device is overheating.', + }, + -1611: { + "text": "Auto/Manual switch set to Manual", + "description": 'A attempt has been made to enable robot power, using one of the standard means, when the Auto/Manual switch is set to Manual. The robot power has not been enabled. The standard means of enabling power are by using: the PowerEnabled property of the Controller class in GPL, the "Enable Power" parameter (DataID 241), the "Automatic power on" parameter (DataID 240), the power enable digital input (defined by DataID 242), or CANopen.', + }, + -1612: { + "text": "Power supply relay stuck", + "description": 'An attempt to turn on power has failed because an internal diagnostic test indicates that the motor power supply relay is stuck in the "on" position and may be unsafe. Reboot your controller. If this error persists, contact customer service.', + }, + -1613: { + "text": "Power supply shorted", + "description": "Power has been disabled because the motor power supply has detected that it is shorted. Check the wiring and the amplifiers to make sure no short is present.", + }, + -1614: { + "text": "Power supply overloaded", + "description": 'Power has been disabled because the motor power supply has detected an overload condition. Verify that you are not attempting to draw more than the rated current from the power supply. Verify that the parameter "Delay after turning on power in sec" (DataID 263) is set long enough to allow power to stabilize before the amplifiers are enabled.', + }, + -1615: { + "text": "No 3-phase power", + "description": "A power supply has been configured to use 3-phase power, but the 3rd phase is not connected or has failed. It is still possible to run the power supply in this mode at low power, but attempting to draw high power may overheat the power supply.", + }, + -1616: { + "text": "Shutdown due to overheating", + "description": 'The CPU exceeded its operating temperature for too long a period of time and the controller automatically turned off. When the CPU first exceeds its maximum permitted limit of 90 degrees Celsius, a "Controller overheating" error (-1610) is generated and motor power is disabled. If the temperature does not decline within 5 minutes, error code -1616 is logged to the flash in a special system file ("/flash/system/errlog.txt")and the controller is shutdown. The next time the controller is restarted, the -1616 error code will be displayed in the error log. This error code will be displayed every time the controller is restarted until the error log is cleared.', + }, + -1617: { + "text": "CPU overheating", + "description": 'Either the master or a slave CPU board temperature has exceeded its maximum temperature of 90 degrees Celsius. See CPU temperature (DataID 126) for the current temperature of all CPUs. If the temperature does not fall below 90 degrees, the controller will switch off in 5 minutes and the controller must be restarted. Normally this error is followed by the generic error -1610 "Controller overheating". Ensure there is good air circulation around the controller and check that the fans are not blocked. The Installation section of the controller hardware manuals provide guidelines for ventilating and cooling controllers.', + }, + -1618: { + "text": "Power supply not communicating", + "description": 'Some PreciseFlex™ power supplies communicate with the PreciseFlex™ controller for identification, error detection, and safety interlocks. This communications has failed. Be sure that all cables between the controller and power supply are installed properly. Verify that DataID 128 "Power supply type" is set properly for the type of power supply you are using.', + }, + -1619: { + "text": "Power disabled by GIO timeout", + "description": 'This errors occurs in two possible cases: If the error message indicates node 8 and the robot has a "safety board" (e.g. a PreciseFlex™ 3400), this error indicates that the safety board has stopped responding for 32 trajectory periods (approximately 0.128 seconds). This error disables the robot motor power and this error cannot be disabled. Otherwise, the error is related to the GIO board indicated by the node number in the error message. The GIO has stopped responding for 32 trajectory periods and the "GIO mode" (DataID 574) has bit mask 2 set, so that robot motor is disabled. If bit mask 2 is clear, GIO board timeouts do not disable robot motor power. This error might be due to: a wiring problem; improperly installed RS485 bus termination jumpers on the main CPU, Safety, GIO or GSB boards; or excessive noise in another cable, motor, or amplifier that is close to the RS485 cable. Issuing a "GSB Show" command from the System Web Console may provide additional information about the source of the error.', + }, + -1620: { + "text": "Safety diagnostics failed", + "description": 'In enhanced CAT3 mode, the safety diagnostic checks have failed. Robot power cannot be enabled. Previous error messages may indicate the cause of the failure. Use the "Show Safety" web console command for more details. Once the problem has been resolved, try to enable power again.', + }, + -1621: { + "text": "Safety software configuration mismatch", + "description": 'The safety configuration bits in DataID 2031 "Enhanced safety mode" do not match the settings of DataID 117 "Safety mode" or require hardware not present in your controller. For example, you set bit &H001 of DataID 2031 but DataID 117 is not set to 4 or 5 or you set bit &H200 in DataID 2031 but do not have a 3-phase power supply. Verify that DataID 117 is correct. For hardware-related errors, contact Brooks support. If a numeric value is displayed with this error, the value indicates what aspect of the safety configuration did not match. See DataID 2031 "Enhanced safety mode" for a description of the bits that form this value.', + }, + -1622: { + "text": "Not allowed in safety mode", + "description": 'You have attempted to perform some operation that is not allowed in your current safety mode. For example, you have attempted to enable power from a user program while DataID 117 "Safety mode" is set to 5 (Enhanced CAT-3 full).', + }, + -1623: { + "text": "ESTOP1 stuck on", + "description": "The safety diagnostics in enhanced CAT3 mode have determined that ESTOP channel 1 is asserted. Probably your channel 1 ESTOP loop is open. Close the loop and retry the operation.", + }, + -1624: { + "text": "ESTOP2 stuck on", + "description": "The safety diagnostics in enhanced CAT3 mode have determined that ESTOP channel 2 is asserted. Probably your channel 2 ESTOP loop is open. Close the loop and retry the operation.", + }, + -1625: { + "text": "ESTOP1 stuck off", + "description": "The safety diagnostics in enhanced CAT3 mode have determined that ESTOP channel 1 cannot be asserted by the internal force-ESTOP circuit. Your channel 1 ESTOP may be wired incorrectly.", + }, + -1626: { + "text": "ESTOP2 stuck off", + "description": "The safety diagnostics in enhanced CAT3 mode have determined that ESTOP channel 2 cannot be asserted by the internal force-ESTOP circuit. Your channel 2 ESTOP may be wired incorrectly.", + }, + -1627: { + "text": "Motor power stuck on", + "description": 'The safety diagnostics in enhanced CAT3 mode have determined the DC voltage indicated by DataID 12684 ("Nominal DC bus voltage, volt") is too high when the motor power has been turned off. This may indicate a failed internal safety circuit or an unplugged cable.', + }, + -1628: { + "text": "Motor power stuck off", + "description": 'The safety diagnostics in enhanced CAT3 mode have determined the DC voltage indicated by DataID 12684 ("Nominal DC bus voltage, volt") is too low when the motor power has been turned on. This may indicate a failed internal safety circuit or an unplugged cable.', + }, + -1629: { + "text": "FFC ENA_PWR' signal stuck on", + "description": 'In a controller that includes a FFC safety board, the safety diagnostics in enhanced CAT3 mode have determined the DC voltage indicated by DataID 12684 ("Nominal DC bus voltage, volt") is too high when the motor power has been turned off. Probably the FFC_ENA_PWR\' signal on the FFC board is stuck on or this signal from the controller is shorted high. When the safety board is present, the output from the motor power supply should be off when the controller commands that motor power should be disabled. If the output of motor power supply is on, there is a problem with the motor power disable signal and its associated circuits.', + }, + -1630: { + "text": "FFC ENA_PWR' signal stuck off", + "description": 'In a controller that includes a FFC safety board, the safety diagnostics in enhanced CAT3 mode have determined the DC voltage indicated by DataID 12684 ("Nominal DC bus voltage, volt") is too low when the motor power has been turned on. Probably the FFC_ENA_PWR\' signal on the FFC board is stuck off or this signal from the controller is shorted low.', + }, + -1631: { + "text": "Power dump circuit failed", + "description": 'The safety diagnostics in enhanced CAT3 mode have determined the DC voltage indicated by DataID 12684 ("Nominal DC bus voltage, volt") is not falling quickly enough when power is turned off. This indicates that the power dump circuit has failed or is missing. Verify that you are using the correct robot *.pac files with this robot.', + }, + -1632: { + "text": "At least one motor must be enabled", + "description": 'The safety diagnostics in enhanced CAT3 mode have determined that there are no motors enabled for the robot. At least one motor must be present to perform the safety tests. Check the values for DataID 2105 ("Simulate servo interface") and DataID 2026 ("Motor disable mask") to make sure at least one motor is enabled. If you really want all motors disabled, you must also disable safety mode using DataID 117.', + }, + -1633: { + "text": "Hardware watchdog timer failed to disable power", + "description": 'The safety diagnostics in enhanced CAT3 mode have determined the DC voltage indicated by DataID 12684 ("Nominal DC bus voltage, volt") is too high after the hardware watchdog timer has expired. There may be a hardware problem with the watchdog timer, or you have attempted to enable safety mode on an older controller that does not contain a hardware watchdog timer.', + }, + -1634: { + "text": "FPGA watchdog timer failed to disable power", + "description": 'The safety diagnostics in enhanced CAT3 mode have determined the DC voltage indicated by DataID 12684 ("Nominal DC bus voltage, volt") is too high after the watchdog timer in the FPGA has expired. There may be a hardware problem with the watchdog timer.', + }, + -1635: { + "text": "FPGA watchdog trigger stuck on", + "description": "The FPGA indicates that the watchdog timer is triggered even though it is being polled normally. There may be a hardware problem with the watchdog timer or the FPGA.", + }, + -1636: { + "text": "FPGA watchdog trigger stuck off", + "description": "The FPGA indicates that the watchdog timer is not triggered even though it is not being polled. There may be a hardware problem with the watchdog timer or the FPGA.", + }, + -1700: { + "text": "Cannot get local host name", + "description": "The Ethernet network is not configured properly. Check the parameter values for DataIDs 420, 421 and 422.", + }, + -1701: { + "text": "Cannot get local host address", + "description": "The Ethernet network is not configured properly. Check the parameter values for DataIDs 420, 421 and 422.", + }, + -1702: { + "text": "Connection refused", + "description": "An attempt to connect to a TCP server was refused. Be sure your IP address and port numbers are correct. Be sure the server is ready to accept connections.", + }, + -1703: { + "text": "No connection", + "description": "An attempt was made to send or receive on socket that was not connected to a TCP or UDP port.", + }, + -1704: { + "text": "Invalid network address", + "description": "The IP address specified cannot be accessed.", + }, + -1705: { + "text": "Network timeout", + "description": "A network operation did not complete within the allowed time period. Depending on the operation, the connection may or may not be closed.", + }, + -1706: { + "text": "Already connected", + "description": "An attempt was made to connect a socket to an IP Address and port when there is already a connection.", + }, + -1707: { + "text": "Socket not open", + "description": "An attempt was made to use a network socket that is not open.", + }, + -1708: { + "text": "Connection closed", + "description": "An attempted was made to use a socket whose connection has been closed either locally or by the remote endpoint.", + }, + -1709: { + "text": "Invalid protocol", + "description": "A message was received that does not follow a known communications protocol.", + }, + -1710: { + "text": "Invalid multicast address", + "description": "The IP address is not a valid multicast address. The recommended range for multicast IP addresses is from 239.192.0.0 through 239.195.255.255.", + }, + -1720: { + "text": "Web interface not enabled", + "description": 'An external system attempted to access one of the controller\'s web pages, but access via the web server has been disabled. To enable the web interface, modify the value of the "Web password security" (DataId 450) database parameter.', + }, + -1730: { + "text": "Modbus/RIO exception: n", + "description": "A MODBUS or RIO board request failed with exception code n. The standard MODBUS exception codes are: 1 = Illegal function, 2 = Illegal data address, 3 = Illegal data value, 4 = Device has failed.", + }, + -1731: { + "text": "Modbus/RIO device timeout", + "description": "A MODBUS device or RIO board is not responding or is not responding within the specified timeout period.", + }, + -1732: { + "text": "Modbus/RIO disable requested", + "description": 'A MODBUS device or RIO board connection has closed because of a user request. For example, you have set the parameter "Remote IO module enable" (DataID 554) to zero.', + }, + -1740: { + "text": "Servo latency too large", + "description": "The master controller cannot connect with a slave controller because the measured communications latency is too large or is negative. Typically there should be no more than 80 microseconds of latency between the nodes. Verify that both controllers are on the same LAN and external traffic is low. Verify that your Ethernet switch is operating properly. Verify that the servo network protocols for all nodes are compatible.", + }, + -1750: {"text": "EtherCAT error", "description": ""}, + -1751: {"text": "EtherCAT slave not responding", "description": ""}, + -1752: {"text": "EtherCAT synchronous message timeout", "description": ""}, + -1753: {"text": "EtherCAT slave not ready", "description": ""}, + -1754: {"text": "EtherCAT disabled", "description": ""}, + -2101: { + "text": "Vertical search limit violated", + "description": "This error is generated during the vertical motion to search for the height of the nest. This indicates that the motion search distance limit was encountered before the specified force level was achieved.", + }, + -2102: { + "text": "Horizontal search limit violated", + "description": "This error is generated during a horizontal motion to search for the edges of the nest. This indicates that the motion search distance limit was encountered before the specified force level was achieved.", + }, + -2103: { + "text": "Insufficient number of Tool X samples", + "description": "The nest height detection routines did not collect the minimum required number of force samples either when the plate was in free air or when the plate was pressing against the nest.", + }, + -2104: { + "text": "Insufficient number of Tool Tx torque samples", + "description": "The nest orientation detection did not collect the minimum required number of torque samples either when the plate was rotated between the edges of the nest.", + }, + -2105: { + "text": "No free air during Yaw detection", + "description": "When using Tx to determine the nest Yaw angle, the line fit to the first samples overlapped with the line fit to the last of the samples. This indicates that no free air region was detected.", + }, + -2106: { + "text": "Motor exceeded peak torque", + "description": "One or more motors was generating the peak permitted torque. The torque will be saturated and the Cartesian gripper forces and torques will not be accurate. The offending motor numbers are listed.", + }, + -2107: { + "text": "GPL 3.2 or later required", + "description": "This package relies on features contained in GPL 3.2 or later.", + }, + -2850: { + "text": "Invalid Gripper Type", + "description": "This command requires a servoed gripper and none is detected.", + }, + -2851: { + "text": "Invalid Station ID", + "description": "A station ID less than 1 or greater than the maximum number of stations has been specified.", + }, + -2852: { + "text": "Invalid robot state to execute command", + "description": "The command cannot be executed while the robot is in its current state. For example, you cannot issue a TeachPlate command while the robot is moving.", + }, + -2853: { + "text": "Rail not at correct station", + "description": "The optional rail is not currently at or moving toward the destination station for this command. Issue a MoveRail command to move the rail to the desired station.", + }, + -2854: { + "text": "Invalid Station type", + "description": "The robot cannot move to a station of the requested type. For example, the PP100 robot cannot move to a horizontal station.", + }, + -2855: { + "text": "No gripper close sensor", + "description": "The command requires a gripper-close sensor but no such sensor exists for the current robot type.", + }, + -3000: {"text": "NULL pointer detected", "description": ""}, + -3001: {"text": "Too many arguments", "description": ""}, + -3002: {"text": "Too few arguments", "description": ""}, + -3003: {"text": "Illegal value", "description": ""}, + -3004: {"text": "Servo not initialized", "description": ""}, + -3005: {"text": "Servo mode transition failed", "description": ""}, + -3006: {"text": "Servo mode locked", "description": ""}, + -3007: {"text": "Servo hash table not found", "description": ""}, + -3008: {"text": "Servo hash entry collision", "description": ""}, + -3009: {"text": "No hash entry found", "description": ""}, + -3010: {"text": "Servo hash table full", "description": ""}, + -3011: {"text": "Illegal parameter access", "description": ""}, + -3012: { + "text": "One or more servo tasks stopped", + "description": "A software watchdog timer has not been updated in the required time. This normally indicates a controller hardware failure or a system software bug.", + }, + -3013: {"text": "Servo task submission failed", "description": ""}, + -3014: { + "text": "Cal parameters not set correctly", + "description": """This error is generated if a homing operation is initiated and one or more parameters that affect homing are not set properly. For example, this error is generated in the following circumstances: + + The "Hardstop envelope limit, mcnt" (DataID 10122) is less thanor equal to zero or it is greater than either the "Soft envelope error limit, mcnt" (DataID 10302) or the "Hard envelope error limit, mcnt" (DataID 10303). If you are not using DataID 10122 for your homing method, set it to a small non-zero value to avoid this error.""", + }, + -3015: { + "text": "Cal position not ready", + "description": """If your robot is equipped with the Precise Absolute encoders, e.g. you have a PrecisePlace 2300/2400 robot, this indicates that the robot does not have its factory encoder calibration data defined. Most likely, one of the "Index code for calibration" (DataID 16241) values is zero. To correct the problem, run the factory encoder calibration program. + + For 3rd party absolute encoders, this error indicates that the full precision absolute encoder position could not be read from the encoder during the homing operation. In some cases, this indicates an error in reading the multiple turn counter for the encoder. If this problem persists, it probably indicates an encoder or controller hardware failure. + + For incremental encoders, this error indicates that a signal required for the selected homing method was not found (e.g. a missing homing or limit or index signal) or a signal was corrupted (e.g. incorrect index due to excessive skew).""", + }, + -3016: { + "text": "Illegal cal seek command", + "description": "This error should never be generated. It indicates that the homing operation sent an illegal command to the servo code. Please report this message along with the process that generated this problem to Precise.", + }, + -3017: { + "text": "No axis selected", + "description": "A debug control panel operation has been requested for an axis that does not exist on a particular servo network node. Reselect the axis and try again. Verify that the node mapped to the axis is active on the network.", + }, + -3100: { + "text": "Hard envelope error", + "description": """This error is generated if the value of the "Position tracking error, mcnt" (DataID 12320) for an axis exceeds its "Hard envelope error limit, mcnt" (DataID 10303) for a sufficient period of time. This error indicates that there was a significant difference between an axis' commanded position and its actual position. This can occur if: + + The axis is being driven too fast for the load that it is carrying. + The axis hits an obstacle and cannot advance. + The axis is oscillating due to a servo tuning instability. + There is a hardware failure of some type. + Normally, this error can be avoided by reducing the speed and/or acceleration of a motion.""", + }, + -3101: { + "text": "PID output saturated too long", + "description": """This error indicates that the sum of the servo feedback terms ("Compensator output torque" (DataID 12304) minus the sum of the "Dynamic feedforward torque" (DataID 12337) and the "Filtered feedforward torque" (DataID 12331)) has either saturated the maximum specified torque for an axis or the "Max positive/negative torque limit for PID feedback" (10351,10352) for more than the time specified the "PID output saturation duration limit" (DataID 10369), which is set to 200 msec by default. + + This error is generated if the limits are set too low or the axis has been over-driven or the axis has collided with an obstacle or some other unexpected error has occurred. + + This check reduces the time that an axis is over-driven or that it drives into an obstacle.""", + }, + -3102: { + "text": "Illegal zero index", + "description": """An encoder zero index pulse was detected when none is expected. The axis is marked as "not calibrated" and the robot must be re-homed. Verify that parameters "Encoder counts for resolution calc, ecnt" (DataID 10203) and "Encoder revs for resolution calc, rev" (DataID 10204) are correct. If the unexpected pulse is due to noise, the parameter "Index noise spikes limit" (DataID 10222) may be adjusted. If the unexpected pulse is due to encoder slippage, parameter "Index skew count limit, mcnt" (DataID 10221) may be adjusted.""", + }, + -3103: { + "text": "Missing zero index", + "description": """No encoder zero index pulse was detected when one is expected. The axis is marked as "not calibrated" and the robot must be re-homed. Verify that parameters "Encoder counts for resolution calc, ecnt" (DataID 10203) and "Encoder revs for resolution calc, rev" (DataID 10204) are correct. If the missing pulse is due to encoder slippage, parameter "Index skew count limit, mcnt" (DataID 10221) may be adjusted.""", + }, + -3104: { + "text": "Motor duty cycle exceeded", + "description": """Duty cycle testing is intended to prevent a motor from being damaged due to overheating. The overheating estimate is computed based upon the average power that is supplied to a motor by an amplifier over a period of time. + + The duty cycle criteria is defined by the "RMS rated motor current, A(rms)" (DataID 10611), "Duty cycle limit in terms of rated torque" (DataID 10623), "Duty cycle exceeded duration" (DataID 10622) and "Duty cycle SPR filter pole" (DataID 10621). + + If the dynamically computed "Duty cycle value, tcnt^2" (DataID 12606) exceeds the "Duty cycle limit, tcnt^2" (DataID 10624), which is defined from the criteria above, the "Motor duty cycle exceeded" error is generated and motor power is disabled. + + If this error is generated, but the motor is still very cool, try changing the "Duty cycle SPR filter pole" (DataID 10621) to average the power over a longer period of time. This will reduce the effect of short periods of high power utilization.""", + }, + -3105: { + "text": "Motor stalled", + "description": """This error indicates that the torque/current for a motor has been saturated at the peak value as defined by the "RMS rated motor current, A(rms)" (DataID 10611) * "AUTO mode motor PEAK(non-RMS)/(RMS rated) current, %" (DataID 10613) for "Motor stalled check duration" (DataID 10617) seconds. + + This is different than the conventional definition of having a motor not moving for a period of time with the torque/current continuously above a specified level.""", + }, + -3106: { + "text": "Axis over-speed", + "description": """This error is generated when power is enabled or during normal running if the system detects that an axis has violated a speed limit. + + If this occurs when power is enabled, it indicates that the axis has violated the speed limit defined by "Special power-up speed limit" (DataID 10210). The possible causes for this error are as follows: + + The motor torque sign is negated and the axis is attempting to run away at high speed. + For 3rd party amplifiers, there is an excessive DAC offset. + For gravity loaded axes, the axis might be dropping very quickly after the brake is released. + A somewhat large offset between the amplifier/motor phase currents might be causing the axis to move excessively when the system attempts to automatically compensate for the phase offset. The "Disable auto phase offset adjustment" (DataID 10695) can be used to turn off this adjustment if desired. + The "Special power-up speed limit" (DataID 10210) may be set too low. + If this error occurs when the system is running, it indicates that either the "Run-time speed limit" (DataID 10208) or the "Manual mode speed limit" (DataID 10209) has been violated. When this runtime error occurs, do the following: + + Reduce the speed of your motions to avoid damaging the motor or its gear train or violating manual control safety regulations. + Review the values of 10208 and 10209 and ensure that they are set properly. + If possible, reduce the gear ratio of the motor to reduce the maximum motor rotational speed. + If the error is triggered by intermittent noise in the velocity signal, reduce the "Motor velocity SPR filter pole" (10207). This value will not affect the PID loop tuning, but it does affect other functions of the system. Review the documentation for 10207 before you change its value.""", + }, + -3107: { + "text": "Amplifier over-current", + "description": "This error is generated if the FPGA firmware detects that the output motor current has exceeded the specified current limits for too long a time.", + }, + -3108: { + "text": "Amplifier over-voltage", + "description": """This error is generated by the FPGA firmware when it detects that the DC bus voltage is too high. The limit on the bus voltage is a function of the controller model being utilized. For the G1xxxA/B controllers, the maximum voltage is approximately 59.5 VDC. For the G3xxx and G2xxxB/C series controllers, the maximum voltage was approximately 445 VDC, but in May 2014 was adjusted down to 436 VDC. Whenever a motor decelerates, it typically pumps power back into the motor power supply and the voltage will rise above its nominal value. For example, if the nominal bus voltage is 330V, it is not unusual to see the voltage rise into the high 300's when a large motor is decelerating. To monitor the DC bus voltage, see "Raw DC bus voltage, volt" (DataID 12684). If this problem persists, it may indicate that the motor power supply must be changed or augmented to include more capacity to dump or absorb more power when the robot is decelerating.""", + }, + -3109: { + "text": "Amplifier under-voltage", + "description": """This indicates that the DC motor bus has dropped too low. This can occur if: + + The bus voltage does not rise above 10V the first time that motor power is enabled. + The bus voltage falls too far (typically 30%) below its nominal value at any time after power has been enabled. + The bus voltage falls below 10V while motor power and the motor amplifiers are enabled. + If this error is generated at the same time as a "Hard E-STOP" error (-1028), it is possible that the "E-stop delay" (DataID 267) is set too short and the DC motor bus voltage is dropping before the amplifiers are disabled. + + If this error persists, it could be due to a fuse on the Motor Power Supply being blown. To monitor the DC bus voltage, see "Raw DC bus voltage, volt" (DataID 12684). To see the current setting of the nominal voltage look at "Nominal DC bus voltage, volt" (DataID 12683).""", + }, + -3110: { + "text": "Amplifier fault", + "description": """This is a generic message indicating that the amplifier hardware has detected a significant problem and has shut down. For example, the output motor current has exceeded the rated limits of the hardware, or the input power to the amplifier has failed while the amplifier was enabled. Frequently, a separate amplifier-related error message is also displayed that provides details about the specific problem. + + Verify that the amplifiers are configured properly. + Verify the motors are wired correctly. + Verify the current loop tuning is correct. Unstable current loop tuning can trigger an amplifier fault. + Verify the motor and motor harness are not shorted. This can be done by disconnecting the motor and measuring the resistance between the UVW phases. The resistance should be the same for each pair and low, but not zero. + If this error occurs when an E-STOP is asserted, verify that the "Delay after setting brakes" (DataID 260) is longer than or equal to the "E-Stop delay" (DataID 267). Normally, DataID 260 should be at least 0.1 seconds shorter than DataID 267.""", + }, + -3111: { + "text": "Brake fault", + "description": "This error indicates that the FPGA has detected a fault condition in the hardware motor brake driver circuit. This test is not currently enabled, so this error message should never be generated.", + }, + -3112: { + "text": "Excessive dual encoder slippage", + "description": """If an axis has been configured for dual encoder loop control (i.e. two encoders are used to control a single motor), this error is generated if there is an excessive position or speed differential between the readings of the two encoders. The slippage limits are defined by "Dual loop position slippage limit" (DataID 10212) and "Dual loop speed slippage limit" (DataID 10216). + + If the axis is driven by a traction drive, some amount of slippage occurs every time that the axis is accelerated or decelerated. This normal slippage is automatically corrected for by the system software. If excessive speed slippage occurs, it could indicate that one of the two encoders has failed and the system was shutdown to prevent a run-away condition.""", + }, + -3113: { + "text": "Motor commutation setup failed", + "description": """The procedure for determining the commutation reference angle for the motor failed and the reference angle was not established. This error normally occurs the first time that motor power is enabled after the controller is restarted or during the homing process. + + The followings are the common causes for the failure: + + Motor power was disabled. The motor power was manually disabled or was automatically disabled due to another error occurring before the end of the search process. + Lose motor cable. Some commutation reference search processes move the motor a short distance. During this small motion, the motor cable became disconnected. Encoder feedback lost. + TEhnec ofdeeedback device (encoder) is not working properly or the "Encoder type" (DataID 10027) was incorrectly set. + Incorrect configuration parameters. Any of the following parameters may have been incorrect set. The Precise Configuration Utility (PCU) contains tools that can be used to verify the correct settings. + DataID 10108, Dedicated DIN's selection + DataID 10650, Commutation sign + DataID 10651, # of pole pairs per motor revolution + DataID 10652, Commutation counts per electrical cycle + Poor current loop tuning. The motor current loop may not be properly tuned. + Improper configuration parameters for selected commutation search method. While the default parameter values will work for a wide variety of axis configurations, there are cases which require parameter adjustment in order to properly perform commutation reference finding. Refer to the selected commutation method parameter description for details. + If the problem persists after checking the above items, please contact Precise support for further assistance.""", + }, + -3114: { + "text": "Servo tasks overrun", + "description": 'The servo tasks are not able to complete their servo computations within the specified servo period. Too many axes are being servoed by a single board, the parameter "Servo update period in sec" (DataID 603) is too small, or a CPU failure has occurred. This error is fatal and prevents robot power from being turned on until the controller is rebooted.', + }, + -3115: { + "text": "Encoder quadrature error", + "description": """For incremental encoders, the encoder count and direction of change is derived from two square waves (channels A & B) that are 90 degrees out of phase. If at any time, the FPGA detects that the phase angle between the channels is too small, a quadrature error is generated. Normally, this error is caused by noise in the encoder lines. To correct this problem, please see the recommendations on wiring encoders and motors in the Installation Section of the Controller Hardware Manual. The motor wiring is as important as the encoder wiring since the motors are often generating the noise that is cause the erroneous reading on the encoder channels. + + When this error occurs, the motor must be commutated again and should be re-homed since this error indicates that the position of the motor/encoder is not longer exactly known. + + For robots with gravity loaded axes (e.g. Z-axes), the "Severe error power off mode" (DataID 142) should be set to mode 1. If the robot's incremental encoders suffer a quadrature error, the robot's brakes will be set immediately and minimize dropping due to gravity loading.""", + }, + -3116: {"text": "Precise encoder index error", "description": ""}, + -3117: { + "text": "Amplifier RMS current exceeded", + "description": """This error indicates that the rated RMS current for an integrated amplifier has been exceeded and the software has disabled motor power to protect the amplifier from being damaged. This is an internal test that is automatically performed and cannot be disabled. See the specifications for your controller for the RMS rating of the amplifiers. If this error occurs, consider doing the following: + + Reduce the accelerations, decelerations and speeds for your motions. + Verify that the rated RMS current for the motor is equal to or below that of the amplifier. If the motor needs to operate at a higher average current level (due to gravity loading, constantly working against friction, etc.), consider purchasing a controller with amplifiers that have a higher rated RMS current. + This error is different than a "Motor duty cycle exceeded" (-3104) error. The duty cycle testing is intended to protect a motor from being damaged due to over-heating. There are a number of parameters for configuring the duty cycle testing to match a motor's operating specifications.""", + }, + -3118: { + "text": "Dedicated DINs not config'ed for Hall", + "description": """If the "Commutation reference setup config" (DataID 10700) specifies that the "Hall-effect" method is to be used for commutating a motor, then the "Dedicated DIN's selection" (DataID 10108) must be set to configure the single-ended inputs in the corresponding encoder connector for use as hall sensor inputs. If DataID 10108 is not set properly, this error is generated. Normally, DataID 10108 is automatically configured if DataID 10700 specifies the "Hall-effect" method.""", + }, + -3119: { + "text": "Illegal 6-step number", + "description": """If the "Commutation reference setup config" (DataID 10700) specifies that the "Hall-effect" method is to be used for commutating a motor, the single-ended inputs in the corresponding encoder connector are read to determine the hall sensor 6-step value. The only permitted hall readings are values from 1 to 6. If the single-ended digital inputs are set to some other value, this error is generated.""", + }, + -3120: { + "text": "Illegal commutation angle", + "description": """This is a general error message that indicates a problem has been detected with the commutation reference angle. Some possible problems that would generate this error message include: + + The "Commutation counts per electrical cycle" (DataID 10652) may be set to zero or some other invalid number. + For a motor with an absolute encoder, the "Commutation offset" (DataID 10775) may be un-initialized so the commutation reference angle cannot be set based upon the encoders single turn data reading. + For a motor with a serial incremental encoder that outputs hall sensor readings during startup, the hall sensor reading may have been unavailable or invalid. + For a motor with hall sensors, the commutation angle specified for a hall sensor reading (DataIDs 10744-10756) may be un-initialized. + For a motor with an analog encoder, the "Analog hall commutation phase angle" (DataID 10756) may not yield a valid commutation reference angle.""", + }, + -3121: { + "text": "Encoder fault", + "description": """This code is generated when an error occurs in communication with a serial encoder such as a Panasonic or Yaskawa serial encoder. This error indicates one of several possible problems. + + For the Yaskawa Sigma II/III serial absolute encoder, this error is generated when the encoder signals a "Runtime Error" due to an error in the encoder's memory. When this occurs, bit 1 in the "Encoder alarm" field (DataID 12251) is set. Check the encoder cable and cycle the power to see if the error goes away. See the Yaskawa encoder alarm documentation for more details. + For the Panasonic serial incremental encoder (type 41), this error is generated when the encoder signals a Preload error after the encoder has been initialized. When this occurs, bit 7 is set in the "Encoder alarm" field (DataID 12251). See the Panasonic encoder alarm documentation for more details. + + This is a severe error and requires the encoder to be re-initialization and/or power cycled.""", + }, + -3122: { + "text": "Soft envelope error", + "description": """The position error of an axis has exceeded the value set in the "Soft envelope error limit" (DataID 10302). This is a safety precaution to ensure that each axis does not deviate too far from its intended position. This error may indicate that the following has occurred: + + An axis has been commanded to accelerate too quickly or move too fast and does not have the power to perform the operation. If necessary, this can be confirmed by datalogging the "Compensator output torque" (DataID 12304) and the "Position tracking error" (DataID 12320) for the axis in question. If this is the source of the envelope error, the position error will increase significantly when the motor torque saturates at its maximum value for several tens of milliseconds. + The axis has gone unstable due to a hardware failure or some other error. This can be confirmed by listening for an audible noise or by datalogging the "Velocity tracking error" (DataID 12321) for the axis in question and looking for high frequency oscillations. + An axis may have crashed into an obstacle and is unable to move to the specified position. + The "Software envelope error limit" (DataID 10302) may be set to too small of a value.""", + }, + -3123: { + "text": "Cannot switch serial encoder mode", + "description": "When an absolute or incremental serial encoder is employed, it can operate in either sync or async mode. In order to switch between the two modes the axis motor power must be turned OFF. If it's not then this error is issued.", + }, + -3124: { + "text": "Serial encoder busy", + "description": """During communication with a serial absolute or incremental encoder, if the encoder takes too long to process a command, this error is issued. When the encoder is in its normal synchronous mode, this error can be generated when the encoder is periodically returning position data. During asynchronous mode , this error can be generated when the host controller issues a specific encoder command to perform a diagnostic function. + + If this error persists, please check the encoder connections and power cycle the encoder.""", + }, + -3125: { + "text": "Illegal encoder command", + "description": "The serial command issued from the controller is not supported by the encoder. Normally, this error should never occur. However, if it is generated, it indicates that there is an internal implementation error. Please inform Precise.", + }, + -3126: { + "text": "Encoder operation error", + "description": """This indicates that a serial encoder is rotating at too high a speed before motor power is turned on or an error has been detected in the encoder position data. This error is generated when the encoder signals the following alarms conditions: + + For Panasonic and Tamagawa serial encoders, this error is generated when "Over-speed" (bit 0) and/or "Counter Error" (bit 2) is set in the "Encoder alarm" field (DataID 12251). + For Yaskawa serial encoders, this error is generated when "Absolute Error" (bit 3), "Over-speed" (bit 4) and/or "Reset Complete" (bit 6) is set in the "Encoder alarm" field (DataID 12251). + This is a standard error and requires the encoder to be re-initialization and/or power cycled. + + See the Panasonic, Tamagawa and Yaskawa encoder alarm documentation for more details.""", + }, + -3127: { + "text": "Encoder battery low", + "description": "Many serial absolute encoders require a connection to an external battery. If the battery voltage is too low, this error is issued. When this error is generated, the encoder is still operational and its internal data, e.g. multi-turn value, will still be valid. However, the battery should be replaced as soon as possible before the internal data is lost.", + }, + -3128: { + "text": "Encoder battery down", + "description": """Many serial absolute encoders require a connection to an external battery. If the battery is dead or this backup power is disconnected (even momentarily), this error is issued and will be latched until cleared. When this error occurs, the encoder's internal multi-turn data is no longer valid and the encoder position must be re-calibrated. The battery must be replaced or reconnected and the factory setup for the encoder must be executed again. + + If this error occurs, verify that the battery voltage is adequate (typically 3.6V or above) and is connected to the encoder via the controller. Once the battery power has been restored, execute the factory calibration program to clear this latched error and reset the encoder's multiple turn counter.""", + }, + -3129: { + "text": "Invalid encoder multi-turn data", + "description": """During run-time, this error is triggered when an encoder's multi-turn counter either over-flows or under-flows or the encoder itself detects a read error of the multi-turn data. + + The over-flow or under-flow error should not occur so long as the encoder is not continuously turned in a single direction and the homing setup is done properly. + + If the error is generated when the controller is restarted or during homing, the error is most likely due to a read error and the encoder should be power cycled to clear this condition. + + For Panasonic and Tamagawa serial absolute encoders, this error is generated when the "Multi-turn Counter Overflow" bit (bit 3) and/or "Multi-turn read error" bit (bit 5) is set in the "Encoder alarm" field (DataID 12251). See the Panasonic/Tamagawa encoder alarm documentation for more details. + + This is a standard error and requires the encoder to be re-initialization and/or power cycled""", + }, + -3130: { + "text": "Illegal encoder operation mode", + "description": """During normal run-time operation, serial absolute and serial incremental encoders should be in synchronous communication mode. If an encoder is not in this mode when motor power is enabled, this error is issued. Normally, this error should not be generated. + + If this error persists, use the Absolute Encoder Diagnostics page in the web interface to re-initialize the serial encoder or power cycle the encoder.""", + }, + -3131: { + "text": "Encoder not supported or mis-matched", + "description": """The parameter "Encoder type" (DataID 10027) is set to a value not supported by the servos or that is inconsistent with the ID code returned by a serial encoder. This can occur if: + + The Encode type is not set to the proper value + When the controller first communicates with the encoder, the encoder communication is corrupted and the wrong encoder ID is received + For bus line absolute encoders, one or more of the encoders did not properly boot and the encoder message received by the controller did not include the response from all of the expected encoders + + This error is fatal and prevents robot power from being turned on until the controller is rebooted.""", + }, + -3132: { + "text": "Trajectory extrapolation limit exceeded", + "description": """This error occurs in systems with slave controllers or GSB boards that are networked to a master controller via Ethernet or RS485. If a set point transmission is lost, the servo code on the slave controller or GSB extrapolates from the previous trajectory set points. If the number of sequential missed set points exceeds the "Number of consecutive extrapolations allowed" (DataID 10424), the servo generates this severe level error and disables motor power. + + If this error occurs, verify that the value of DataID 10424 is not set too low. Typically, this value should be set to 8. + + If this error is generated by an Ethernet slave controller, there is probably noise in the Ethernet network. Ensure that shielded twisted pair Ethernet cables are used to interconnect the master and slave controllers and in any other part of the network that could inject noise. + + If this error is generated by a GSB slave, there is probably noise on the RS485 bus. Verify that the termination jumpers are installed correctly and that the RS485 connectors are firmly seated on all boards, and ensure that shielded twisted pair wire is used for all of the RS485 signals. View the GSB error statistics to identify what GSB board is experiencing noise. + + To minimize noise generation in a custom high voltage robot mechanism, verify that the recommended ferrite beads are installed on all motor power wires and that the communication cables are not routed next to the high voltage signal lines. + + If this error is generated by servos on the master controller, or in systems that are not part of a servo network, it indicates that the controller's CPU is overloaded. If the problem persists, please contact Precise technical support.""", + }, + -3133: { + "text": "Amplifier fault, DC bus stuck", + "description": "This error is generated if an amplifier is in a fault state and the user tries to re-enable motor power while the DC bus voltage is still not below 18VDC. If an amplifier fault has occurred, the high power relay to the motor power supply must be disengaged before motor power is re-enabled.", + }, + -3134: { + "text": "Encoder data or accel/decel limit error", + "description": """This error indicates that either invalid position data has been received from a serial encoder or a serial encoder's position reading changed by too large of a value in a very short period of time. Most often, this means that six or more consecutive "Absolute encoder bad readings" (DataID 12269) or "Absolute encoder communication errors" (DataID 12259) have occurred. If the controller detects a single error of these types, it will usually automatically correct the error and continue normal operation. + + When this error code is generated, the robot must be homed again to re-sample the encoder's full absolute position information. Also, the encoder may be disabled. To restart the encoder's operation, use the "Reset" function in the "Absolute Encoder Diagnostics" page of the web interface. + + If the mechanism is expected to operate at very high accelerations and decelerations, increase the limit used to detect bad encoder readings by reducing the value defined by the "Min. accel time to 5000 RPM, msec" DataID 10252. + + If DataID 10252 is properly set for the expected operation of the axis and the axis is not moving exceeding fast and this problem persists, it typically indicates that there is a hardware problem. The possible hardware defects (starting with the most likely cause) are: a poor connection in an encoder cable (please ensure all contacts are high compression with gold plating); a damaged encoder cable; electronic noise in the encoder cable; a defective encoder; a defective controller.""", + }, + -3135: { + "text": "Phase offset too large", + "description": "The detected amplifier phase offset is too large to be corrected automatically. To perform phase offset correction manually, disable the automatic phase offset adjustment using DataID 10695.", + }, + -3136: { + "text": "Excessive movement during phase offset adjustment", + "description": "Automatic amplifier phase offset correction (DataID 10695) can only be performed on Precise integrated amplifiers and when the motor is not in motion. If this error continues to persist even when the motor is stopped and you have Precise amplifiers, disable this adjustment by setting DataID 10695 to 1 and contact Precise support.", + }, + -3137: { + "text": "Amplifier hardware failure or invalid configuration", + "description": """This error will be reported in the following situations: + + Amplifiers are being enabled and the controller does not have any integrated amplifiers but a Precise amplifier has been specified in the configuration database. + The motor DC bus voltage is too high or low for the configured Precise amplifiers. Most likely, this indicates a software configuration error.""", + }, + -3138: { + "text": "Encoder position not ready", + "description": """The accuracy of the absolute encoder position data or the single turn position data of a serial encoder is reduced due to excessive rotational speed when the controller is turned on. + + For Panasonic and Tamagawa serial encoders, this error is generated when the "Reduced encoder resolution" (bit 1) is set in the "Encoder alarm" field (DataID 12251). See the Panasonic/Tamagawa encoder alarm documentation for more details. + + This alarm bit will be reset automatically by the encoder. However it is sometimes necessary to perform a re-initialization to clear the alarm condition.""", + }, + -3139: { + "text": "Encoder not ready", + "description": """A serial encoder is not ready to operate. This error is generated in the following situations: + + The configuration parameter specifies a serial encoder but the encoder is not physically connected to the controller. + The servo code failed to initialize the serial encoder. + The servo detects the serial encoder model (Panasonic and Tamagawa only), but the model is different from the specified "Encoder type" (DataID 10027). + An attempt was made to enable motor power after a serial encoder communication error (-3140) occurred without re-initialize the encoder. + If a serial encoder is successfully initialized and is operated normally, the "Serial encoder ready" (bit 2) of "Encoder software status word" (DataID 12200) will be set to 1.""", + }, + -3140: { + "text": "Encoder communication error", + "description": """The controller hardware has failed to establish communication with a serial encoder or communication was lost after the encoder was initialized. This typically indicates that the FPGA firmware has timed out while waiting for a communication packet from the encoder. The servo code may also issue an "Encoder not ready" error (-3139) depending upon the failure situation. + + In addition to issuing this error, the "Serial encoder communication error" bit (bit 26) of the "Encoder software status word" (DataID 12200) will be set to 1. If the communication error is detected during the normal synchronized operation, the "Serial encoder ready" bit (bit 2) of the status word will be set to 0. + + This is a severe error and requires the encoder to be re-initialization and/or power cycled.""", + }, + -3141: { + "text": "Encoder overheated", + "description": """A serial encoder has overheated. This error is generated in the following situations. + + For a Yaskawa serial encoder, this error is generated when the "Overheat" bit (bit 5) is set in the "Encoder alarm" field (DataID 12251). See the Yaskawa encoder alarm documentation for more details. + + This is a severe error and requires the encoder to be re-initialization and/or power cycled.""", + }, + -3142: { + "text": "Encoder hall sensor error", + "description": """A serial incremental encoder has detected an error in its hall effect data. + + For a Panasonic serial incremental encoder (type 41), this error is generated when the "Count error between phase" bit (bit 4) and/or the "Illegal Hall Data bit (bit 6) is set in the "Encoder alarm" field (DataID 12251). + This is a severe error and requires the encoder to be re-initialization and/or power cycled.""", + }, + -3143: { + "text": "General serial bus encoder error", + "description": """This error is generated by some serial encoders that pass back a limited amount of status information while the robot is running. When this error occurs, it means that an encoder has signaled an error, but no specific information about the nature of the error is known. + + It may be possible to obtain more information on the error if you cycle the AC power on the controller. For example, if this error was due to a low battery voltage condition, cycling AC power and re-homing the robot will generate an "Encoder battery low" (-3127) error. + + To reset this error, go to the Absolute Encoder maintenance panel in the Settings section of the controller's web interface. If the encoder error persists after the encoder is reset, the Operator Control Panel will display the detailed error information. + + + Currently, this error only occurs with the daisy-chained serial bus Panasonic encoders that are utilized in the Denso line of robots.""", + }, + -3144: { + "text": "Amplifier overheating", + "description": 'The indicated power amplifier has exceeded its permitted operating temperature. Normally this error is followed by the generic error -1610 "Controller overheating". For amplifiers of the G3xxx or G1xxxA/B controllers (except for the G3x3xA 30A version), the limit is 80C. For the amplifiers of the G3x3xA 30A version, the limit is 100C. See Amplifier temperature (DataID 12605) for the actual amplifier temperatures. The G2xxx controller power amplifier chips are equipped with internal temperature monitoring to protect the power modules, but they do not have temperature sensors that can be read.', + }, + -3145: { + "text": "Motor overheating", + "description": 'The indicated motor has exceeded its permitted operating temperature. Normally this error is followed by the generic error -1610 "Controller overheating". The parameter "Max motor temperature" (DataID 10110) determines the maximum allowed temperature. See "Motor temperature" (DataID 12110) for the actual motor temperature. Motor temperature monitoring is configurable and requires special temperature sensors in the motors and special signal conditioning electronics.', + }, + -3146: { + "text": "Earlier encoder error inhibiting power", + "description": "An attempt to enable motor power failed because a severe encoder error previously occurred. Consequently, the encoder is not operational and permitting motor power to be enabled could result in the motor being unstable. Typically, the encoder in question is a serial absolute or incremental type and is either not communicating properly or must be reset. Please go to the following web page to view the serial encoder status and to clear any error conditions: Setup > Hardware Tuning and Diagnostics > Absolute Encoder.", + }, + -3147: { + "text": "Abnormal envelope error", + "description": """This error indicates a more severe instance of the condition signaled by the "Hard envelope error" (-3100). Like the "Hard" error, this error indicates that there was a significant difference betortween an axis' commanded position and its actual position. And, like the "Hard" error, this error is generated when the value of the "Position tracking error, mcnt" (DataID 12320) for an axis exceeds its "Hard envelope error limit, mcnt" (DataID 10303) for a sufficient period of time. + + + However, the "Abnormal" error is generated in place of the "Hard" error when the command velocity is lower than 33% of the Manual mode speed limit (DataID 10209). So, the "Abnormal" error indicates that a significant position error occurred when an axis was not supposed to be moving fast. This error normally indicates a collision occurred when the robot was moving at a slow speed or one of the following motor configuration parameters are incorrect. + + Encoder sign (DataID 10202) + Torque sign (DataID 10609) + Commutation sign (DataID 10650) + Hard envelope error (DataID 10303) + Commutation offset (DataID 10775) + Commutation position at zero index (16653)""", + }, + -3148: { + "text": "Encoder hardware related warning", + "description": "This indicates that an internal encoder warning bit is ON. Currently this warning is only reported by BiSS encoders. Please consult the documentation for the specific encoder model to determine the nature of the error.\n\nThis is only a warning message. This error will not stop program execution or turn Off robot/motor power.", + }, + -3149: { + "text": "Velocity restrict limit exceeded", + "description": """(CAT-3, GPL 4.2 or later) The low-level control that advances the commutation angle for a BLDC motor has detected that the motor is attempting to turn faster than the Run-time or Manual mode speed limits (DataIDs 10208/10209) permit. The motor is immediately shut down and an error is signaled. + + Since a BLDC motor cannot generate torque if the commutation angle is not properly set and cannot rotate unless the commutation angle is properly advanced, this check provides a low level independent test to ensure that a motor is not rotating faster than permitted. This is sometimes referred to as an independent "Velocity Restrict" test. + + This error will only be reported in controllers with FPGA firmware that supports CAT-3 safety capability, and the CAT-3 safety feature is enabled in software. + + This error may indicate that: + + DataIDs 10208/10209 are set too low + An application program is trying to drive the motor faster than allowed + An encoder read error has occurred due to noise on the encoder lines or bad data has been sent by an encoder. For non-CAT-3 systems, these types of errors typically generate "Encoder data or accel/decel limit errors" (-3134) or "Encoder operation errors" (-3126) or "Encoder communication errors" (-3140). + A system hardware failure or a system software error has occurred that attempted to drive the motor faster than allowed.""", + }, + -3150: {"text": "Position compare not enabled", "description": ""}, + -3151: {"text": "Position compare memory allocation failed", "description": ""}, + -3152: {"text": "Position compare buffer empty", "description": ""}, + -3153: {"text": "Failed to start position compare task", "description": ""}, + -3154: {"text": "Position compare buffer full", "description": ""}, + -3155: {"text": "Invalid DOUT for position compare", "description": ""}, + -3156: {"text": "Motor moving away from compare position", "description": ""}, + -3157: {"text": "Position compare internal inconsistence error", "description": ""}, + -3160: { + "text": "Dump circuit duty cycle exceeded", + "description": "The motor power dump circuit has been turn on too long. The dump circuit has been switched off to avoid overheating the dump resistor.\n\nNOTE: If you load GPL versions 4.1J1 and later or 4.2E and later into a PreciseFlex™400 Rev B or earlier robot, this error may be erroneously generated. If this occurs, loading GPL 4.2H or later will properly detect the dump board in the robot and eliminate this error.", + }, + -3161: {"text": "No position updated in Fpga", "description": ""}, + -4000: { + "text": "Cannot connect to vision server", + "description": 'GPL cannot establish an Ethernet TCP connection with the Precise Vision software on the vision host PC. If the web interface is not working, check the basic Ethernet connectivity. In addition, verify that the "Vision Server IP address" (DataID 424) is set properly for your vision host PC. Make sure that Precise Vision is active on that PC.', + }, + -4001: { + "text": "Invalid vision protocol", + "description": "GPL is unable to decode a message received from PreciseVision. Verify that the versions of GPL and PreciseVision are compatible. If so, please report this error to customer service.", + }, + -4002: { + "text": "Vision interlocked", + "description": "The communications link to PreciseVision is being used by a different GPL thread. Only one thread may access vision at a time. Stop any other threads that may be accessing PreciseVision.", + }, + -4003: {"text": "Vision interlocked", "description": ""}, + -4010: { + "text": "Vision invalid protocol", + "description": "PreciseVision is unable to decode a message received from GPL. Verify that the versions of GPL and PreciseVision are compatible. If so, please report this error to customer service.", + }, + -4011: { + "text": "Vision internal error", + "description": "An unexpected error has occurred within PreciseVision. Please report this error to customer service", + }, + -4012: { + "text": "Vision unknown process", + "description": "A GPL vision method has specified a vision process that is not defined in the current PreciseVision project. Verify that the correct GPL project and PreciseVision project are loaded.", + }, + -4013: { + "text": "Vision unknown tool", + "description": "A GPL vision method has specified a vision tool that is not defined in the current PreciseVision project. Verify that the correct GPL project and PreciseVision project are loaded.", + }, + -4014: { + "text": "Vision invalid index", + "description": "A GPL vision Result method has specified an index for a result that does not exist. Verify that the correct GPL project and PreciseVision project are loaded. Verify the number of results returned by the current vision process.", + }, + -4016: { + "text": "Vision invalid arguments", + "description": "The argument values specified in a vision ToolProperty method are not valid for that property.", + }, + -4017: { + "text": "Vision property not found", + "description": "The property name specified in a vision ToolProperty method does not exist. Verify that the correct GPL project and PreciseVision project are loaded.", + }, + -4018: { + "text": "Vision property protected", + "description": "An attempt has been made to change a vision tool property than cannot be modified.", + }, + -4019: { + "text": "Vision process failed", + "description": "Execution of a selected process within PreciseVision has failed. This is normally due to the acquisition operation failing.", + }, + -4020: { + "text": "Vision invalid calibration type", + "description": "A remote request to execute a vision calibration procedure failed because an invalid calibration type was specified. Set the calibration type to a valid type and try again.", + }, + -4021: { + "text": "Vision invalid project name", + "description": "A remote request to load a vision project failed. Probably the project name was not valid. Verify that the name specified is correct and that the file exists in the correct location.", + }, + -4022: { + "text": "Vision calibration file not found", + "description": "A remote request to load a vision calibration file failed because the calibration file could not be found. Verify that the file name specified is correct and that the file exists in the correct location.", + }, + -4023: { + "text": "Vision project not saved", + "description": "A remote request to load a new vision project has failed because the current project has not been saved. Save the current project before attempting to load a new one.", + }, +} + + +class PreciseFlexError(Exception): + def __init__(self, replycode: int, message: str): + self.replycode = replycode + self.message = message + if replycode in ERROR_CODES: + text = ERROR_CODES[replycode]["text"] + description = ERROR_CODES[replycode]["description"] + super().__init__(f"PreciseFlexError {replycode}: {text}. {description} - {message}") + else: + super().__init__(f"PreciseFlexError {replycode}: {message}") + + +class OperationInterrupted(Exception): + """Raised when a user interrupt (Jupyter stop / Ctrl+C) halts an in-flight device operation. + + The device has been sent a stop and the connection has been resynced and left open, so the session + can continue. Raised in place of a bare ``KeyboardInterrupt``; an ``asyncio.CancelledError`` is + re-raised as-is rather than converted, so cancellation semantics are preserved. + """ + + +class OutOfRangeOfMotionError(Exception): + """An axis's current position is outside its soft limits, so the controller rejects every + commanded move (-1012) until it is driven back into range. + + Its own type so the recover-and-retry path can catch this recoverable arm *state* without also + catching a bad commanded *target* (which stays a ``ValueError``). ``axes`` maps each offending + ``Axis`` to ``(value, (lo, hi))``. + """ + + def __init__(self, message: str, axes: dict): + super().__init__(message) + self.axes = axes + + +# -- collision detection --------------------------------------------------- + +# Collision / over-drive errors: the servo trips one of these when an axis is blocked - it hit an +# obstacle (or is otherwise over-driven) - and the controller stops the arm itself. Two mechanisms: +# position-tracking (envelope) and torque saturation. Each surfaces as a ``PreciseFlexError`` on the +# next command: +# -3100 hard envelope error (position tracking; severe, turns power off) +# -3122 soft envelope error (position tracking; leaves power on) +# -3101 PID output saturated too long (torque saturated - over-driven / collided) +# -3105 motor stalled (torque saturated at the peak rating) +COLLISION_ERROR_CODES = frozenset({-3100, -3101, -3105, -3122}) + + +def is_collision(exc: object) -> bool: + """Whether ``exc`` is a ``PreciseFlexError`` from a collision / over-drive (the arm hit something) - + an envelope (``-3100`` / ``-3122``) or torque-saturation (``-3101`` / ``-3105``) error - as opposed + to an ordinary command error. Lets protocol code branch on "did I crash?". + """ + return isinstance(exc, PreciseFlexError) and exc.replycode in COLLISION_ERROR_CODES diff --git a/pylabrobot/brooks/precise_flex/interrupt.py b/pylabrobot/brooks/precise_flex/interrupt.py new file mode 100644 index 00000000000..96eb7be4bfd --- /dev/null +++ b/pylabrobot/brooks/precise_flex/interrupt.py @@ -0,0 +1,88 @@ +"""Notebook/asyncio interrupt handling: stop the in-flight operation, keep the connection. + +When a user interrupts (Jupyter stop / Ctrl+C) while a command is awaiting a reply, we want to stop +whatever the device is doing - motion or vision alike - and resync the connection rather than drop +it. ``halt_on_interrupt`` wraps a blocking command: on interrupt it runs a channel-specific +stop+resync that is itself protected against a second interrupt, then re-raises an +``asyncio.CancelledError`` (preserving cancellation) or converts a ``KeyboardInterrupt`` to +``OperationInterrupted``. + +Behavior by context: +- Notebook: the kernel stays alive; the device is stopped and the connection resynced, so work + continues in the same session. +- Script via ``asyncio.run``: shutdown cancels the task, so the stop still fires before the process + exits (the connection survives only until the process ends). +- Physical E-stop is a hardware path, not an interrupt: the in-flight command returns an error reply + (``PreciseFlexError``) or times out, which is not caught here and propagates normally. +""" + +import asyncio +import logging +from contextlib import asynccontextmanager +from typing import Awaitable, Callable, Optional + +from pylabrobot.io.socket import Socket + +from .errors import OperationInterrupted + +logger = logging.getLogger(__name__) + + +async def _run_to_completion(coro: Awaitable[None]) -> None: + """Await ``coro`` to completion even if the surrounding task is interrupted again. + + Shields the work from cancellation and re-awaits if a second interrupt pops the await, so an + interrupt-triggered stop can never be left half-sent (double Ctrl+C is common in notebooks). + """ + task = asyncio.ensure_future(coro) + while not task.done(): + try: + await asyncio.shield(task) + except (KeyboardInterrupt, asyncio.CancelledError): + continue + + +async def drain(io: Socket, *, timeout: float = 0.5, max_lines: int = 50) -> None: + """Read and discard pending lines until the socket goes quiet, resyncing the stream.""" + for _ in range(max_lines): + try: + if not await io.readline(timeout=timeout): + return # peer closed + except TimeoutError: + return # quiet -> resynced + + +async def halt_and_resync(io: Socket, stop: Optional[bytes] = None) -> None: + """Best-effort: optionally send a ``stop`` command, then drain to resync. Never closes. + + When ``stop`` is given, a leading newline first terminates any command frame that was only + partially written when the interrupt landed, so the stop is parsed on its own line. Pass + ``stop=None`` to drain-only - a channel with no known abort command, where we just resync and keep + it open. + """ + try: + if stop is not None: + await io.write(b"\n" + stop + b"\n") + await drain(io) + except Exception: # best-effort; the connection is deliberately left open + logger.warning("interrupt stop/resync I/O failed", exc_info=True) + + +@asynccontextmanager +async def halt_on_interrupt(stop_action: Callable[[], Awaitable[None]]): + """Wrap a blocking command so an interrupt stops the in-flight operation and keeps the connection. + + On ``KeyboardInterrupt`` or ``asyncio.CancelledError``, runs ``stop_action`` to completion + (protected against a second interrupt), then re-raises ``CancelledError`` or converts a + ``KeyboardInterrupt`` to ``OperationInterrupted``. Any other exception (e.g. a ``PreciseFlexError`` + from an error reply, as an E-stop produces) propagates unchanged. + """ + try: + yield + except (KeyboardInterrupt, asyncio.CancelledError) as exc: + await _run_to_completion(stop_action()) + if isinstance(exc, asyncio.CancelledError): + raise + raise OperationInterrupted( + "operation halted by interrupt; device stopped, connection alive" + ) from exc diff --git a/pylabrobot/brooks/precise_flex/kinematics.py b/pylabrobot/brooks/precise_flex/kinematics.py new file mode 100644 index 00000000000..ffa70084707 --- /dev/null +++ b/pylabrobot/brooks/precise_flex/kinematics.py @@ -0,0 +1,214 @@ +""" +PF400 kinematics: FK and IK for a 4-DOF SCARA + prismatic Z + optional rail. + +Joint dict keys match the firmware and `Axis` enum: + 1: J1 (Z lift) [mm] + 2: J2 (shoulder) [deg] + 3: J3 (elbow) [deg] + 4: J4 (wrist) [deg] + 6: rail position [mm] (optional; 0 if missing) + +Task pose p = (x, y, z, yaw) with yaw in degrees. The gripper stays level +(all revolute axes parallel to world +Z), so IK is closed-form: +Z is decoupled, planar 2R for (x, y), wrist yaw for orientation. + +Sign conventions follow right-hand rule about +Z (CCW positive looking down). +""" + +from dataclasses import dataclass +from math import atan2, cos, degrees, hypot, pi, radians, sin +from typing import Dict, Literal, Optional, Tuple + +from pylabrobot.resources import Coordinate, Rotation + +# --------------------------------------------------------------------------- +# Value types +# --------------------------------------------------------------------------- + + +# Joint positions keyed by firmware axis number (see the `Axis` enum). +JointPose = Dict[int, float] + + +@dataclass +class CartesianPose: + """Location and rotation of the gripper.""" + + location: Coordinate + rotation: Rotation + + +ElbowOrientation = Literal["right", "left"] +Wrist = Literal["cw", "ccw"] + + +@dataclass +class PreciseFlexCartesianPose(CartesianPose): + rail_position: Optional[float] = None + orientation: Optional[ElbowOrientation] = None + wrist: Optional[Wrist] = None + + +@dataclass(frozen=True) +class WorkEnvelope: + """Reachable tool-tip envelope: an annulus about the shoulder, over a Z range (mm).""" + + inner: float + outer: float + zmin: float + zmax: float + + +# --------------------------------------------------------------------------- +# Kinematic parameters +# --------------------------------------------------------------------------- + + +# Known PF400 link-length configs (l1 = shoulder->elbow, l2 = elbow->wrist), in mm, per the 615287 +# System Dimensions - the single source of truth for the standard vs extended arm. +ARM_LINKS_STANDARD = (225.0, 210.0) +ARM_LINKS_EXTENDED = (302.0, 289.0) +_LINK_MATCH_TOLERANCE = 5.0 # mm; per-link calibration spread allowed when matching a read + + +@dataclass +class PF400Params: + """Calibrated link lengths; sub-mm FK residual on a held-out probe set.""" + + l1: float = ARM_LINKS_EXTENDED[0] # shoulder -> elbow [mm] + l2: float = ARM_LINKS_EXTENDED[1] # elbow -> wrist [mm] + gripper_length: float = 162.0 # wrist -> TCP [mm] + gripper_z_offset: float = 0.0 + eps: float = 1e-6 + + +def _classify_pf400_reach(links: Tuple[float, float]) -> Literal["standard", "extended", "unknown"]: + """Classify (l1, l2) link lengths as the standard or extended PF400 arm, or "unknown". + + "unknown" means the lengths match neither known config - a sign the arm's device-stored link + lengths may have been changed. + + Args: + links: (l1, l2) link lengths in mm (inner shoulder -> elbow, outer elbow -> wrist). + Returns: + "standard", "extended", or "unknown". + """ + l1, l2 = links + tol = _LINK_MATCH_TOLERANCE + if abs(l1 - ARM_LINKS_STANDARD[0]) <= tol and abs(l2 - ARM_LINKS_STANDARD[1]) <= tol: + return "standard" + if abs(l1 - ARM_LINKS_EXTENDED[0]) <= tol and abs(l2 - ARM_LINKS_EXTENDED[1]) <= tol: + return "extended" + return "unknown" + + +# --------------------------------------------------------------------------- +# Forward / inverse kinematics +# --------------------------------------------------------------------------- + +# -- forward kinematics ---------------------------------------------------- + + +def fk(joints: JointPose, p: PF400Params) -> PreciseFlexCartesianPose: + """Forward kinematics. + + Args: + joints: {1: J1 mm, 2: J2 deg, 3: J3 deg, 4: J4 deg, 6: rail position mm (optional)}. + p: kinematic parameters. + Returns: + PreciseFlexCartesianPose with location, rotation.yaw, rail_position, and + orientation/wrist derived from the joint configuration (J3 sign and + wrapped J4 sign, respectively). + """ + j1 = joints[1] + j2 = radians(joints[2]) + j3 = radians(joints[3]) + j4 = radians(joints[4]) + rail_position = joints.get(6, 0.0) + yaw = j2 + j3 + j4 + x = rail_position + p.l1 * cos(j2) + p.l2 * cos(j2 + j3) + p.gripper_length * cos(yaw) + y = p.l1 * sin(j2) + p.l2 * sin(j2 + j3) + p.gripper_length * sin(yaw) + z = j1 + p.gripper_z_offset + j3_wrapped = (joints[3] + 180) % 360 - 180 + orientation: ElbowOrientation = "right" if j3_wrapped >= 0 else "left" + wrist: Wrist = "ccw" if joints[4] >= 0 else "cw" + return PreciseFlexCartesianPose( + location=Coordinate(x, y, z), + rotation=Rotation(-180, 90, z=degrees(yaw)), + orientation=orientation, + wrist=wrist, + rail_position=rail_position, + ) + + +# -- inverse kinematics ---------------------------------------------------- + + +class IKError(ValueError): + """Target pose is unreachable.""" + + +def ik(pose: PreciseFlexCartesianPose, p: PF400Params) -> JointPose: + """Inverse kinematics. + + Args: + pose: PreciseFlexCartesianPose. Requires location.{x,y,z}, rotation.yaw, + orientation ("right"/"left" — elbow branch), wrist ("cw"/"ccw" — + absolute J4 sign), and rail_position (mm, arm's X origin). + p: kinematic parameters. + Returns: + joints dict {1: J1 mm, 2: J2 deg, 3: J3 deg, 4: J4 deg, 6: rail position mm}. + J4 is in (-360°, 0°] for wrist="cw" and [0°, 360°) for wrist="ccw" + (J4=0 qualifies for both). + Raises: + IKError if the target is unreachable or the wrist coincides with the + shoulder axis (singularity where the shoulder angle is undefined). + """ + if pose.orientation not in ("right", "left"): + raise ValueError(f"pose.orientation must be 'right' or 'left', got {pose.orientation!r}") + if pose.wrist not in ("cw", "ccw"): + raise ValueError(f"pose.wrist must be 'cw' or 'ccw', got {pose.wrist!r}") + if pose.rail_position is None: + raise ValueError("pose.rail_position must be set") + yaw = radians(pose.rotation.yaw) + + # Shoulder is at (pose.rail_position, 0) in world; work in shoulder-centered coords. + x_w = pose.location.x - pose.rail_position - p.gripper_length * cos(yaw) + y_w = pose.location.y - p.gripper_length * sin(yaw) + + r = hypot(x_w, y_w) + r_max = p.l1 + p.l2 + r_min = abs(p.l1 - p.l2) + if r > r_max + p.eps or r < r_min - p.eps: + raise IKError(f"wrist target r={r:.3f} mm outside annulus [{r_min:.3f}, {r_max:.3f}]") + if r < p.eps: + raise IKError("wrist target coincides with shoulder axis (singular)") + + c_elbow = (r * r - p.l1 * p.l1 - p.l2 * p.l2) / (2.0 * p.l1 * p.l2) + c_elbow = max(-1.0, min(1.0, c_elbow)) + s_elbow = (1 if pose.orientation == "right" else -1) * (1.0 - c_elbow * c_elbow) ** 0.5 + elbow_delta = atan2(s_elbow, c_elbow) + alpha = atan2(y_w, x_w) - atan2(p.l2 * s_elbow, p.l1 + p.l2 * c_elbow) + + j2 = _wrap(alpha) + j3 = _wrap(elbow_delta) + j4 = _wrap(yaw - alpha - elbow_delta) + # Tolerance on the sign check so J4 values within FP dust of 0 aren't + # pushed to ±2π; J4 ≈ 0 satisfies both conventions. + if pose.wrist == "cw" and j4 > p.eps: + j4 -= 2 * pi + elif pose.wrist == "ccw" and j4 < -p.eps: + j4 += 2 * pi + + return { + 1: pose.location.z - p.gripper_z_offset, + 2: degrees(j2), + 3: degrees(j3), + 4: degrees(j4), + 6: pose.rail_position, + } + + +def _wrap(a: float) -> float: + """Wrap angle to (-pi, pi].""" + return (a + pi) % (2 * pi) - pi diff --git a/pylabrobot/brooks/precise_flex/precise_flex.py b/pylabrobot/brooks/precise_flex/precise_flex.py new file mode 100644 index 00000000000..e70b8141582 --- /dev/null +++ b/pylabrobot/brooks/precise_flex/precise_flex.py @@ -0,0 +1,2481 @@ +"""PreciseFlex driver - owns the socket I/O connection and device lifecycle.""" + +import asyncio +import dataclasses +import logging +import time +import warnings +from typing import Callable, ClassVar, Dict, List, Literal, NamedTuple, Optional, Sequence + +from pylabrobot.brooks.precise_flex import kinematics +from pylabrobot.brooks.precise_flex.config import Axis, PreciseFlexConfiguration +from pylabrobot.brooks.precise_flex.kinematics import JointPose +from pylabrobot.io.socket import Socket +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.rotation import Rotation + +from .confirmed_firmware_versions import ( + SUPPORTED_ROBOT_TYPES, + is_confirmed, + is_supported_model, + suggest_entry, +) +from .data_ids import DataID, PowerState +from .errors import OutOfRangeOfMotionError, PreciseFlexError +from .interrupt import halt_and_resync, halt_on_interrupt +from .kinematics import ElbowOrientation, PreciseFlexCartesianPose, Wrist +from .tcs_modules import missing_required_modules + +logger = logging.getLogger(__name__) + + +# InRange sentinel that lets the controller blend through waypoints instead of stopping at each one. +BLEND_IN_RANGE = -1 + + +class MotionProfile(NamedTuple): + """A controller motion profile, as reported by ``Profile `` (field order matches the wire).""" + + profile: int + speed: float + speed2: float + acceleration: float + deceleration: float + acceleration_ramp: float + deceleration_ramp: float + in_range: float # -1 (BLEND_IN_RANGE) to 100; -1 blends, 0 stops, >0 enforces position accuracy + straight: bool # True = straight-line path, False = joint-based path + + +def _parse_scalar(response: str) -> float: + """Parse the first numeric field of a DataID reply. + + Some scalar DataIDs come back zero-padded (e.g. robot type as ``12, 0, 0, ...``) + and Cartesian references carry several components; take the leading value. + """ + return float(response.split(",")[0]) + + +def _parse_per_axis(response: str) -> Dict[Axis, float]: + """Parse a comma-separated per-axis DataID reply into an {Axis: value} map.""" + values = [float(v) for v in response.split(",")] + return {Axis(i + 1): values[i] for i in range(min(len(values), len(Axis)))} + + +def _zip_axis_ranges( + low: Dict[Axis, float], high: Dict[Axis, float] +) -> Dict[Axis, tuple[float, float]]: + """Combine min and max per-axis maps into an {Axis: (min, max)} map.""" + return {axis: (low[axis], high[axis]) for axis in low.keys() & high.keys()} + + +def _snap_to_current(ik_joints: JointPose, current: JointPose, wrist: Optional[Wrist]) -> JointPose: + """Shift each rotary joint by 360° multiples toward `current`, then re-enforce + the wrist-sign half on J4 so the result still matches `wrist`. Avoids + gratuitous full-turn moves when multiple IK solutions are equivalent. + """ + out = dict(ik_joints) + for axis in (Axis.SHOULDER, Axis.ELBOW, Axis.WRIST): + out[axis] += 360 * round((current[axis] - out[axis]) / 360) + if wrist == "ccw" and out[Axis.WRIST] < 0: + out[Axis.WRIST] += 360 + elif wrist == "cw" and out[Axis.WRIST] > 0: + out[Axis.WRIST] -= 360 + return out + + +class PreciseFlex: + """Driver for PreciseFlex robotic arms. + + Owns the Socket I/O connection and device-level operations (power, attach, + home, response mode). Exposes ``send_command`` as the generic wire method. + + Documentation and error codes available at + https://www2.brooksautomation.com/#Root/Welcome.htm + """ + + # Validated parked orientations: planar folds differing only in which way the arm faces, named for + # the direction the gripper points (BACK / RIGHT / FRONT). The Z column (Axis.BASE) is omitted on + # purpose - ``park()`` fills it from the discovered travel (3/4 of it) so one orientation works on + # any reach; set Axis.BASE yourself to override. The gripper and rail are left untouched so parking + # never drops a held plate or assumes a rail. Assign one to ``parking_position`` to change the park. + PARKING_POSITION_BACK: ClassVar[JointPose] = { + Axis.SHOULDER: 90.0, + Axis.ELBOW: 180.0, + Axis.WRIST: 90.0, + } + PARKING_POSITION_RIGHT: ClassVar[JointPose] = { + Axis.SHOULDER: 0.0, + Axis.ELBOW: 180.0, + Axis.WRIST: 180.0, + } + PARKING_POSITION_FRONT: ClassVar[JointPose] = { + Axis.SHOULDER: -90.0, + Axis.ELBOW: 180.0, + Axis.WRIST: 270.0, + } + + def __init__( + self, + host: str, + gripper_length: float, + gripper_z_offset: float, + closed_gripper_position: float, + port: int = 10100, + timeout: int = 20, + is_dual_gripper: bool = False, + has_rail: bool = False, + read_kinematics_from_device: bool = True, + recover_out_of_range: bool = True, + parking_position: Optional[JointPose] = None, + ) -> None: + """ + Args: + gripper_length: wrist-axis → TCP distance in mm. Used as the fallback / + override; when ``read_kinematics_from_device`` is True (the default) the + link lengths and tool length are read from the controller at setup and + this value is only used if that read fails. + gripper_z_offset: vertical offset in mm from the wrist plate to the tool tip. + Depends on the mounted gripper; the concrete Device wrapper supplies a + model-appropriate default. Always taken from here (not on the controller). + read_kinematics_from_device: when True, read l1/l2 and the tool length from + the controller at setup and use them for kinematics; the constructor's + ``gripper_length`` then acts only as a fallback. Set False to force the + constructor values regardless of what the controller reports. + recover_out_of_range: when True (the default), an out-of-range axis (its current position + outside its soft limit - a state the controller rejects every commanded move for, -1012) is + driven back into range once via ``recover_axes_within_limits``, the same way at both moments + it matters: at setup, and before a commanded move (which then retries). If it is still out of + range after that, ``OutOfRangeOfMotionError`` propagates (no loop). Set False to forbid this + autonomous motion - an out-of-range axis then raises instead, carrying recovery instructions. + Every recovery is logged. + closed_gripper_position: firmware-unit value (passed to ``GripClosePos`` / + ``GripOpenPos``) at which the jaws are at :attr:`min_gripper_width`. + Depends on the mounted gripper. The conversion mm → firmware units is + linear with slope 1: ``units = closed_gripper_position + (width_mm - + min_gripper_width)``. + parking_position: initial value for the public, runtime-settable ``parking_position`` that + ``park()`` moves to. Leave None (the default) and setup fills the generic default RIGHT pose + (planar fold, Z column at 3/4 of the discovered travel); reassign it any time to park + elsewhere. While unset (no configuration), ``park()`` falls back to ``movetosafe``. + """ + super().__init__() + self.io = Socket(human_readable_device_name="Precise Flex Arm", host=host, port=port) + self.timeout = timeout + self.profile_index: int = 1 + self.location_index: int = 1 + self._rail_position_index = 1 + self.horizontal_compliance: bool = False + self.horizontal_compliance_torque: int = 0 + self._has_rail = has_rail + self._is_dual_gripper = is_dual_gripper + self.closed_gripper_position = closed_gripper_position + self._kinematics_params = kinematics.PF400Params( + gripper_length=gripper_length, gripper_z_offset=gripper_z_offset + ) + self._read_kinematics_from_device = read_kinematics_from_device + self._recover_out_of_range = recover_out_of_range + # Device configuration, resolved once at setup; None until then. Set before parking_position so its + # validating setter can check assignments against the soft limits once they are known. + self._configuration: Optional[PreciseFlexConfiguration] = None + # Public and runtime-settable (validated on assignment); setup fills the default RIGHT pose when + # this is left None. + self.parking_position = parking_position + if is_dual_gripper: + warnings.warn( + "Dual gripper support is experimental and may not work as expected.", UserWarning + ) + + # -- communication --------------------------------------------------------- + + async def send_command(self, command: str) -> str: + await self.io.write(command.encode("utf-8") + b"\n") + reply = await self.io.readline() + return self._parse_reply_ensure_successful(reply) + + def _parse_reply_ensure_successful(self, reply: bytes) -> str: + """Parse reply from Precise Flex. + + Expected format: b'replycode data message\r\n' + - replycode is an integer at the beginning + - data is rest of the line (excluding CRLF) + """ + text = reply.decode().strip() + if not text: + raise PreciseFlexError(-1, "Empty reply from device.") + parts = text.split(" ", 1) + if len(parts) == 1: + replycode = int(parts[0]) + data = "" + else: + replycode, data = int(parts[0]), parts[1] + if replycode != 0: + raise PreciseFlexError(replycode, data) + return data + + # -- lifecycle ------------------------------------------------------------- + + async def setup(self, skip_home: bool = False): + """Initialize the PreciseFlex driver. + + Opens the socket connection, sets response mode to PC, powers on the + robot, attaches it, and (optionally) homes it. + + Args: + skip_home: If True, skip the homing step during setup. + """ + await self.io.setup() + await self.set_response_mode("pc") + await self.power_on_robot() + await self.attach(1) + if not skip_home: + await self.home() + logger.debug("[PreciseFlex %s] connected: port=%s", self.io._host, self.io._port) + + await self.stop_freedrive_mode() + # Resolve the device configuration once and adopt it as the source of truth; + # without it the class defaults stay in place. + try: + self._configuration = await self._request_configuration() + except Exception as exc: # discovery is best-effort + logger.warning( + "[PreciseFlex %s] could not read configuration, using defaults: %s", + self.io._host, + exc, + ) + return + self._adopt_configuration(self._configuration) + if self.parking_position is None: + self.parking_position = self.PARKING_POSITION_RIGHT + self._log_configuration_summary(self._configuration) + self._assess_configuration(self._configuration) + await self._handle_out_of_range_axes() + + async def stop(self): + """Stop the PreciseFlex driver.""" + await self.detach() + await self.power_off_robot() + await self._exit() + await self.io.stop() + logger.info("[PreciseFlex %s] disconnected: port=%s", self.io._host, self.io._port) + + # -- device-level commands ------------------------------------------------- + + async def _exit(self) -> None: + """Close the communications link immediately. + + Note: + Does not affect any robots that may be active. + """ + await self.io.write(b"exit\n") + + ResponseMode = Literal["pc", "verbose"] + + async def request_mode(self) -> ResponseMode: + """Get the current response mode. + + Returns: + Current mode (0 = PC mode, 1 = verbose mode) + """ + response = await self.send_command("mode") + mapping: Dict[int, "PreciseFlex.ResponseMode"] = {0: "pc", 1: "verbose"} + return mapping[int(response)] + + async def set_response_mode(self, mode: ResponseMode) -> None: + """Set the response mode. + + Args: + mode: Response mode to set. + 0 = Select PC mode + 1 = Select verbose mode + + Note: + When using serial communications, the mode change does not take effect + until one additional command has been processed. + """ + if mode not in ["pc", "verbose"]: + raise ValueError("Mode must be 'pc' or 'verbose'") + mapping = {"pc": 0, "verbose": 1} + await self.send_command(f"mode {mapping[mode]}") + + async def request_system_state(self) -> int: + """Controller power/system-state word (the ``sysState`` command, == DataID 234). + + See :class:`~pylabrobot.brooks.precise_flex.data_ids.PowerState` for the values; + ``PowerState.OFF_HARD_ESTOP`` (15) means a hard E-stop is engaged, ``PowerState.ON_ATTACHED`` + (21) is the normal running state. Read-only, so it detects an E-stop without provoking an error. + """ + return int(await self.send_command("sysState")) + + async def power_on_robot(self): + """Power on the robot.""" + error: Optional[PreciseFlexError] = None + for _ in range(3): + try: + await self.set_power(True, self.timeout) + except PreciseFlexError as e: + logger.warning(f"Error powering on robot, retrying... Attempt {_ + 1}/3. Error: {e}") + error = e + else: + return + + if error: + raise error + raise RuntimeError("Failed to power on robot after 3 attempts for unknown reasons.") + + async def recover_from_fault(self) -> None: + """Recover after a collision / fault that stopped the arm and dropped power, leaving it usable. + + A collision trips an envelope error (``-3100`` hard / ``-3122`` soft, see + :func:`~pylabrobot.brooks.precise_flex.errors.is_collision`); the servo stops the arm itself and + high power drops. This re-enables power, re-attaches, and re-homes (which only cycles the gripper + when the other axes are already homed - absolute encoders retain them - so it does not sweep the + arm), leaving it ready to move. It does **not** drive the arm to any pose; confirm the obstacle is + removed before calling. + + The envelope error auto-clears, so no explicit clear is needed; a latched fatal that blocks + power-on is surfaced by ``power_on_robot`` for the operator to reset (DataID 247) or reboot. + + Raises: + PreciseFlexError: if a hard E-stop is engaged (release the button first) or power cannot be + re-enabled. + """ + if await self.request_system_state() == PowerState.OFF_HARD_ESTOP: + raise PreciseFlexError( + -1028, "hard E-Stop engaged - release the E-stop button before recovering" + ) + await self.power_on_robot() + await self.attach(1) + await self.home() + + async def power_off_robot(self): + """Power off the robot.""" + await self.set_power(False) + + async def set_power(self, enable: bool, timeout: int = 0) -> None: + """Enable or disable robot high power. + + Args: + enable: True to enable power, False to disable + timeout: Wait timeout for power to come on. + 0 or omitted = do not wait for power to come on + > 0 = wait this many seconds for power to come on + -1 = wait indefinitely for power to come on + + Raises: + PreciseFlexError: If power does not come on within the specified timeout. + """ + power_state = 1 if enable else 0 + if timeout == 0: + await self.send_command(f"hp {power_state}") + else: + await self.send_command(f"hp {power_state} {timeout}") + + async def request_power_state(self) -> int: + """Get the current robot power state. + + Returns: + Current power state (0 = disabled, 1 = enabled) + """ + response = await self.send_command("hp") + return int(response) + + async def attach(self, attach_state: Optional[int] = None) -> int: + """Attach or release the robot, or get attachment state. + + Args: + attach_state: If omitted, returns the attachment state. 0 = release the robot; 1 = attach the robot. + + Returns: + If attach_state is omitted, returns 0 if robot is not attached, -1 if attached. Otherwise returns 0 on success. + + Note: + The robot must be attached to allow motion commands. + """ + if attach_state is None: + response = await self.send_command("attach") + return int(response) + await self.send_command(f"attach {attach_state}") + return 0 + + async def detach(self): + """Detach the robot.""" + await self.attach(0) + + async def home(self) -> None: + """Home the robot associated with this thread. + + Note: + Requires power to be enabled. + Requires robot to be attached. + Waits until the homing is complete. + """ + await self.send_command("home") + + async def home_all(self) -> None: + """Home all robots. + + Note: + Requires power to be enabled. + Requires that robots not be attached. + """ + await self.send_command("homeAll") + + async def _wait_for_eom( + self, poll_interval: float = 0.05, settle: float = 0.02, timeout: float = 60.0 + ) -> None: + """Wait (non-blocking) until the arm has stopped moving, keeping the connection responsive. + + Polls the live joint position (``wherej``) and returns once it stops changing between samples + (every axis moving less than ``settle``) - i.e. end of motion. It returns promptly when the arm + is already stationary, including when it was stopped short of its last commanded target (after a + halt/interrupt or a hand-move), so it never hangs waiting to reach a target that will not be + reached. + + This deliberately avoids the firmware ``waitForEom``: that command parks the controller's single + command interpreter and makes it ignore everything else on the connection - including ``halt`` - + until the move ends (hardware-verified). Polling instead leaves the connection free between + samples, so a user interrupt can stop the move mid-flight via ``halt`` and other controller + commands (status, vision, barcode) can run during motion. + + Raises: + TimeoutError: if the arm never settles within ``timeout`` seconds. + OperationInterrupted: on a user interrupt (the arm is halted and the connection kept). + """ + + def _floats(reply: str) -> list[float]: + return [float(x) for x in reply.split()] + + # On interrupt, `halt` stops the move on the now-free connection and we resync; the connection is + # kept open. Hardware-verified: a clean halt keeps power, attach, and the link (only a collision + # trips -3122 and drops power, which needs explicit recovery). + async with halt_on_interrupt(lambda: halt_and_resync(self.io, b"halt")): + previous = _floats(await self.send_command("wherej")) + deadline = time.monotonic() + timeout + while True: + await asyncio.sleep(poll_interval) + current = _floats(await self.send_command("wherej")) + if all(abs(c - p) < settle for c, p in zip(current, previous)): + return # stopped moving + if time.monotonic() > deadline: + raise TimeoutError(f"motion did not settle within {timeout:.0f}s (current={current})") + previous = current + + async def request_state(self) -> str: + """Return state of motion. + + This value indicates the state of the currently executing or last completed robot motion. + For additional information, please see 'Robot.TrajState' in the GPL reference manual. + + Returns: + str: The current motion state. + """ + return await self.send_command("state") + + def _parse_xyz_response( + self, parts: List[str] + ) -> tuple[float, float, float, float, float, float]: + if len(parts) != 6: + raise PreciseFlexError(-1, "Unexpected response format for Cartesian coordinates.") + return ( + float(parts[0]), + float(parts[1]), + float(parts[2]), + float(parts[3]), + float(parts[4]), + float(parts[5]), + ) + + def _parse_angles_response(self, parts: List[str]) -> JointPose: + """Parse angle values from a response string. + + For self._has_rail=True: wire order is [base, shoulder, elbow, wrist, gripper, rail] + For self._has_rail=False: wire order is [base, shoulder, elbow, wrist, gripper] + """ + if len(parts) < 3: + raise PreciseFlexError(-1, "Unexpected response format for angles.") + if self._has_rail: + return { + Axis.RAIL: float(parts[5]) if len(parts) > 5 else 0.0, + Axis.BASE: float(parts[0]), + Axis.SHOULDER: float(parts[1]), + Axis.ELBOW: float(parts[2]), + Axis.WRIST: float(parts[3]) if len(parts) > 3 else 0.0, + Axis.GRIPPER: float(parts[4]) if len(parts) > 4 else 0.0, + } + return { + Axis.RAIL: 0.0, + Axis.BASE: float(parts[0]), + Axis.SHOULDER: float(parts[1]), + Axis.ELBOW: float(parts[2]) if len(parts) > 2 else 0.0, + Axis.WRIST: float(parts[3]) if len(parts) > 3 else 0.0, + Axis.GRIPPER: float(parts[4]) if len(parts) > 4 else 0.0, + } + + # -- raw parameters ----------------------------------------------------------------------- + + async def request_parameter( + self, + data_id: int, + unit_number: Optional[int] = None, + sub_unit: Optional[int] = None, + array_index: Optional[int] = None, + ) -> str: + """Get the value of a numeric parameter database item. + + Args: + data_id: DataID of parameter. + unit_number: Unit number, usually the robot number (1-NROB). + sub_unit: Sub-unit, usually 0. + array_index: Array index. + + Returns: + str: The numeric value of the specified database parameter. + """ + if unit_number is not None: + if sub_unit is not None: + if array_index is not None: + response = await self.send_command(f"pd {data_id} {unit_number} {sub_unit} {array_index}") + else: + response = await self.send_command(f"pd {data_id} {unit_number} {sub_unit}") + else: + response = await self.send_command(f"pd {data_id} {unit_number}") + else: + response = await self.send_command(f"pd {data_id}") + return response + + async def set_parameter( + self, + data_id: int, + value, + unit_number: Optional[int] = None, + sub_unit: Optional[int] = None, + array_index: Optional[int] = None, + ) -> None: + """Change a value in the controller's parameter database. + + Args: + data_id: DataID of parameter. + value: New parameter value. If string, will be quoted automatically. + unit_number: Unit number, usually the robot number (1 - N_ROB). + sub_unit: Sub-unit, usually 0. + array_index: Array index. + + Note: + Updated values are not saved in flash unless a save-to-flash operation + is performed (see DataID 901). + """ + if unit_number is not None and sub_unit is not None and array_index is not None: + if isinstance(value, str): + await self.send_command(f'pc {data_id} {unit_number} {sub_unit} {array_index} "{value}"') + else: + await self.send_command(f"pc {data_id} {unit_number} {sub_unit} {array_index} {value}") + else: + if isinstance(value, str): + await self.send_command(f'pc {data_id} "{value}"') + else: + await self.send_command(f"pc {data_id} {value}") + + async def set_axis_parameter( + self, + data_id: int, + axis: Axis, + value, + robot_number: int = 1, + ) -> None: + """Change one joint's element of a per-axis parameter array (``pc``). + + Per-axis DataIDs (motor current limits, hard-stop homing envelope, joint limits) + hold one value per joint; this writes a single joint's element and leaves the rest + untouched. ``axis`` is the controller's 1-based array index (``Axis.GRIPPER`` -> 5), + cast to int at the wire boundary; reads of the same DataID come back in this order + (see ``_parse_per_axis``). + + Args: + data_id: the per-axis DataID to change. + axis: which joint's element to write. + value: the new value for that element. + robot_number: unit number, the robot (1 - N_ROB). + + Note: + Volatile until a save-to-flash (DataID 901); a power cycle otherwise restores the + flashed value. + """ + await self.set_parameter( + data_id, value, unit_number=robot_number, sub_unit=0, array_index=int(axis) + ) + + async def nop(self) -> None: + """No operation command. + + Does nothing except return the standard reply. Can be used to see if the link + is active or to check for exceptions. + """ + await self.send_command("nop") + + # -- digital I/O -------------------------------------------------------------------------- + + async def request_signal(self, signal_number: int) -> int: + """Get the value of the specified digital input or output signal. + + Args: + signal_number: The number of the digital signal to get. + + Returns: + The current signal value. + """ + response = await self.send_command(f"sig {signal_number}") + sig_id, sig_val = response.split() + return int(sig_val) + + async def set_signal(self, signal_number: int, value: int) -> None: + """Set the specified digital input or output signal. + + Args: + signal_number: The number of the digital signal to set. + value: The signal value to set. 0 = off, non-zero = on. + """ + await self.send_command(f"sig {signal_number} {value}") + + # -- motion primitives -------------------------------------------------------------------- + + async def _move_j(self, profile_index: int, joint_coords: JointPose) -> None: + """Move the robot using joint coordinates, handling rail configuration. Raw moveJ - the + out-of-range guard lives in the caller (``_guarded_move_j``), not in this primitive.""" + if self._has_rail: + angles_str = ( + f"{joint_coords[Axis.BASE]} " + f"{joint_coords[Axis.SHOULDER]} " + f"{joint_coords[Axis.ELBOW]} " + f"{joint_coords[Axis.WRIST]} " + f"{joint_coords[Axis.GRIPPER]} " + f"{joint_coords[Axis.RAIL]} " + ) + else: + angles_str = ( + f"{joint_coords[Axis.BASE]} " + f"{joint_coords[Axis.SHOULDER]} " + f"{joint_coords[Axis.ELBOW]} " + f"{joint_coords[Axis.WRIST]} " + f"{joint_coords[Axis.GRIPPER]}" + ) + await self.send_command(f"moveJ {profile_index} {angles_str}") + + async def _move_one_axis(self, axis: Axis, position: float) -> None: + """Move a single axis to an absolute position (firmware ``MoveOneAxis``). + + Used for recovery: the controller blocks a normal move while an axis is out of + range, but allows a single-axis move heading back into range. Does not wait for + the motion to complete. + """ + await self.send_command(f"MoveOneAxis {int(axis)} {position} {self.profile_index}") + + async def _move_to_stored_location(self, location_index: int, profile_index: int) -> None: + """Move to the location specified by the station index using the specified profile. + + Args: + location_index: The index of the location to which the robot moves. + profile_index: The profile index for this move. + + Note: + Requires that the robot be attached. + """ + await self.send_command(f"move {location_index} {profile_index}") + + async def _move_to_stored_location_appro(self, location_index: int, profile_index: int) -> None: + """Approach the location specified by the station index using the specified profile. + + This is similar to `_move_to_stored_location` except that the Z clearance value is included. + + Args: + location_index: The index of the location to which the robot moves. + profile_index: The profile index for this move. + + Note: + Requires that the robot be attached. + """ + await self.send_command(f"moveAppro {location_index} {profile_index}") + + async def _set_joint_angles( + self, + location_index: int, + joint_position: JointPose, + ) -> None: + """Set joint angles for stored location, handling rail configuration.""" + if self._has_rail: + await self.send_command( + f"locAngles {location_index} " + f"{joint_position[Axis.RAIL]} " + f"{joint_position[Axis.BASE]} " + f"{joint_position[Axis.SHOULDER]} " + f"{joint_position[Axis.ELBOW]} " + f"{joint_position[Axis.WRIST]} " + f"{joint_position[Axis.GRIPPER]}" + ) + else: + await self.send_command( + f"locAngles {location_index} " + f"{joint_position[Axis.BASE]} " + f"{joint_position[Axis.SHOULDER]} " + f"{joint_position[Axis.ELBOW]} " + f"{joint_position[Axis.WRIST]} " + f"{joint_position[Axis.GRIPPER]}" + ) + + async def _cart_to_joints(self, cart: PreciseFlexCartesianPose) -> JointPose: + """Convert a Cartesian location into a full joint dict using our IK. + + Any of cart.orientation, cart.wrist, and cart.rail_position left as None + default to the current pose - picks the configuration closest to where the + arm is now. Fetches current joint state for the gripper and rail axes so + callers get a complete joint dict, ready for `_guarded_move_j`. + """ + joints, current = await self._request_state() + cart = dataclasses.replace( + cart, + orientation=current.orientation if cart.orientation is None else cart.orientation, + wrist=current.wrist if cart.wrist is None else cart.wrist, + rail_position=current.rail_position if cart.rail_position is None else cart.rail_position, + ) + ik_joints = _snap_to_current(kinematics.ik(cart, p=self._kinematics_params), joints, cart.wrist) + # IK only solves the arm axes; gripper and rail keep their current values. + for axis in (Axis.BASE, Axis.SHOULDER, Axis.ELBOW, Axis.WRIST): + joints[axis] = ik_joints[axis] + if cart.rail_position is not None: + joints[Axis.RAIL] = cart.rail_position + return joints + + # -- speed & motion profiles -------------------------------------------------------------- + + async def request_monitor_speed(self) -> int: + """Get the global system (monitor) speed. + + Returns: + Current monitor speed as a percentage (0-100) + """ + response = await self.send_command("mspeed") + return int(response) + + async def set_monitor_speed(self, speed_pct: int) -> None: + """Set the global system (monitor) speed. + + Args: + speed_pct: Speed percentage between 0 and 100, where 100 means full speed. + + Raises: + ValueError: If speed_pct is not between 0 and 100. + """ + if not 0 <= speed_pct <= 100: + raise ValueError(f"speed_pct must be between 0 and 100, got {speed_pct}") + await self.send_command(f"mspeed {speed_pct}") + + async def request_payload(self) -> int: + """Get the payload percent value for the current robot. + + Returns: + Current payload as a percentage of maximum (0-100) + """ + response = await self.send_command("payload") + return int(response) + + async def set_payload(self, payload_pct: int) -> None: + """Set the payload percent of maximum for the currently selected or attached robot. + + Args: + payload_pct: Payload percentage from 0 to 100 indicating the percent of the maximum payload the robot is carrying. + + Raises: + ValueError: If payload_pct is not between 0 and 100. + + Note: + If the robot is moving, waits for the robot to stop before setting a value. + """ + if not (0 <= payload_pct <= 100): + raise ValueError("Payload percent must be between 0 and 100") + await self.send_command(f"payload {payload_pct}") + + async def request_profile_speed(self, profile_index: int) -> float: + """Get the speed property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + float: The current speed as a percentage. 100 = full speed. + """ + response = await self.send_command(f"Speed {profile_index}") + profile, speed = response.split() + return float(speed) + + async def set_profile_speed(self, profile_index: int, speed_pct: float) -> None: + """Set the speed property of the specified profile. + + Args: + profile_index: The profile index to modify. + speed_pct: The new speed as a percentage (0-100). 100 = full speed. + + Raises: + ValueError: If speed_pct is not between 0 and 100. + """ + if not 0 <= speed_pct <= 100: + raise ValueError(f"speed_pct must be between 0 and 100, got {speed_pct}") + await self.send_command(f"Speed {profile_index} {speed_pct}") + + async def request_profile_speed2(self, profile_index: int) -> float: + """Get the speed2 property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + float: The current speed2 as a percentage. Used for Cartesian moves. + """ + response = await self.send_command(f"Speed2 {profile_index}") + profile, speed2 = response.split() + return float(speed2) + + async def set_profile_speed2(self, profile_index: int, speed2_pct: float) -> None: + """Set the speed2 property of the specified profile. + + Args: + profile_index: The profile index to modify. + speed2_pct: The new speed2 as a percentage (0-100). 100 = full speed. + Used for Cartesian moves. Normally set to 0. + + Raises: + ValueError: If speed2_pct is not between 0 and 100. + """ + if not 0 <= speed2_pct <= 100: + raise ValueError(f"speed2_pct must be between 0 and 100, got {speed2_pct}") + await self.send_command(f"Speed2 {profile_index} {speed2_pct}") + + async def request_profile_acceleration(self, profile_index: int) -> float: + """Get the acceleration property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + float: The current acceleration as a percentage. 100 = maximum acceleration. + """ + response = await self.send_command(f"Accel {profile_index}") + profile, acceleration = response.split() + return float(acceleration) + + async def set_profile_acceleration(self, profile_index: int, acceleration_pct: float) -> None: + """Set the acceleration property of the specified profile. + + Args: + profile_index: The profile index to modify. + acceleration_pct: The new acceleration as a percentage (0-100). 100 = maximum acceleration. + + Raises: + ValueError: If acceleration_pct is not between 0 and 100. + """ + if not 0 <= acceleration_pct <= 100: + raise ValueError(f"acceleration_pct must be between 0 and 100, got {acceleration_pct}") + await self.send_command(f"Accel {profile_index} {acceleration_pct}") + + async def request_profile_acceleration_ramp(self, profile_index: int) -> float: + """Get the acceleration ramp property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + float: The current acceleration ramp time in seconds. + """ + response = await self.send_command(f"AccRamp {profile_index}") + profile, acceleration_ramp = response.split() + return float(acceleration_ramp) + + async def set_profile_acceleration_ramp( + self, profile_index: int, acceleration_ramp_seconds: float + ) -> None: + """Set the acceleration ramp property of the specified profile. + + Args: + profile_index: The profile index to modify. + acceleration_ramp_seconds: The new acceleration ramp time in seconds. + """ + await self.send_command(f"AccRamp {profile_index} {acceleration_ramp_seconds}") + + async def request_profile_deceleration(self, profile_index: int) -> float: + """Get the deceleration property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + float: The current deceleration as a percentage. 100 = maximum deceleration. + """ + response = await self.send_command(f"Decel {profile_index}") + profile, deceleration = response.split() + return float(deceleration) + + async def set_profile_deceleration(self, profile_index: int, deceleration_pct: float) -> None: + """Set the deceleration property of the specified profile. + + Args: + profile_index: The profile index to modify. + deceleration_pct: The new deceleration as a percentage (0-100). 100 = maximum deceleration. + + Raises: + ValueError: If deceleration_pct is not between 0 and 100. + """ + if not 0 <= deceleration_pct <= 100: + raise ValueError(f"deceleration_pct must be between 0 and 100, got {deceleration_pct}") + await self.send_command(f"Decel {profile_index} {deceleration_pct}") + + async def request_profile_deceleration_ramp(self, profile_index: int) -> float: + """Get the deceleration ramp property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + float: The current deceleration ramp time in seconds. + """ + response = await self.send_command(f"DecRamp {profile_index}") + profile, deceleration_ramp = response.split() + return float(deceleration_ramp) + + async def set_profile_deceleration_ramp( + self, profile_index: int, deceleration_ramp_seconds: float + ) -> None: + """Set the deceleration ramp property of the specified profile. + + Args: + profile_index: The profile index to modify. + deceleration_ramp_seconds: The new deceleration ramp time in seconds. + """ + await self.send_command(f"DecRamp {profile_index} {deceleration_ramp_seconds}") + + async def request_profile_in_range(self, profile_index: int) -> float: + """Get the InRange property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + float: The current InRange value (-1 to 100). + -1 = do not stop at end of motion if blending is possible + 0 = always stop but do not check end point error + > 0 = wait until close to end point (larger numbers mean less position error allowed) + """ + response = await self.send_command(f"InRange {profile_index}") + profile, in_range = response.split() + return float(in_range) + + async def set_profile_in_range(self, profile_index: int, in_range_value: float) -> None: + """Set the InRange property of the specified profile. + + Args: + profile_index: The profile index to modify. + in_range_value: The new InRange value from -1 to 100. + -1 = do not stop at end of motion if blending is possible + 0 = always stop but do not check end point error + > 0 = wait until close to end point (larger numbers mean less position error allowed) + + Raises: + ValueError: If in_range_value is not between -1 and 100. + """ + if not (-1 <= in_range_value <= 100): + raise ValueError("InRange value must be between -1 and 100") + await self.send_command(f"InRange {profile_index} {in_range_value}") + + async def request_profile_straight(self, profile_index: int) -> bool: + """Get the Straight property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + The current Straight property value. + True = follow a straight-line path + False = follow a joint-based path (coordinated axes movement) + """ + response = await self.send_command(f"Straight {profile_index}") + profile, straight = response.split() + return straight == "True" + + async def set_profile_straight(self, profile_index: int, straight_mode: bool) -> None: + """Set the Straight property of the specified profile. + + Args: + profile_index: The profile index to modify. + straight_mode: The path type to use. + True = follow a straight-line path + False = follow a joint-based path (robot axes move in coordinated manner) + + Raises: + ValueError: If straight_mode is not True or False. + """ + straight_int = 1 if straight_mode else 0 + await self.send_command(f"Straight {profile_index} {straight_int}") + + async def request_motion_profile_values(self, profile: int) -> MotionProfile: + """ + Get the current motion profile values for the specified profile index on the PreciseFlex robot. + + Args: + profile: Profile index to get values for. + + Returns: + A :class:`MotionProfile` with the profile's speed, acceleration, ramps, InRange and path mode. + """ + data = await self.send_command(f"Profile {profile}") + parts = data.split(" ") + if len(parts) != 9: + raise PreciseFlexError(-1, "Unexpected response format from device.") + return MotionProfile( + int(parts[0]), + float(parts[1]), + float(parts[2]), + float(parts[3]), + float(parts[4]), + float(parts[5]), + float(parts[6]), + float(parts[7]), + int(parts[8]) != 0, + ) + + async def set_motion_profile_values( + self, + profile: int, + speed_pct: float, + speed2_pct: float, + acceleration_pct: float, + deceleration_pct: float, + acceleration_ramp: float, + deceleration_ramp: float, + in_range: float, + straight: bool, + ): + """ + Set motion profile values for the specified profile index on the PreciseFlex robot. + + Args: + profile: Profile index to set values for. + speed_pct: Percentage of maximum speed (0-100). 100 = full speed. + speed2_pct: Secondary speed setting (0-100), typically for Cartesian moves. Normally 0. + acceleration_pct: Percentage of maximum acceleration (0-100). 100 = full acceleration. + deceleration_pct: Percentage of maximum deceleration (0-100). 100 = full deceleration. + acceleration_ramp: Acceleration ramp time in seconds. + deceleration_ramp: Deceleration ramp time in seconds. + in_range: InRange value, from -1 to 100. -1 = allow blending, 0 = stop without checking, >0 = enforce position accuracy. + straight: If True, follow a straight-line path (-1). If False, follow a joint-based path (0). + """ + if not 0 <= speed_pct <= 100: + raise ValueError(f"speed_pct must be between 0 and 100, got {speed_pct}") + if not 0 <= speed2_pct <= 100: + raise ValueError(f"speed2_pct must be between 0 and 100, got {speed2_pct}") + if not 0 <= acceleration_pct <= 100: + raise ValueError(f"acceleration_pct must be between 0 and 100, got {acceleration_pct}") + if not 0 <= deceleration_pct <= 100: + raise ValueError(f"deceleration_pct must be between 0 and 100, got {deceleration_pct}") + if acceleration_ramp < 0: + raise ValueError("acceleration_ramp must be >= 0 (seconds).") + if deceleration_ramp < 0: + raise ValueError("deceleration_ramp must be >= 0 (seconds).") + if not (-1 <= in_range <= 100): + raise ValueError("InRange must be between -1 and 100.") + straight_int = -1 if straight else 0 + await self.send_command( + f"Profile {profile} {speed_pct} {speed2_pct} {acceleration_pct} {deceleration_pct} " + f"{acceleration_ramp} {deceleration_ramp} {in_range} {straight_int}" + ) + + async def _set_speed(self, speed_pct: float): + """Set the speed percentage of the arm's movement (0-100).""" + await self.set_profile_speed(self.profile_index, speed_pct) + + async def _request_speed(self) -> float: + """Get the current speed percentage of the arm's movement.""" + return await self.request_profile_speed(self.profile_index) + + # -- brakes, torque & freedrive ----------------------------------------------------------- + + async def release_brake(self, axis: int) -> None: + """Release the axis brake. + + Overrides the normal operation of the brake. It is important that the brake not be set + while a motion is being performed. This feature is used to lock an axis to prevent + motion or jitter. + + Args: + axis: The number of the axis whose brake should be released. + """ + await self.send_command(f"releaseBrake {axis}") + + async def set_brake(self, axis: int) -> None: + """Set the axis brake. + + Overrides the normal operation of the brake. It is important not to set a brake on an + axis that is moving as it may damage the brake or damage the motor. + + Args: + axis: The number of the axis whose brake should be set. + """ + await self.send_command(f"setBrake {axis}") + + async def zero_torque(self, enable: bool, axis_mask: int = 1) -> None: + """Sets or clears zero torque mode for the selected robot. + + Individual axes may be placed into zero torque mode while the remaining axes are servoing. + + Args: + enable: If True, enable torque mode for axes specified by axis_mask. If False, disable torque mode for the entire robot. + axis_mask: The bit mask specifying the axes to be placed in torque mode when enable is True. The mask is computed by OR'ing the axis bits: 1 = axis 1, 2 = axis 2, 4 = axis 3, 8 = axis 4, etc. Ignored when enable is False. + """ + if enable: + assert axis_mask > 0, "axis_mask must be greater than 0" + await self.send_command(f"zeroTorque 1 {axis_mask}") + else: + await self.send_command("zeroTorque 0") + + async def start_freedrive_mode(self, free_axes: Optional[List[int]] = None) -> None: + """Enter freedrive mode, allowing manual movement of the specified joints. + + The robot must be attached to enter free mode. + + Args: + free_axes: List of joint indices to free. Use [0] for all axes. + """ + if free_axes is None: + # Default to the positioning axes that exist; include the rail only when + # fitted - freemode on an absent axis returns -2800 on a no-rail arm. The + # cached configuration is the source of truth for the installed axes; fall + # back to the constructor hint before setup has resolved it. + has_rail = self._configuration.has_rail if self._configuration is not None else self._has_rail + free_axes = [Axis.BASE, Axis.SHOULDER, Axis.ELBOW, Axis.WRIST] + if has_rail: + free_axes.append(Axis.RAIL) + for axis in free_axes: + await self.send_command(f"freemode {axis}") + + async def stop_freedrive_mode(self) -> None: + """Exit freedrive mode for all axes.""" + await self.send_command("freemode -1") + + async def halt(self): + """Stops the current robot immediately but leaves power on.""" + await self.send_command("halt") + + # -- gripper primitives ------------------------------------------------------------------- + + async def change_config(self, grip_mode: int = 0) -> None: + """Change Robot configuration from Righty to Lefty or vice versa using customizable locations. + + Uses customizable locations to avoid hitting robot during change. + Does not include checks for collision inside work volume of the robot. + Can be customized by user for their work cell configuration. + + Args: + grip_mode: Gripper control mode. + 0 = do not change gripper (default) + 1 = open gripper + 2 = close gripper + """ + await self.send_command(f"ChangeConfig {grip_mode}") + + async def change_config2(self, grip_mode: int = 0) -> None: + """Change Robot configuration from Righty to Lefty or vice versa using algorithm. + + Uses an algorithm to avoid hitting robot during change. + Does not include checks for collision inside work volume of the robot. + Can be customized by user for their work cell configuration. + + Args: + grip_mode: Gripper control mode. + 0 = do not change gripper (default) + 1 = open gripper + 2 = close gripper + """ + await self.send_command(f"ChangeConfig2 {grip_mode}") + + async def _request_grip_close_pos(self) -> float: + """Get the gripper close position for the servoed gripper. + + Returns: + float: The current gripper close position. + """ + data = await self.send_command("GripClosePos") + return float(data) + + async def _set_grip_close_pos(self, close_position: float) -> None: + """Set the gripper close position for the servoed gripper. + + The close position may be changed by a force-controlled grip operation. + + Args: + close_position: The new gripper close position. + """ + await self.send_command(f"GripClosePos {close_position}") + + async def _request_grip_open_pos(self) -> float: + """Get the gripper open position for the servoed gripper. + + Returns: + float: The current gripper open position. + """ + data = await self.send_command("GripOpenPos") + return float(data) + + async def _set_grip_open_pos(self, open_position: float) -> None: + """Set the gripper open position for the servoed gripper. + + Args: + open_position: The new gripper open position. + """ + await self.send_command(f"GripOpenPos {open_position}") + + async def _request_grasp_data(self) -> tuple[float, float, float]: + """Get the data to be used for the next force-controlled PickPlate command grip operation. + + Returns: + A tuple containing (plate_width_mm, finger_speed_pct, grasp_force) + """ + data = await self.send_command("GraspData") + parts = data.split() + if len(parts) != 3: + raise PreciseFlexError(-1, "Unexpected response format from GraspData command.") + return (float(parts[0]), float(parts[1]), float(parts[2])) + + async def _set_grasp_data( + self, plate_width: float, finger_speed_pct: float, grasp_force: float + ) -> None: + """Set the data to be used for the next force-controlled PickPlate command grip operation. + + This data remains in effect until the next GraspData command or the system is restarted. + + Args: + plate_width: The plate width in mm. + finger_speed_pct: The finger speed during grasp as a percentage (0-100). 100 = full speed. + grasp_force: The gripper squeezing force, in Newtons. + A positive value indicates the fingers must close to grasp. + A negative value indicates the fingers must open to grasp. + + Raises: + ValueError: If finger_speed_pct is not between 0 and 100. + """ + if not 0 <= finger_speed_pct <= 100: + raise ValueError(f"finger_speed_pct must be between 0 and 100, got {finger_speed_pct}") + await self.send_command(f"GraspData {plate_width} {finger_speed_pct} {grasp_force}") + + async def _set_grip_detail(self): + """Configure a default vertical station type for pick/place operations.""" + await self.send_command(f"StationType {self.location_index} 1 0 100 0 10") + + def _mm_to_firmware_units(self, width_mm: float) -> float: + """Convert a jaw width (mm) to the firmware's native position unit. + + Anchored at :attr:`closed_gripper_position`, which is the firmware value + when the jaws are at :attr:`min_gripper_width`. Slope is 1 (1 mm = 1 unit). + """ + return self.closed_gripper_position + (width_mm - self.min_gripper_width) + + # -- rail primitives ---------------------------------------------------------------------- + + async def _set_rail_position(self, station_id: int, rail_position: float) -> None: + """Set the rail position for the specified station. + + Args: + station_id: The station index. + rail_position: The rail position in mm. + """ + await self.send_command(f"Rail {station_id} {rail_position}") + + async def _move_rail(self, station_id: Optional[int] = None, mode: int = 1) -> None: + """Move the rail to the position stored at the specified station. + + Args: + station_id: The station index whose rail position to move to. + mode: Motion mode (0 = normal). + """ + if station_id is not None: + await self.send_command(f"MoveRail {station_id} {mode}") + else: + await self.send_command(f"MoveRail {mode}") + + # -- identity & status reads -------------------------------------------------------------- + + async def request_manufacturer(self) -> str: + return (await self.request_parameter(DataID.MANUFACTURER)).strip() + + async def request_controller_model(self) -> str: + return (await self.request_parameter(DataID.CONTROLLER_MODEL)).strip() + + async def request_hardware_version(self) -> str: + return (await self.request_parameter(DataID.HARDWARE_VERSION)).strip() + + async def request_gpl_version(self) -> str: + """Controller firmware/runtime version (distinct from ``request_version``, the TCS app).""" + return (await self.request_parameter(DataID.GPL_VERSION)).strip() + + async def request_controller_serial(self) -> str: + return (await self.request_parameter(DataID.CONTROLLER_SERIAL)).strip() + + async def request_robot_name(self) -> str: + return (await self.request_parameter(DataID.ROBOT_NAME)).strip() + + async def request_robot_type(self) -> int: + """Built-in kinematic model id (PF400 = 12).""" + return int(_parse_scalar(await self.request_parameter(DataID.ROBOT_TYPE))) + + async def request_axis_count(self) -> int: + """Number of servoed axes.""" + return int(_parse_scalar(await self.request_parameter(DataID.NUM_AXES))) + + async def request_extra_axis_count(self) -> int: + """Number of non-servoed (extra) axes.""" + return int(_parse_scalar(await self.request_parameter(DataID.EXTRA_AXES))) + + async def request_axis_mask(self) -> int: + """Capability/option bit field (rail, dual gripper, ...).""" + return int(_parse_scalar(await self.request_parameter(DataID.AXIS_MASK))) + + async def request_version(self) -> str: + """Get the current version of TCS and any installed plug-ins. + + Returns: + str: The current version information. + """ + return await self.send_command("version") + + # -- kinematics & reference limits -------------------------------------------------------- + + async def request_joint_limits(self, hard: bool = False) -> Dict[Axis, tuple[float, float]]: + """Per-axis travel limits as {Axis: (min, max)}. + + Returns the soft limits by default; pass ``hard=True`` for the hard limits. + """ + min_id = DataID.HARD_LIMIT_MIN if hard else DataID.SOFT_LIMIT_MIN + max_id = DataID.HARD_LIMIT_MAX if hard else DataID.SOFT_LIMIT_MAX + return _zip_axis_ranges( + _parse_per_axis(await self.request_parameter(min_id)), + _parse_per_axis(await self.request_parameter(max_id)), + ) + + async def request_reference_speed(self) -> Dict[Axis, float]: + """Per-axis rated speed at 100%; J1/J5 in mm/s, J2-J4 in deg/s.""" + return _parse_per_axis(await self.request_parameter(DataID.REFERENCE_SPEED)) + + async def request_reference_acceleration(self) -> Dict[Axis, float]: + """Per-axis rated acceleration at 100%.""" + return _parse_per_axis(await self.request_parameter(DataID.REFERENCE_ACCEL)) + + async def request_link_lengths(self) -> tuple[float, float]: + """(l1, l2) SCARA link lengths in mm: shoulder->elbow, elbow->wrist.""" + per_axis = _parse_per_axis(await self.request_parameter(DataID.LINK_LENGTHS)) + return per_axis[Axis.SHOULDER], per_axis[Axis.ELBOW] + + async def request_tool_length(self) -> float: + """Wrist->TCP distance in mm (z of the tool-offset transform).""" + values = [float(v) for v in (await self.request_parameter(DataID.TOOL_OFFSET)).split(",")] + return values[2] + + async def request_kinematic_parameters(self) -> "kinematics.PF400Params": + """Build PF400Params from the controller's stored geometry. + + Link lengths and tool length come from the device; gripper_z_offset is not on + the controller, so it is carried over from the constructor params. + """ + l1, l2 = await self.request_link_lengths() + return dataclasses.replace( + self._kinematics_params, + l1=l1, + l2=l2, + gripper_length=await self.request_tool_length(), + ) + + async def request_reference_cartesian_speed(self) -> float: + """Rated Cartesian (translational) speed at 100%, in mm/s.""" + return _parse_scalar(await self.request_parameter(DataID.REFERENCE_CARTESIAN_SPEED)) + + async def request_reference_cartesian_acceleration(self) -> float: + """Rated Cartesian (translational) acceleration at 100%, in mm/s^2.""" + return _parse_scalar(await self.request_parameter(DataID.REFERENCE_CARTESIAN_ACCEL)) + + async def request_max_speed_percent(self) -> float: + """Global cap on the speed percentage (one value, applies to all joints).""" + return _parse_scalar(await self.request_parameter(DataID.MAX_SPEED_PERCENT)) + + async def request_max_acceleration_percent(self) -> float: + """Global cap on the acceleration percentage (one value, applies to all joints).""" + return _parse_scalar(await self.request_parameter(DataID.MAX_ACCEL_PERCENT)) + + async def request_max_deceleration_percent(self) -> float: + """Global cap on the deceleration percentage (one value, applies to all joints).""" + return _parse_scalar(await self.request_parameter(DataID.MAX_DECEL_PERCENT)) + + # -- tool & base frame -------------------------------------------------------------------- + + async def request_base(self) -> tuple[float, float, float, float]: + """Get the robot base offset. + + Returns: + A tuple containing (x_offset, y_offset, z_offset, z_rotation) + """ + data = await self.send_command("base") + parts = data.split() + if len(parts) != 4: + raise PreciseFlexError(-1, "Unexpected response format from base command.") + return (float(parts[0]), float(parts[1]), float(parts[2]), float(parts[3])) + + async def set_base( + self, x_offset: float, y_offset: float, z_offset: float, z_rotation: float + ) -> None: + """Set the robot base offset. + + Args: + x_offset: Base X offset + y_offset: Base Y offset + z_offset: Base Z offset + z_rotation: Base Z rotation + + Note: + The robot must be attached to set the base. + Setting the base pauses any robot motion in progress. + """ + await self.send_command(f"base {x_offset} {y_offset} {z_offset} {z_rotation}") + + async def request_tool_transformation_values( + self, + ) -> tuple[float, float, float, float, float, float]: + """Get the current tool transformation values. + + Returns: + A tuple containing (X, Y, Z, yaw, pitch, roll) for the tool transformation. + """ + data = await self.send_command("tool") + if data.startswith("tool: "): + data = data[6:] + parts = data.split() + if len(parts) != 6: + raise PreciseFlexError(-1, "Unexpected response format from tool command.") + x, y, z, yaw, pitch, roll = self._parse_xyz_response(parts) + return (x, y, z, yaw, pitch, roll) + + async def _set_tool_transformation_values( + self, x: float, y: float, z: float, yaw: float, pitch: float, roll: float + ) -> None: + """Set the robot tool transformation (private). + + Private because the client kinematics read the tool once at setup into the frozen configuration; + changing it live desyncs `request_gripper_pose` from the controller's `wherec` until the + configuration is rebuilt. The robot must be attached to set the tool, and setting it pauses any + robot motion in progress. + + Args: + x: Tool X coordinate. + y: Tool Y coordinate. + z: Tool Z coordinate. + yaw: Tool yaw rotation. + pitch: Tool pitch rotation. + roll: Tool roll rotation. + """ + await self.send_command(f"tool {x} {y} {z} {yaw} {pitch} {roll}") + + # -- robot selection ---------------------------------------------------------------------- + + async def reset(self, robot_number: int) -> None: + """Reset the threads associated with the specified robot. + + Stops and restarts the threads for the specified robot. Any TCP/IP connections + made by these threads are broken. This command can only be sent to the status thread. + + Args: + robot_number: The number of the robot thread to reset, from 1 to N_ROB. Must not be zero. + + Raises: + ValueError: If robot_number is zero or negative. + """ + if robot_number <= 0: + raise ValueError("Robot number must be greater than zero") + await self.send_command(f"reset {robot_number}") + + async def request_selected_robot(self) -> int: + """Get the number of the currently selected robot. + + Returns: + The number of the currently selected robot. + """ + response = await self.send_command("selectRobot") + return int(response) + + async def select_robot(self, robot_number: int) -> None: + """Change the robot associated with this communications link. + + Does not affect the operation or attachment state of the robot. The status thread + may select any robot or 0. Except for the status thread, a robot may only be + selected by one thread at a time. + + Args: + robot_number: The new robot to be connected to this thread (1 to N_ROB) or 0 for none. + """ + await self.send_command(f"selectRobot {robot_number}") + + # -- configuration discovery & adoption --------------------------------------------------- + + @property + def configuration(self) -> "PreciseFlexConfiguration": + """The device configuration resolved at setup. Raises before setup().""" + if self._configuration is None: + raise RuntimeError("Configuration is not available until setup() has run.") + return self._configuration + + async def _request_configuration(self) -> "PreciseFlexConfiguration": + """Read the controller's identity, axes, limits, kinematics, and envelope. + + Read-only (no motion, no homing required), so it is safe to call at setup. + Link lengths and tool length are read from the controller; per-arm flags are + derived from the joint set, the axis mask, and the model name. + """ + soft_limits = await self.request_joint_limits() + axis_mask = await self.request_axis_mask() + robot_name = await self.request_robot_name() + name_tokens = robot_name.split() + suffix = name_tokens[-1].upper().lstrip("0123456789") if name_tokens else "" + # The version command reports the TCS app version then its loaded modules. + tcs_version, *modules = (seg.strip() for seg in (await self.request_version()).split(",")) + + # Combine the per-axis 100% references with the global percent caps into the + # effective per-joint maxima, so consumers get usable limits, not raw factors. + reference_speed = await self.request_reference_speed() + reference_acceleration = await self.request_reference_acceleration() + speed_pct = await self.request_max_speed_percent() + acceleration_pct = await self.request_max_acceleration_percent() + deceleration_pct = await self.request_max_deceleration_percent() + + # Kinematics: read the link/tool geometry from the controller by default, so + # the driver is correct for whichever 400 variant is plugged in; fall back to + # the constructor params if the read fails or the override is set. + kinematics_source: Literal["device", "provided", "default"] + if self._read_kinematics_from_device: + try: + kinematic_params = await self.request_kinematic_parameters() + kinematics_source = "device" + except Exception as exc: + logger.warning( + "[PreciseFlex %s] could not read kinematics, using constructor params: %s", + self.io._host, + exc, + ) + kinematic_params = self._kinematics_params + kinematics_source = "default" + else: + kinematic_params = self._kinematics_params + kinematics_source = "provided" + reach_class = kinematics._classify_pf400_reach((kinematic_params.l1, kinematic_params.l2)) + if reach_class == "unknown": + logger.warning( + "[PreciseFlex %s] link lengths l1=%.1f l2=%.1f match neither the standard %s nor " + "extended %s PF400 arm; the arm's device-stored link lengths may have been changed", + self.io._host, + kinematic_params.l1, + kinematic_params.l2, + kinematics.ARM_LINKS_STANDARD, + kinematics.ARM_LINKS_EXTENDED, + ) + + return PreciseFlexConfiguration( + manufacturer=await self.request_manufacturer(), + controller_model=await self.request_controller_model(), + hardware_version=await self.request_hardware_version(), + gpl_version=await self.request_gpl_version(), + controller_serial=await self.request_controller_serial(), + robot_name=robot_name, + robot_type=await self.request_robot_type(), + tcs_version=tcs_version, + modules=tuple(modules), + num_axes=await self.request_axis_count(), + extra_axes=await self.request_extra_axis_count(), + axis_mask=axis_mask, + soft_limits=soft_limits, + hard_limits=await self.request_joint_limits(hard=True), + max_joint_speed={a: v * speed_pct / 100 for a, v in reference_speed.items()}, + max_joint_acceleration={ + a: v * acceleration_pct / 100 for a, v in reference_acceleration.items() + }, + max_joint_deceleration={ + a: v * deceleration_pct / 100 for a, v in reference_acceleration.items() + }, + max_cartesian_speed=(await self.request_reference_cartesian_speed()) * speed_pct / 100, + max_cartesian_acceleration=(await self.request_reference_cartesian_acceleration()) + * acceleration_pct + / 100, + power_state=await self.request_system_state(), + kinematics=kinematic_params, + kinematics_source=kinematics_source, + has_rail=Axis.RAIL in soft_limits, + is_dual_gripper=bool(axis_mask & 0x80), + is_vision_gripper=suffix[:1] == "V", + reach_class=reach_class, + ) + + async def _request_state( + self, + ) -> tuple[JointPose, PreciseFlexCartesianPose]: + """Single-query snapshot of joint state and the derived Cartesian pose.""" + joints = await self.request_joint_position() + pose = kinematics.fk(joints, self._kinematics_params) + # PF400 gripper stays level: pitch=90, roll=-180. + pose = dataclasses.replace(pose, rotation=Rotation(x=-180, y=90, z=pose.rotation.yaw)) + return joints, pose + + def _adopt_configuration(self, config: "PreciseFlexConfiguration") -> None: + """Adopt the discovered configuration as the source of truth for later commands. + + The gripper width limits come from the gripper-axis soft limits, IK/FK use the + device link lengths, and the rail / dual-gripper command paths follow the axes + the controller actually reports. + """ + gmin, gmax = config.gripper_width_range + self._gripper_soft_min, self._gripper_soft_max = gmin, gmax + self.min_gripper_width, self.max_gripper_width = gmin, gmax + self._kinematics_params = config.kinematics + self._has_rail = config.has_rail + self._is_dual_gripper = config.is_dual_gripper + + def _assess_configuration(self, config: "PreciseFlexConfiguration") -> None: + """Warn about an unsupported model, a missing TCS module, or an untested combo. + + The kinematics is the PreciseFlex 400 geometry, so a different model would get + wrong joint targets; a missing module (e.g. PARobot) is the usual ``-2805`` + cause; an unlisted full configuration is allowed but flagged for reporting. + """ + host = self.io._host + if not is_supported_model(config.robot_type): + logger.warning( + "[PreciseFlex %s] robot_type %s is not a model this driver's kinematics " + "supports (%s); move_to/work_envelope may be wrong.", + host, + config.robot_type, + ", ".join(SUPPORTED_ROBOT_TYPES.values()), + ) + for module, provides, project in missing_required_modules(config.modules): + logger.warning( + "[PreciseFlex %s] the '%s' module (%s) is not loaded; install the '%s' TCS " + "project (obtain it from Brooks Automation) and restart it.", + host, + module, + provides, + project, + ) + if not is_confirmed(config.robot_type, config.gpl_version, config.tcs_version, config.modules): + logger.info( + "[PreciseFlex %s] this software stack has not been tested with this driver. " + "If the arm works correctly, please add the following entry to " + "CONFIRMED_FIRMWARE_VERSIONS in pylabrobot/brooks/confirmed_firmware_versions.py " + "and open a pull request so other users benefit:\n%s", + host, + suggest_entry(config.robot_type, config.gpl_version, config.tcs_version, config.modules), + ) + + def _log_configuration_summary(self, config: "PreciseFlexConfiguration") -> None: + """Log a single structured summary of the discovered device: name, connection, + firmware, this unit's configuration, and the resulting capabilities.""" + io = self.io + axes = f"{config.num_axes} axes" + (" + rail" if config.has_rail else "") + grippers = [ + label + for present, label in ( + (config.is_dual_gripper, "dual gripper"), + (config.is_vision_gripper, "vision gripper"), + ) + if present + ] + gripper_note = (", " + ", ".join(grippers)) if grippers else "" + logger.info( + "[%s] Connected on %s:%s\n" + " Firmware: GPL %s, TCS %s\n" + " Configuration: %s, robot_type %s, %s%s\n" + " Capabilities: %s reach (l1=%.1f, l2=%.1f mm), modules: %s", + config.robot_name or config.controller_model or "PreciseFlex", + io._host, + io._port, + config.gpl_version, + config.tcs_version, + config.controller_model, + config.robot_type, + axes, + gripper_note, + config.reach_class, + config.kinematics.l1, + config.kinematics.l2, + ", ".join(config.modules), + ) + + # -- homing & range recovery -------------------------------------------------------------- + + # Axes auto-recovered when out of range, in a deliberately safe order: the + # gripper jaw first (no arm motion), then the Z column (vertical clearance), then + # the rotary links shoulder -> elbow (smallest swept volume last to first). + # The wrist is intentionally absent: rotating it back to +/-180 can self-collide, so + # it needs the other links first driven to minimal clearance from the origin - a + # maneuver not implemented here. The rail (gross lateral travel) is likewise left out. + # TODO: clearance-aware wrist recovery (and rail). An out-of-range wrist or rail is + # left for the setup post-condition to raise on. + _RECOVERY_ORDER = (Axis.GRIPPER, Axis.BASE, Axis.SHOULDER, Axis.ELBOW) + + async def recover_axes_within_limits( + self, speed_pct: float = 20.0, max_distance: Optional[float] = 5.0 + ) -> Dict[Axis, float]: + """Bring out-of-range axes back inside their soft limits, one axis at a time. + + While an axis is outside its soft limit the controller rejects every commanded + coordinated move (-1012), and homing does not help on the absolute rotary axes. + A single-axis move is the documented exception: it may move an axis toward the + in-range region. Each recoverable offender is driven to just inside its nearest + soft limit, slowly, waiting for each to finish, in :attr:`_RECOVERY_ORDER`. + + Args: + speed_pct: Profile speed for the recovery moves (default 20%, deliberately slow). + max_distance: only move an axis that is out of range by at most this much (deg + for the rotary axes, mm for base/gripper). An axis further out is left in place: + a large unattended single-axis sweep risks a collision, so it is left for the + caller to recover manually (e.g. by freedriving). Pass None to move regardless. + + Returns: + The axes moved, as ``axis -> recovered target``. Empty when nothing recoverable + is out of range or the configuration was not discovered. The wrist and rail are + never auto-recovered (see :attr:`_RECOVERY_ORDER`). + """ + outside = self._axes_outside_soft_limits(await self.request_joint_position()) + if not outside: + return {} + prior_speed = await self._request_speed() + await self._set_speed(speed_pct) + recovered: Dict[Axis, float] = {} + try: + for axis in self._RECOVERY_ORDER: + if axis not in outside: + continue + value, (lo, hi) = outside[axis] + above = value > hi # which limit is violated; both moves below hinge on this + overshoot = (value - hi) if above else (lo - value) + if max_distance is not None and overshoot > max_distance: + continue # too far out to move unattended; left for the post-condition to raise + # Land just inside the violated limit, toward the in-range region. Clamp the + # 1-unit margin to half the range so the target stays within [lo, hi] even if + # the range is narrower than the margin (degenerate, but keeps direction sound). + margin = min(1.0, (hi - lo) / 2.0) + target = (hi - margin) if above else (lo + margin) + logger.warning( + "[PreciseFlex %s] recovering %s from %s into soft limit [%s, %s] -> %s", + self.io._host, + axis.name, + value, + lo, + hi, + target, + ) + await self._move_one_axis(axis, target) + await self._wait_for_eom() + recovered[axis] = target + finally: + await self._set_speed(prior_speed) # don't leave the profile at the slow recovery speed + return recovered + + async def _is_robot_homed(self) -> bool: + """Whether all axes are homed (DataID 2800). + + Homing is lost on every power cycle (incremental encoders), and until it is redone + the controller blocks commanded motion (-1021) and reports unreliable positions. + """ + return _parse_scalar(await self.request_parameter(DataID.ROBOT_HOMED)) == 1.0 + + async def _handle_out_of_range_axes(self) -> None: + """Warn about every out-of-range axis, then correct what is recoverable, or raise. + + An axis out of range (its current position outside its soft limit) makes the arm unusable - the + controller rejects every commanded move with -1012. Setup logs the full set first (either way), + then, with ``recover_out_of_range`` on (the default), drives each recoverable offender back into + range. If recovery is off or leaves any axis out, setup raises with explicit recovery steps + rather than leaving a dead arm. + + No-op until the robot is homed: an unhomed incremental axis reads a meaningless ~0 + (so the check would false-positive), and the controller blocks the recovery move with + -1021 anyway. Homing is the prerequisite, so the check waits for it. + """ + if not await self._is_robot_homed(): + logger.warning( + "[PreciseFlex %s] robot not homed; skipping the out-of-range check until it is " + "(home() first - unhomed positions are unreliable and commanded moves are blocked).", + self.io._host, + ) + return + + outside = self._axes_outside_soft_limits(await self.request_joint_position()) + if not outside: + return + logger.warning( + "[PreciseFlex %s] axes out of soft limit at setup: %s", + self.io._host, + self._fmt_axes(outside), + ) + if self._recover_out_of_range: + await self.recover_axes_within_limits() + outside = self._axes_outside_soft_limits(await self.request_joint_position()) + if outside: + raise OutOfRangeOfMotionError( + f"axis outside its soft limit after setup: {self._fmt_axes(outside)}. The controller rejects all " + f"commanded moves in this state. Recover with recover_axes_within_limits(), or freedrive " + f"the axis back into range manually (required for the wrist, or when an axis is far past " + f"its limit).", + axes=outside, + ) + + def _axes_outside_soft_limits(self, joints: JointPose) -> Dict[Axis, tuple]: + """Axes whose value lies outside their soft limit, as ``axis -> (value, (lo, hi))``. + + Iterates the soft-limit set (keyed by :class:`Axis`) and looks each axis up in + ``joints`` so the comparison stays Axis-typed. Empty until the configuration has + been discovered. + """ + if self._configuration is None: + return {} + outside: Dict[Axis, tuple] = {} + for axis, (lo, hi) in self._configuration.soft_limits.items(): + value = joints.get(axis) + if value is not None and not (lo <= value <= hi): + outside[axis] = (value, (lo, hi)) + return outside + + @staticmethod + def _fmt_axes(axes: Dict[Axis, tuple]) -> str: + """Format ``{axis: (value, (lo, hi))}`` for logs/errors, e.g. + ``BASE at 0.959 (soft limit (1.5, 401.5))``.""" + return "; ".join( + f"{axis.name} at {value} (soft limit {limit})" for axis, (value, limit) in axes.items() + ) + + def _assert_within_soft_limits(self, current: JointPose, target: JointPose) -> None: + """Guard a commanded move. The controller rejects every move with -1012 while an axis is out of + range - whether that is the *current* pose or the commanded *target*. They are distinct failures + with distinct types: + + - an axis whose *current* position is out of range is a recoverable arm *state* (e.g. it lost + power and drifted past its limit) -> ``OutOfRangeOfMotionError``, which the caller can recover + and retry. Homing will not fix it (the rotary axes are absolute); call + ``recover_axes_within_limits()`` to drive it back into range. + - an axis whose *target* is out of range is a bad request (freedrive can hand-move an axis past a + soft limit, so a taught pose can land outside the commandable envelope) -> ``ValueError``; + re-teach the pose. + + No-op until the configuration is discovered. + """ + out_of_range = self._axes_outside_soft_limits(current) + if out_of_range: + raise OutOfRangeOfMotionError( + f"axis out of range: {self._fmt_axes(out_of_range)}. The controller rejects every commanded " + f"move while an axis is out of range. Homing will not recover it (the rotary axes are " + f"absolute); call recover_axes_within_limits() to drive it back into range.", + axes=out_of_range, + ) + for axis, (value, limit) in self._axes_outside_soft_limits(target).items(): + raise ValueError( + f"{axis.name} target {value} is outside its soft limit {limit}; the controller " + f"would reject the move (-1012). Re-teach this pose within the envelope." + ) + + async def _guarded_move_j(self, build_target: Callable[[JointPose], JointPose]) -> None: + """The single guarded path to the raw ``_move_j`` primitive: read the live pose, check it and + the target against the soft limits, send the move, and on out-of-range recover once and retry. + Both ``move_to_joint_position`` (a partial spec merged over the live pose) and + ``move_to_location`` (a full pose from IK) funnel through here, so no commanded move reaches + ``_move_j`` unchecked. + + ``build_target`` maps the freshly-read pose to the full target joints - the only part that + differs between the two callers. It re-runs each attempt, so a recovery move that shifts an + unspecified axis is reflected in the next merge. + + When an axis is out of range the controller blocks the move (-1012). With ``recover_out_of_range`` + set, this drives the offending axes back into range once (``recover_axes_within_limits``) and + retries; otherwise the ``OutOfRangeOfMotionError`` propagates. Recovery uses ``_move_one_axis``, a + different primitive, so it cannot recurse here. + """ + + async def attempt() -> None: + current = await self.request_joint_position() + target = build_target(current) + self._assert_within_soft_limits(current, target) + await self._move_j(profile_index=self.profile_index, joint_coords=target) + + try: + await attempt() + except OutOfRangeOfMotionError as exc: + if not self._recover_out_of_range: + raise + host = self.io._host + logger.warning( + "[PreciseFlex %s] commanded move blocked - %s; auto-recovery on -> recovering and retrying", + host, + self._fmt_axes(exc.axes), + ) + await self.recover_axes_within_limits() + try: + await attempt() # re-reads the live pose - recovery just moved an axis + except OutOfRangeOfMotionError as exc2: + logger.error( + "[PreciseFlex %s] auto-recovery did not clear %s - freedrive/manual recovery needed", + host, + self._fmt_axes(exc2.axes), + ) + raise + logger.info("[PreciseFlex %s] out-of-range axes recovered; move retried successfully", host) + + # -- joint-space motion ------------------------------------------------------------------- + + async def request_joint_position(self) -> JointPose: + """Get the current joint position of the arm.""" + await self._wait_for_eom() + num_tries = 2 + for _ in range(num_tries): + data = await self.send_command("wherej") + parts = data.split() + if len(parts) > 0: + break + else: + raise PreciseFlexError(-1, "Unexpected response format from wherej command.") + return self._parse_angles_response(parts) + + async def move_to_joint_position( + self, + position: JointPose, + speed_pct: Optional[float] = None, + ) -> None: + """Move the arm to the specified joint position. A partial spec is merged over the live pose; + the move is guarded against out-of-range axes (see ``_guarded_move_j``). + + Args: + position: Target joint pose. Omitted axes keep their live values. + speed_pct: Movement speed override as a percentage (0-100). If None, uses the current + speed setting. + """ + if speed_pct is not None: + await self._set_speed(speed_pct) + await self._guarded_move_j(lambda current: {**current, **position}) + + async def request_gripper_pose(self) -> PreciseFlexCartesianPose: + """Get the current pose using our kinematics model (no firmware `wherec`).""" + _, pose = await self._request_state() + return pose + + # -- cartesian motion --------------------------------------------------------------------- + + async def move_to_location( + self, + location: Coordinate, + direction: float, + speed_pct: Optional[float] = None, + orientation: Optional[ElbowOrientation] = None, + wrist: Optional[Wrist] = None, + rail_position: Optional[float] = None, + ) -> None: + """Move the arm to the specified Cartesian location. The IK target is guarded against + out-of-range axes (see ``_guarded_move_j``). + + Args: + location: Target Cartesian location. + direction: Approach direction, applied as the pose's z rotation in degrees. + speed_pct: Movement speed override as a percentage (0-100). If None, uses the current + speed setting. + orientation: Elbow orientation (``"lefty"`` or ``"righty"``). If None, the robot + picks the closest configuration. + wrist: Wrist configuration. If None, the robot picks the closest configuration. + rail_position: Linear rail position in mm. Required when the arm has a rail. + """ + if speed_pct is not None: + await self._set_speed(speed_pct) + + if rail_position is not None: + await self.move_rail(rail_position) + elif self._has_rail: + raise ValueError( + "Rail position must be specified for move_to_location when using a rail-equipped arm." + ) + + coords = PreciseFlexCartesianPose( + location=location, + rotation=Rotation(x=-180, y=90, z=direction), + orientation=orientation, + wrist=wrist, + ) + joints = await self._cart_to_joints(coords) + await self._guarded_move_j(lambda _current: joints) + + async def _plan_cartesian_pose_route( + self, poses: Sequence[PreciseFlexCartesianPose] + ) -> List[JointPose]: + """Plan a Cartesian pose route into joint targets, snapshotting state once. + + Unlike :meth:`_cart_to_joints`, this does not query the controller for every waypoint: it + reads the current state once and resolves each waypoint's IK from the previous waypoint's + result. Omitted pose fields inherit from the previous pose so IK branch selection remains + continuous across the route. + """ + prev_joints, prev_pose = await self._request_state() + targets: List[JointPose] = [] + for pose in poses: + cart = dataclasses.replace( + pose, + orientation=prev_pose.orientation if pose.orientation is None else pose.orientation, + wrist=prev_pose.wrist if pose.wrist is None else pose.wrist, + # PF400 IK expects a shoulder/reference rail position even on rail-less arms. + # Mirror _cart_to_joints(): omitted pose fields inherit from the previous pose. + rail_position=prev_pose.rail_position if pose.rail_position is None else pose.rail_position, + ) + ik_joints = _snap_to_current( + kinematics.ik(cart, p=self._kinematics_params), + prev_joints, + cart.wrist, + ) + # IK only solves the arm axes; gripper and rail keep the previous values. + target = dict(prev_joints) + for axis in (Axis.BASE, Axis.SHOULDER, Axis.ELBOW, Axis.WRIST): + target[axis] = ik_joints[axis] + if self._has_rail and cart.rail_position is not None: + target[Axis.RAIL] = cart.rail_position + + self._assert_within_soft_limits(prev_joints, target) + targets.append(target) + prev_joints = target + prev_pose = cart + return targets + + async def move_through_cartesian_poses( + self, + poses: Sequence[PreciseFlexCartesianPose], + speed_pct: Optional[float] = None, + blend: bool = True, + ) -> None: + """Move through a sequence of Cartesian poses using one planned IK route. + + The standard Cartesian move path snapshots the current state for each waypoint, + which waits for end-of-motion between moves. For taught air-transit routes, this + method snapshots state once, plans each subsequent IK target from the previous + planned target, queues the joint moves, and waits only after the final waypoint. + + This is a PreciseFlex-specific primitive: intermediate waypoints may be blended + by the controller and should not be used for operations that require an exact + stop, gripper action, or physical contact at every pose. + + Args: + poses: Cartesian waypoints to move through, in order. + speed_pct: Movement speed override as a percentage (0-100). If None, uses the + current speed setting. + blend: When True, temporarily set the active motion profile's ``InRange`` value to + ``-1`` so the controller may blend through intermediate waypoints instead of stopping + at each one. The original profile is restored after the final waypoint is reached. + """ + if not poses: + return + if speed_pct is not None: + await self._set_speed(speed_pct) + + targets = await self._plan_cartesian_pose_route(poses) + + profile_index = self.profile_index + original_profile = None + should_restore_profile = False + if blend: + original_profile = await self.request_motion_profile_values(profile_index) + should_restore_profile = original_profile.in_range != BLEND_IN_RANGE + if should_restore_profile: + await self.set_motion_profile_values(*original_profile._replace(in_range=BLEND_IN_RANGE)) + + try: + for target in targets: + await self._move_j(profile_index=profile_index, joint_coords=target) + finally: + # Let queued motion settle before returning or restoring the profile - restoring InRange + # mid-move would change the in-flight blend. + try: + await self._wait_for_eom() + finally: + if should_restore_profile and original_profile is not None: + await self.set_motion_profile_values(*original_profile) + + async def dest_c(self, arg1: int = 0) -> tuple[float, float, float, float, float, float, int]: + """Get the destination or current Cartesian location of the robot. + + Args: + arg1: Selects return value. Defaults to 0. + 0 = Return current Cartesian location if robot is not moving + 1 = Return target Cartesian location of the previous or current move + + Returns: + A tuple containing (X, Y, Z, yaw, pitch, roll, config) + If arg1 = 1 or robot is moving, returns the target location. + If arg1 = 0 and robot is not moving, returns the current location. + """ + if arg1 == 0: + data = await self.send_command("destC") + else: + data = await self.send_command(f"destC {arg1}") + parts = data.split() + if len(parts) != 7: + raise PreciseFlexError(-1, "Unexpected response format from destC command.") + x, y, z, yaw, pitch, roll = self._parse_xyz_response(parts[:6]) + config = int(parts[6]) + return (x, y, z, yaw, pitch, roll, config) + + async def dest_j(self, arg1: int = 0) -> JointPose: + """Get the destination or current joint location of the robot. + + Args: + arg1: Selects return value. Defaults to 0. + 0 = Return current joint location if robot is not moving + 1 = Return target joint location of the previous or current move + + Returns: + A dict mapping Axis to float values. + If arg1 = 1 or robot is moving, returns the target joint positions. + If arg1 = 0 and robot is not moving, returns the current joint positions. + """ + if arg1 == 0: + data = await self.send_command("destJ") + else: + data = await self.send_command(f"destJ {arg1}") + parts = data.split() + if not parts: + raise PreciseFlexError(-1, "Unexpected response format from destJ command.") + return self._parse_angles_response(parts) + + async def here_j(self, location_index: int) -> None: + """Record the current position of the selected robot into the specified Location as angles. + + The Location is automatically set to type "angles". + + Args: + location_index: The station index, from 1 to N_LOC. + """ + await self.send_command(f"hereJ {location_index}") + + async def here_c(self, location_index: int) -> None: + """Record the current position of the selected robot into the specified Location as Cartesian. + + The Location object is automatically set to type "Cartesian". + Can be used to change the pallet origin (index 1,1,1) value. + + Args: + location_index: The station index, from 1 to N_LOC. + """ + await self.send_command(f"hereC {location_index}") + + # -- gripper ------------------------------------------------------------------------------ + + # Physical jaw range for the PF400 servoed gripper. Overridden at setup from the + # gripper-axis soft limits (DataIDs 16078/16077, Axis.GRIPPER) when discoverable. + min_gripper_width: float = 60.0 + max_gripper_width: float = 145.0 + # Gripper-axis soft limits (GripOpenPos/GripClosePos units), read at setup; None until then. + _gripper_soft_min: Optional[float] = None + _gripper_soft_max: Optional[float] = None + + async def move_gripper( + self, + width: float, + force_sensing: bool = False, + ): + """Move the PreciseFlex gripper jaws. + + ``force_sensing=False`` drives to the open position (``gripper 1``); + ``force_sensing=True`` drives to the close position with force feedback + (``gripper 2``), which may stop short of ``width`` on contact. + + Not interruptible: the ``gripper`` firmware command blocks the controller's command interpreter + until the jaws finish (hardware-verified, like ``waitForEom``), so a user interrupt cannot halt it + mid-travel - it is intentionally not wrapped by the motion-wait interrupt guard. The move is short + and force-limited, so this is a documented limitation rather than a hazard. + """ + logger.info( + "[PreciseFlex %s] move_gripper: width_mm=%s force_sensing=%s", + self.io._host, + width, + force_sensing, + ) + units = self._mm_to_firmware_units(width) + if ( + self._gripper_soft_min is not None + and self._gripper_soft_max is not None + and not (self._gripper_soft_min <= units <= self._gripper_soft_max) + ): + raise ValueError( + f"gripper width {width} mm maps to firmware units {units:.1f}, outside the gripper " + f"axis range [{self._gripper_soft_min}, {self._gripper_soft_max}] - check " + f"closed_gripper_position (currently {self.closed_gripper_position})." + ) + if force_sensing: + await self._set_grip_close_pos(units) + await self.send_command("gripper 2") + else: + await self._set_grip_open_pos(units) + await self.send_command("gripper 1") + + async def is_gripper_closed(self) -> bool: + """(Single Gripper Only) Tests if the gripper is fully closed by checking the end-of-travel sensor. + + Returns: + For standard gripper: True if the gripper is within 2mm of fully closed, otherwise False. + """ + if self._is_dual_gripper: + raise ValueError("IsGripperClosed command is only valid for single gripper robots.") + response = await self.send_command("IsFullyClosed") + return int(response) == -1 + + async def are_grippers_closed(self) -> tuple[bool, bool]: + """(Dual Gripper Only) Tests if each gripper is fully closed by checking the end-of-travel sensors.""" + if not self._is_dual_gripper: + raise ValueError("AreGrippersClosed command is only valid for dual gripper robots.") + response = await self.send_command("IsFullyClosed") + ret_int = int(response) + gripper_1_closed = (ret_int & 1) != 0 + gripper_2_closed = (ret_int & 2) != 0 + return (gripper_1_closed, gripper_2_closed) + + # -- rail --------------------------------------------------------------------------------- + + async def move_rail(self, rail_position: float) -> None: + """Move the rail to the specified position. + + Args: + rail_position: Rail destination in mm. + + Raises: + RuntimeError: If the arm does not have a rail. + """ + if not self._has_rail: + raise RuntimeError("This arm does not have a rail.") + await self._set_rail_position(self._rail_position_index, rail_position) + await self._move_rail(station_id=self._rail_position_index) + + # -- pick & place ------------------------------------------------------------------------- + + async def pick_up_at_joint_position( + self, + position: JointPose, + resource_width: float, + finger_speed_pct: float = 50.0, + grasp_force: float = 10.0, + ) -> None: + """Pick up at the specified joint position. + + Args: + position: Joint pose to pick from. + resource_width: Width of the resource to grasp, in mm. + finger_speed_pct: Finger closing speed as a percentage (0-100). + grasp_force: Grasp force in Newtons. + """ + logger.info( + "[PreciseFlex %s] pick_up: joints=%s, resource_width_mm=%s", + self.io._host, + position, + resource_width, + ) + await self._set_grasp_data( + plate_width=resource_width, + finger_speed_pct=finger_speed_pct, + grasp_force=grasp_force, + ) + await self._pick_plate_j(position) + + async def drop_at_joint_position( + self, + position: JointPose, + resource_width: float, + ) -> None: + """Drop at the specified joint position. + + Args: + position: Joint pose to drop at. + resource_width: Width of the held resource, in mm. + """ + logger.info( + "[PreciseFlex %s] drop: joints=%s, resource_width_mm=%s", + self.io._host, + position, + resource_width, + ) + await self._place_plate_j(position) + + async def pick_up_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + finger_speed_pct: float = 50.0, + grasp_force: float = 10.0, + orientation: Optional[ElbowOrientation] = None, + wrist: Optional[Wrist] = None, + rail_position: Optional[float] = None, + ) -> None: + """Pick up at the specified Cartesian location. + + Args: + location: Cartesian location to pick from. + direction: Approach direction, applied as the pose's z rotation in degrees. + resource_width: Width of the resource to grasp, in mm. + finger_speed_pct: Finger closing speed as a percentage (0-100). + grasp_force: Grasp force in Newtons. + orientation: Elbow orientation (``"lefty"`` or ``"righty"``). If None, the robot + picks the closest configuration. + wrist: Wrist configuration. If None, the robot picks the closest configuration. + rail_position: Linear rail position in mm. Required when the arm has a rail. + """ + logger.info( + "[PreciseFlex %s] pick_up: x=%s, y=%s, z=%s, direction=%s, resource_width_mm=%s", + self.io._host, + location.x, + location.y, + location.z, + direction, + resource_width, + ) + if rail_position is not None: + await self.move_rail(rail_position) + elif self._has_rail: + raise ValueError( + "rail_position must be specified for pick_up_at_location when using a rail-equipped arm." + ) + coords = PreciseFlexCartesianPose( + location=location, + rotation=Rotation(z=direction), + orientation=orientation, + wrist=wrist, + ) + await self._set_grasp_data( + plate_width=resource_width, + finger_speed_pct=finger_speed_pct, + grasp_force=grasp_force, + ) + await self._pick_plate_c(cartesian_position=coords) + + async def drop_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + orientation: Optional[ElbowOrientation] = None, + wrist: Optional[Wrist] = None, + rail_position: Optional[float] = None, + ) -> None: + """Drop at the specified Cartesian location. + + Args: + location: Cartesian location to drop at. + direction: Approach direction, applied as the pose's z rotation in degrees. + resource_width: Width of the held resource, in mm. + orientation: Elbow orientation (``"lefty"`` or ``"righty"``). If None, the robot + picks the closest configuration. + wrist: Wrist configuration. If None, the robot picks the closest configuration. + rail_position: Linear rail position in mm. Required when the arm has a rail. + """ + logger.info( + "[PreciseFlex %s] drop: x=%s, y=%s, z=%s, direction=%s, resource_width_mm=%s", + self.io._host, + location.x, + location.y, + location.z, + direction, + resource_width, + ) + if rail_position is not None: + await self.move_rail(rail_position) + elif self._has_rail: + raise ValueError( + "rail_position must be specified for drop_at_location when using a rail-equipped arm." + ) + coords = PreciseFlexCartesianPose( + location=location, + rotation=Rotation(z=direction), + orientation=orientation, + wrist=wrist, + ) + await self._place_plate_c(cartesian_position=coords) + + async def _pick_plate_j(self, joint_position: JointPose): + """Pick a plate from the specified position using joint coordinates.""" + await self._set_joint_angles(self.location_index, joint_position) + await self._set_grip_detail() + horizontal_compliance_int = 1 if self.horizontal_compliance else 0 + ret_code = await self.send_command( + f"pickplate {self.location_index} {horizontal_compliance_int} {self.horizontal_compliance_torque}" + ) + if ret_code == "0": + raise PreciseFlexError(-1, "the force-controlled gripper detected no plate present.") + + async def _place_plate_j(self, joint_position: JointPose): + """Place a plate at the specified position using joint coordinates.""" + await self._set_joint_angles(self.location_index, joint_position) + await self._set_grip_detail() + horizontal_compliance_int = 1 if self.horizontal_compliance else 0 + await self.send_command( + f"placeplate {self.location_index} {horizontal_compliance_int} {self.horizontal_compliance_torque}" + ) + + async def _pick_plate_c(self, cartesian_position: PreciseFlexCartesianPose): + """Pick a plate at a Cartesian position via IK + joint-space pickplate.""" + joints = await self._cart_to_joints(cartesian_position) + await self._pick_plate_j(joints) + + async def _place_plate_c(self, cartesian_position: PreciseFlexCartesianPose): + """Place a plate at a Cartesian position via IK + joint-space placeplate.""" + joints = await self._cart_to_joints(cartesian_position) + await self._place_plate_j(joints) + + # -- parking ------------------------------------------------------------------------------ + + @property + def parking_position(self) -> Optional[JointPose]: + """The pose ``park()`` moves to. Assign one of the ``PARKING_POSITION_BACK/RIGHT/FRONT`` class + constants or any JointPose; the assignment is validated (keys must be ``Axis`` members, values must + be within the soft limits once the configuration is known). None until setup, where it defaults to + ``PARKING_POSITION_RIGHT``. A pose that omits ``Axis.BASE`` has its Z filled at park time.""" + return self._parking_position + + @parking_position.setter + def parking_position(self, position: Optional[JointPose]) -> None: + if position is not None: + self._validate_parking_position(position) + self._parking_position: Optional[JointPose] = dict(position) if position is not None else None + + async def park(self) -> None: + """Move to ``self.parking_position``; defaults at setup, reassignable at runtime. + + ``parking_position`` is filled at setup with ``PARKING_POSITION_RIGHT`` (a planar fold facing + right, Z column at 3/4 of its discovered travel); assign one of the ``PARKING_POSITION_*`` class + constants or any JointPose to park elsewhere. Falls back to the firmware ``movetosafe`` while it is + unset. No collision checks against 3rd-party obstacles. + """ + if self.parking_position is not None: + await self.move_to_joint_position( + position=self._parking_pose_with_default_z(self.parking_position) + ) + else: + await self.send_command("movetosafe") + + def _validate_parking_position(self, position: JointPose) -> None: + """Reject anything that is not a JointPose of in-range axes (limits checked once known).""" + if not isinstance(position, dict) or not position: + raise ValueError(f"parking_position must be a non-empty JointPose, got {position!r}") + for axis, value in position.items(): + if not isinstance(axis, Axis): + raise ValueError(f"parking_position keys must be Axis members, got {axis!r}") + if not isinstance(value, (int, float)): + raise ValueError(f"parking_position[{axis.name}] must be a number, got {value!r}") + if self._configuration is not None: + lo, hi = self._configuration.soft_limits[axis] + if not lo <= value <= hi: + raise ValueError( + f"parking_position[{axis.name}]={value} is outside the soft limits [{lo}, {hi}]" + ) + + def _parking_pose_with_default_z(self, position: JointPose) -> JointPose: + """Fill the Z column (``Axis.BASE``) at 3/4 of the discovered travel when the pose omits it.""" + if Axis.BASE in position or self._configuration is None: + return position + _, z_max = self._configuration.z_range + return {Axis.BASE: 0.75 * z_max, **position} diff --git a/pylabrobot/brooks/precise_flex/tcs_modules.py b/pylabrobot/brooks/precise_flex/tcs_modules.py new file mode 100644 index 00000000000..6d064c4ca8e --- /dev/null +++ b/pylabrobot/brooks/precise_flex/tcs_modules.py @@ -0,0 +1,31 @@ +"""TCS modules the PreciseFlex driver requires. + +Derived from the TCS source: every command this driver issues is defined in the base +``Cmd.gpl`` or in ``PARobot.gpl`` - none in ``Load_save.gpl``, ``StereoVision.gpl`` +(IntelliGuide), or the Auto-Center plug-in, so those are not required by this driver. +The base command server is implicit (you cannot connect or run ``version`` without it). + +``PARobot.gpl`` carries two modules and the driver uses commands from both: ``PARobot`` +(gripper open/close, plate handling, freedrive, park, rail, config) and ``SSGrip`` +(``IsFullyClosed``). Both ship in that one file, so installing ``Tcp_cmd_server_pa`` +provides both - but the driver depends on each, so both are checked. + +A missing required module is the usual cause of ``-2805 *Unknown command*``; checking +presence at setup turns that into a clear "install it from Brooks" message up front. +""" + +# required module (name substring) -> (what it provides, project that ships it) +REQUIRED_MODULES = { + "PARobot": ("gripper open/close, plate, freedrive, park, rail", "Tcp_cmd_server_pa"), + "SSGrip": ("gripper closed-state sensor (IsFullyClosed)", "Tcp_cmd_server_pa"), +} + + +def missing_required_modules(modules: tuple) -> list: + """Required modules absent from the loaded set, as (module, provides, project).""" + loaded = " ".join(modules) + return [ + (module, provides, project) + for module, (provides, project) in REQUIRED_MODULES.items() + if module not in loaded + ] diff --git a/pylabrobot/plate_reading/tecan/spark20m/__init__.py b/pylabrobot/brooks/precise_flex/tests/__init__.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/__init__.py rename to pylabrobot/brooks/precise_flex/tests/__init__.py diff --git a/pylabrobot/brooks/precise_flex/tests/interrupt_tests.py b/pylabrobot/brooks/precise_flex/tests/interrupt_tests.py new file mode 100644 index 00000000000..ff8292c096d --- /dev/null +++ b/pylabrobot/brooks/precise_flex/tests/interrupt_tests.py @@ -0,0 +1,196 @@ +import asyncio +import unittest +from typing import cast +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.brooks.precise_flex.errors import ( + OperationInterrupted, + PreciseFlexError, + is_collision, +) +from pylabrobot.brooks.precise_flex.interrupt import halt_and_resync, halt_on_interrupt +from pylabrobot.brooks.precise_flex.precise_flex import PreciseFlex + + +def mocked(method: object) -> AsyncMock: + """A real method that a test replaced with an ``AsyncMock``. + + Assertions like ``call_args_list`` live on the mock, not on the declared + method type, so they need narrowing before mypy will accept them. + """ + return cast(AsyncMock, method) + + +def _make_arm() -> PreciseFlex: + """An arm whose socket is mocked: writes recorded, reads drain immediately (TimeoutError).""" + d = PreciseFlex( + host="localhost", + gripper_length=162.0, + gripper_z_offset=0.0, + closed_gripper_position=500.0, + ) + d.io = MagicMock() + d.io.write = AsyncMock() + d.io.readline = AsyncMock(side_effect=TimeoutError()) + return d + + +class TestWaitForEom(unittest.IsolatedAsyncioTestCase): + """The non-blocking motion-wait: polls wherej and returns once the arm stops moving.""" + + async def test_returns_when_motion_stops(self): + """Returns at the first sample where the position stopped changing between polls.""" + wherej = iter( + ["0 0 0 0 0", "5 5 5 5 5", "9.9 9.9 9.9 9.9 9.9", "10 10 10 10 10", "10 10 10 10 10"] + ) + d = _make_arm() + d.send_command = AsyncMock(side_effect=lambda cmd: next(wherej)) # type: ignore[method-assign] + await d._wait_for_eom(poll_interval=0) # no error == returned at the settled sample + + async def test_returns_immediately_when_already_stationary(self): + """An idle arm (e.g. halted short of its last target) returns at once, never hangs to reach it.""" + d = _make_arm() + d.send_command = AsyncMock(return_value="113 81 218 64 70") # type: ignore[method-assign] # stable every poll + await d._wait_for_eom(poll_interval=0) + + async def test_keyboard_interrupt_halts_and_raises_operation_interrupted(self): + """A user interrupt mid-wait sends halt on the connection and surfaces OperationInterrupted.""" + seq = iter(["0 0 0 0 0"]) + + def fake(cmd: str) -> str: + try: + return next(seq) + except StopIteration: + raise KeyboardInterrupt() + + d = _make_arm() + d.send_command = AsyncMock(side_effect=fake) # type: ignore[method-assign] + with self.assertRaises(OperationInterrupted): + await d._wait_for_eom(poll_interval=0) + self.assertTrue(any(b"halt" in c.args[0] for c in mocked(d.io.write).call_args_list)) + + async def test_cancelled_error_halts_and_reraises(self): + """Cancellation is re-raised (not converted) but still sends halt first.""" + seq = iter(["0 0 0 0 0"]) + + def fake(cmd: str) -> str: + try: + return next(seq) + except StopIteration: + raise asyncio.CancelledError() + + d = _make_arm() + d.send_command = AsyncMock(side_effect=fake) # type: ignore[method-assign] + with self.assertRaises(asyncio.CancelledError): + await d._wait_for_eom(poll_interval=0) + self.assertTrue(any(b"halt" in c.args[0] for c in mocked(d.io.write).call_args_list)) + + async def test_timeout_when_never_settles(self): + """An arm that never stops moving raises TimeoutError rather than spinning forever.""" + n = iter(range(1000)) + d = _make_arm() + d.send_command = AsyncMock(side_effect=lambda cmd: f"{next(n)} 0 0 0 0") # type: ignore[method-assign] # always changing + with self.assertRaises(TimeoutError): + await d._wait_for_eom(poll_interval=0, timeout=0) + + +class TestInterruptHelpers(unittest.IsolatedAsyncioTestCase): + """The reusable guard primitives in interrupt.py.""" + + async def test_halt_and_resync_flushes_sends_stop_then_drains(self): + """With a stop command it writes a leading-newline-flushed halt, then drains; never closes.""" + io = MagicMock() + io.write = AsyncMock() + io.readline = AsyncMock(side_effect=[b"0\r\n", TimeoutError()]) + await halt_and_resync(io, b"halt") + io.write.assert_awaited_once_with(b"\nhalt\n") + io.stop.assert_not_called() + + async def test_halt_and_resync_drain_only_when_no_stop(self): + """stop=None means resync-only: drain the socket, write nothing.""" + io = MagicMock() + io.write = AsyncMock() + io.readline = AsyncMock(side_effect=TimeoutError()) + await halt_and_resync(io) + io.write.assert_not_called() + + async def test_converts_keyboard_interrupt(self): + """KeyboardInterrupt -> stop runs, OperationInterrupted raised.""" + stop = AsyncMock() + with self.assertRaises(OperationInterrupted): + async with halt_on_interrupt(stop): + raise KeyboardInterrupt() + stop.assert_awaited_once() + + async def test_reraises_cancelled_error(self): + """CancelledError -> stop runs, cancellation re-raised (semantics preserved).""" + stop = AsyncMock() + with self.assertRaises(asyncio.CancelledError): + async with halt_on_interrupt(stop): + raise asyncio.CancelledError() + stop.assert_awaited_once() + + async def test_passes_through_other_exceptions_without_halting(self): + """A normal error (e.g. an error-reply PreciseFlexError, as an E-stop produces) propagates + unchanged and does NOT trigger a halt.""" + stop = AsyncMock() + with self.assertRaises(ValueError): + async with halt_on_interrupt(stop): + raise ValueError() + stop.assert_not_awaited() + + +class TestRequestSystemState(unittest.IsolatedAsyncioTestCase): + """request_system_state reads the sysState word; PowerState decodes it (15 = hard E-stop).""" + + async def test_returns_state_word_and_decodes_to_powerstate(self): + from pylabrobot.brooks.precise_flex.data_ids import PowerState + + d = _make_arm() + d.send_command = AsyncMock(return_value="15") # type: ignore[method-assign] + state = await d.request_system_state() + self.assertEqual(state, 15) + self.assertEqual(PowerState(state), PowerState.OFF_HARD_ESTOP) + + +class TestCollisionDetectionAndRecovery(unittest.IsolatedAsyncioTestCase): + """Crash interrupts: recognise envelope errors, and recover (re-power + attach + home).""" + + def test_is_collision_recognises_collision_codes_only(self): + """Envelope (-3100/-3122) and torque-saturation (-3101/-3105) errors are collisions; an E-stop, + a no-attach error, or a non-PreciseFlexError is not.""" + for code in (-3100, -3101, -3105, -3122): + self.assertTrue(is_collision(PreciseFlexError(code, "")), code) + self.assertFalse(is_collision(PreciseFlexError(-1028, ""))) # hard E-stop, not a collision + self.assertFalse( + is_collision(PreciseFlexError(-1009, "")) + ) # no robot attached, not a collision + self.assertFalse(is_collision(ValueError())) + + async def test_recover_repowers_attaches_and_homes(self): + """Recovery from a non-E-stop fault re-enables power, re-attaches, and re-homes.""" + d = _make_arm() + d.request_system_state = AsyncMock(return_value=7) # type: ignore[method-assign] # off, waiting for enable (not E-stop) + d.power_on_robot = AsyncMock() # type: ignore[method-assign] + d.attach = AsyncMock() # type: ignore[method-assign] + d.home = AsyncMock() # type: ignore[method-assign] + await d.recover_from_fault() + d.power_on_robot.assert_awaited_once() + d.attach.assert_awaited_once_with(1) + d.home.assert_awaited_once() + + async def test_recover_refuses_while_estop_engaged(self): + """A hard E-stop blocks recovery (release the button first); power is not touched.""" + d = _make_arm() + d.request_system_state = AsyncMock(return_value=15) # type: ignore[method-assign] # OFF_HARD_ESTOP + d.power_on_robot = AsyncMock() # type: ignore[method-assign] + d.home = AsyncMock() # type: ignore[method-assign] + with self.assertRaises(PreciseFlexError) as ctx: + await d.recover_from_fault() + self.assertEqual(ctx.exception.replycode, -1028) + d.power_on_robot.assert_not_awaited() + d.home.assert_not_awaited() + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/brooks/precise_flex/tests/kinematics_tests.py b/pylabrobot/brooks/precise_flex/tests/kinematics_tests.py new file mode 100644 index 00000000000..8d931b3d7db --- /dev/null +++ b/pylabrobot/brooks/precise_flex/tests/kinematics_tests.py @@ -0,0 +1,13 @@ +import unittest + +from pylabrobot.brooks.precise_flex import kinematics + + +class TestClassifyPF400Reach(unittest.TestCase): + """Link lengths are classified as standard, extended, or unknown reach.""" + + def test_classify_pf400_reach(self): + self.assertEqual(kinematics._classify_pf400_reach((225, 210)), "standard") + self.assertEqual(kinematics._classify_pf400_reach((302, 289)), "extended") + self.assertEqual(kinematics._classify_pf400_reach((303, 288)), "extended") # within tolerance + self.assertEqual(kinematics._classify_pf400_reach((500, 500)), "unknown") diff --git a/pylabrobot/brooks/precise_flex/tests/precise_flex_tests.py b/pylabrobot/brooks/precise_flex/tests/precise_flex_tests.py new file mode 100644 index 00000000000..72516530619 --- /dev/null +++ b/pylabrobot/brooks/precise_flex/tests/precise_flex_tests.py @@ -0,0 +1,463 @@ +import unittest +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch + +from pylabrobot.brooks.precise_flex import ( + Axis, + OutOfRangeOfMotionError, + PreciseFlex, + PreciseFlexCartesianPose, +) +from pylabrobot.resources import Coordinate, Rotation + + +def mocked(method: object) -> AsyncMock: + """A real method that a test replaced with an ``AsyncMock``. + + Assertions like ``call_args_list`` live on the mock, not on the declared + method type, so they need narrowing before mypy will accept them. + """ + return cast(AsyncMock, method) + + +def _make_arm(closed_gripper_position: float = 500.0) -> PreciseFlex: + """An arm whose transport is stubbed out, so tests assert on the commands it would send.""" + arm = PreciseFlex( + host="localhost", + gripper_length=162.0, + gripper_z_offset=0.0, + closed_gripper_position=closed_gripper_position, + ) + arm.send_command = AsyncMock(return_value="") # type: ignore[method-assign] + return arm + + +class TestPreciseFlex400Gripper(unittest.IsolatedAsyncioTestCase): + def setUp(self): + # closed_gripper_position=500 ⇒ min_gripper_width(60mm) maps to 500 units. + self.arm = _make_arm(closed_gripper_position=500.0) + + def _sent_commands(self) -> list[str]: + return [c.args[0] for c in mocked(self.arm.send_command).call_args_list] + + async def test_move_gripper_force_sensing_false_opens_with_position(self): + # 80 mm ⇒ 500 + (80 - 60) = 520 firmware units. + await self.arm.move_gripper(width=80.0, force_sensing=False) + self.assertEqual(self._sent_commands(), ["GripOpenPos 520.0", "gripper 1"]) + + async def test_move_gripper_force_sensing_true_closes_with_position(self): + # 60 mm (the closed reference) ⇒ exactly closed_gripper_position. + await self.arm.move_gripper(width=60.0, force_sensing=True) + self.assertEqual(self._sent_commands(), ["GripClosePos 500.0", "gripper 2"]) + + async def test_move_gripper_position_command_precedes_move(self): + await self.arm.move_gripper(width=120.0, force_sensing=False) + commands = self._sent_commands() + self.assertLess( + commands.index("GripOpenPos 560.0"), + commands.index("gripper 1"), + "Position must be set before the gripper move command fires.", + ) + + async def test_force_sensing_branches_use_different_firmware_commands(self): + await self.arm.move_gripper(width=90.0, force_sensing=False) + await self.arm.move_gripper(width=90.0, force_sensing=True) + commands = self._sent_commands() + self.assertIn("gripper 1", commands) + self.assertIn("gripper 2", commands) + self.assertIn("GripOpenPos 530.0", commands) + self.assertIn("GripClosePos 530.0", commands) + + async def test_min_max_gripper_width_advertised(self): + self.assertEqual(self.arm.min_gripper_width, 60.0) + self.assertEqual(self.arm.max_gripper_width, 145.0) + + async def test_closed_gripper_position_shifts_units(self): + # Different anchor ⇒ same width yields a different firmware-unit target. + arm = _make_arm(closed_gripper_position=1000.0) + await arm.move_gripper(width=80.0, force_sensing=False) + commands = [c.args[0] for c in mocked(arm.send_command).call_args_list] + # 80 mm ⇒ 1000 + (80 - 60) = 1020 units. + self.assertEqual(commands, ["GripOpenPos 1020.0", "gripper 1"]) + + def test_mm_to_firmware_units_helper(self): + # Direct check of the linear mapping. + self.assertEqual(self.arm._mm_to_firmware_units(60.0), 500.0) + self.assertEqual(self.arm._mm_to_firmware_units(145.0), 585.0) + self.assertEqual(self.arm._mm_to_firmware_units(100.0), 540.0) + + +class TestPreciseFlex400OutOfRangeRecovery(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.arm = _make_arm() + self.arm._wait_for_eom = AsyncMock() # type: ignore[method-assign] + # Minimal stub configuration: only the soft limits the recovery logic reads. + self.arm._configuration = MagicMock( + soft_limits={ + Axis.SHOULDER: (-93.0, 93.0), + Axis.ELBOW: (12.0, 348.0), + Axis.WRIST: (-960.0, 960.0), + } + ) + + def _stub_transport(self, wherej: str) -> None: + """Stub the transport: ``wherej`` returns ``wherej``, ``Speed`` a 50% profile, other writes no-op. + + The recovery logic reads the live pose and profile speed over the transport, so we feed those + rather than reassigning the arm's own methods. + """ + + async def respond(command: str) -> str: + if command == "wherej": + return wherej + if command.startswith("Speed "): + return f"{self.arm.profile_index} 50.0" + return "" + + self.arm.send_command = AsyncMock(side_effect=respond) # type: ignore[method-assign] + + def _move_one_axis_cmds(self) -> list[str]: + return [ + c.args[0] + for c in mocked(self.arm.send_command).call_args_list + if c.args[0].startswith("MoveOneAxis") + ] + + async def test_recover_moves_offenders_toward_limit_in_order_and_skips_wrist(self): + """Each recoverable offender is driven 1 unit *inside* the violated limit (above-max down, + below-min up), shoulder before elbow per _RECOVERY_ORDER; the wrist is never auto-moved.""" + # wherej (no rail): base shoulder elbow wrist gripper - shoulder/elbow/wrist out of range. + self._stub_transport("0 93.5 9.0 962.0 0") + recovered = await self.arm.recover_axes_within_limits() + self.assertEqual(recovered, {Axis.SHOULDER: 92.0, Axis.ELBOW: 13.0}) # wrist excluded + self.assertEqual( + self._move_one_axis_cmds(), ["MoveOneAxis 2 92.0 1", "MoveOneAxis 3 13.0 1"] + ) # shoulder (2) before elbow (3) + + async def test_recover_skips_axis_too_far_out_of_range(self): + """An axis past its limit by more than max_distance is left in place (no unattended big sweep).""" + # shoulder 120 deg is 27 past the 93 limit, beyond the 5 cap; elbow/wrist in range. + self._stub_transport("0 120.0 30.0 0.0 0") + recovered = await self.arm.recover_axes_within_limits() + self.assertEqual(recovered, {}) + self.assertEqual(self._move_one_axis_cmds(), []) + + +class TestPreciseFlexParking(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.arm = _make_arm() + self.arm._wait_for_eom = AsyncMock() # type: ignore[method-assign] + + def _full_soft_limits(self) -> MagicMock: + return MagicMock( + z_range=(0.0, 400.0), + soft_limits={ + Axis.BASE: (0.0, 400.0), + Axis.SHOULDER: (-93.0, 93.0), + Axis.ELBOW: (12.0, 348.0), + Axis.WRIST: (-960.0, 960.0), + }, + ) + + def _movej_cmds(self) -> list[str]: + return [ + c.args[0] + for c in mocked(self.arm.send_command).call_args_list + if c.args[0].startswith("moveJ") + ] + + def test_named_constants_are_orientation_only_planar_folds(self): + """The three parking orientations are planar folds (ELBOW 180) that never pin Z (Axis.BASE), so + one orientation works on any reach; they differ only in which way the gripper faces.""" + for pose in ( + PreciseFlex.PARKING_POSITION_BACK, + PreciseFlex.PARKING_POSITION_RIGHT, + PreciseFlex.PARKING_POSITION_FRONT, + ): + self.assertNotIn(Axis.BASE, pose) + self.assertEqual(pose[Axis.ELBOW], 180.0) + self.assertEqual(PreciseFlex.PARKING_POSITION_BACK[Axis.SHOULDER], 90.0) + self.assertEqual(PreciseFlex.PARKING_POSITION_FRONT[Axis.SHOULDER], -90.0) + + def test_assignment_rejects_non_axis_keys(self): + """The validating setter refuses a pose keyed by anything but Axis members.""" + bad_pose: dict = {"base": 100.0} + with self.assertRaises(ValueError): + self.arm.parking_position = bad_pose + + def test_assignment_rejects_out_of_limit_value_once_configured(self): + """Once the soft limits are known, a value outside them is rejected at assignment.""" + self.arm._configuration = MagicMock(soft_limits={Axis.SHOULDER: (-93.0, 93.0)}) + with self.assertRaises(ValueError): + self.arm.parking_position = {Axis.SHOULDER: 200.0} + + def test_assignment_accepts_named_constant(self): + """A named constant assigns cleanly and round-trips through the getter.""" + self.arm.parking_position = PreciseFlex.PARKING_POSITION_FRONT + pose = self.arm.parking_position + assert pose is not None + self.assertEqual(pose[Axis.SHOULDER], -90.0) + + async def test_park_fills_z_at_three_quarters_travel_and_keeps_orientation(self): + """park() fills the omitted Z column at 3/4 of the discovered travel and keeps the orientation.""" + self.arm._configuration = self._full_soft_limits() + # Current pose deliberately differs from the target (base 50 not 300; orientation 10/200/90 not + # 0/180/180) so the assertion proves park() supplied the fill and orientation, not the live pose. + self.arm.send_command = AsyncMock(return_value="50 10 200 90 0") # type: ignore[method-assign] + self.arm.parking_position = PreciseFlex.PARKING_POSITION_RIGHT + await self.arm.park() + # Z filled at 3/4 of 400 = 300; orientation = RIGHT (0/180/180); gripper carried from current. + self.assertEqual(self._movej_cmds(), ["moveJ 1 300.0 0.0 180.0 180.0 0.0"]) + + async def test_park_respects_an_explicit_base(self): + """A pose that already sets Axis.BASE is parked as-is (no Z fill).""" + self.arm._configuration = self._full_soft_limits() + # base 50 in the current pose so the explicit 123 (neither the 300 fill nor the live 50) proves + # the supplied base is honored and not Z-filled; elbow/wrist carry from current. + self.arm.send_command = AsyncMock(return_value="50 10 200 90 0") # type: ignore[method-assign] + self.arm.parking_position = {Axis.BASE: 123.0, Axis.SHOULDER: 0.0} + await self.arm.park() + self.assertEqual(self._movej_cmds(), ["moveJ 1 123.0 0.0 200.0 90.0 0.0"]) + + async def test_park_without_position_falls_back_to_movetosafe(self): + """While parking_position is unset (no configuration), park() uses the firmware movetosafe.""" + await self.arm.park() + mocked(self.arm.send_command).assert_awaited_once_with("movetosafe") + self.assertEqual(self._movej_cmds(), []) + + +class TestPreciseFlexSmoothCartesianRoute(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.arm = _make_arm() + self.arm._wait_for_eom = AsyncMock() # type: ignore[method-assign] + self.current_joints = { + Axis.BASE: 100.0, + Axis.SHOULDER: 0.0, + Axis.ELBOW: 180.0, + Axis.WRIST: 180.0, + Axis.GRIPPER: 70.0, + } + self.current_pose = PreciseFlexCartesianPose( + location=Coordinate(10.0, 20.0, 100.0), + rotation=Rotation(x=-180.0, y=90.0, z=0.0), + rail_position=123.0, + orientation="right", + wrist="ccw", + ) + self.arm._request_state = AsyncMock(return_value=(self.current_joints, self.current_pose)) # type: ignore[method-assign] + + def _stub_profile_transport(self, profile: str = "1 50 0 100 100 0 0 25 0") -> None: + async def respond(command: str) -> str: + if command == "Profile 1": + return profile + return "" + + self.arm.send_command = AsyncMock(side_effect=respond) # type: ignore[method-assign] + + def _movej_cmds(self) -> list[str]: + return [ + c.args[0] + for c in mocked(self.arm.send_command).call_args_list + if c.args[0].startswith("moveJ") + ] + + def _profile_cmds(self) -> list[str]: + return [ + c.args[0] + for c in mocked(self.arm.send_command).call_args_list + if c.args[0].startswith("Profile") + ] + + async def test_move_through_cartesian_poses_plans_from_one_state_snapshot(self): + """A smooth route snapshots state once, fills omitted pose fields from the planned pose, + queues all joint moves, then waits once at the end.""" + self._stub_profile_transport() + poses = [ + PreciseFlexCartesianPose( + location=Coordinate(200.0, 20.0, 110.0), + rotation=Rotation(x=-180.0, y=90.0, z=10.0), + ), + PreciseFlexCartesianPose( + location=Coordinate(210.0, 20.0, 120.0), + rotation=Rotation(x=-180.0, y=90.0, z=20.0), + ), + ] + + with patch( + "pylabrobot.brooks.precise_flex.precise_flex.kinematics.ik", + side_effect=[ + {1: 110.0, 2: 10.0, 3: 20.0, 4: 30.0, 6: 123.0}, + {1: 120.0, 2: 11.0, 3: 21.0, 4: 31.0, 6: 123.0}, + ], + ) as ik: + await self.arm.move_through_cartesian_poses(poses) + + mocked(self.arm._request_state).assert_awaited_once() + mocked(self.arm._wait_for_eom).assert_awaited_once() + self.assertEqual( + self._movej_cmds(), + [ + "moveJ 1 110.0 10.0 20.0 30.0 70.0", + "moveJ 1 120.0 11.0 21.0 31.0 70.0", + ], + ) + planned_pose_args = [call.args[0] for call in ik.call_args_list] + self.assertEqual([pose.orientation for pose in planned_pose_args], ["right", "right"]) + self.assertEqual([pose.wrist for pose in planned_pose_args], ["ccw", "ccw"]) + # Rail-less PF400 still needs the shoulder/reference rail position for IK. + self.assertEqual([pose.rail_position for pose in planned_pose_args], [123.0, 123.0]) + + async def test_move_through_cartesian_poses_temporarily_enables_blending(self): + self._stub_profile_transport() + pose = PreciseFlexCartesianPose( + location=Coordinate(200.0, 20.0, 110.0), + rotation=Rotation(x=-180.0, y=90.0, z=10.0), + ) + + with patch( + "pylabrobot.brooks.precise_flex.precise_flex.kinematics.ik", + return_value={1: 110.0, 2: 10.0, 3: 20.0, 4: 30.0, 6: 123.0}, + ): + await self.arm.move_through_cartesian_poses([pose]) + + self.assertEqual( + self._profile_cmds(), + [ + "Profile 1", + "Profile 1 50.0 0.0 100.0 100.0 0.0 0.0 -1 0", + "Profile 1 50.0 0.0 100.0 100.0 0.0 0.0 25.0 0", + ], + ) + + async def test_move_through_cartesian_poses_can_skip_profile_blending(self): + pose = PreciseFlexCartesianPose( + location=Coordinate(200.0, 20.0, 110.0), + rotation=Rotation(x=-180.0, y=90.0, z=10.0), + ) + + with patch( + "pylabrobot.brooks.precise_flex.precise_flex.kinematics.ik", + return_value={1: 110.0, 2: 10.0, 3: 20.0, 4: 30.0, 6: 123.0}, + ): + await self.arm.move_through_cartesian_poses([pose], blend=False) + + self.assertEqual(self._profile_cmds(), []) + self.assertEqual(self._movej_cmds(), ["moveJ 1 110.0 10.0 20.0 30.0 70.0"]) + mocked(self.arm._wait_for_eom).assert_awaited_once() + + async def test_move_through_cartesian_poses_blocks_before_motion_on_limit_failure(self): + pose = PreciseFlexCartesianPose( + location=Coordinate(200.0, 20.0, 110.0), + rotation=Rotation(x=-180.0, y=90.0, z=10.0), + ) + self.arm._assert_within_soft_limits = MagicMock(side_effect=ValueError("bad target")) # type: ignore[method-assign] + + with patch( + "pylabrobot.brooks.precise_flex.precise_flex.kinematics.ik", + return_value={1: 110.0, 2: 10.0, 3: 20.0, 4: 30.0, 6: 123.0}, + ): + with self.assertRaisesRegex(ValueError, "bad target"): + await self.arm.move_through_cartesian_poses([pose]) + + self.assertEqual(self._movej_cmds(), []) + self.assertEqual(self._profile_cmds(), []) + mocked(self.arm._wait_for_eom).assert_not_awaited() + + +_LOGGER = "pylabrobot.brooks.precise_flex.precise_flex" + + +class TestPreciseFlex400AutoRecoverOnMove(unittest.IsolatedAsyncioTestCase): + """A commanded move that finds an axis out of range: default recovers; opt-out raises.""" + + def setUp(self): + self.arm = _make_arm() + self.arm._wait_for_eom = AsyncMock() # type: ignore[method-assign] + self.arm._configuration = MagicMock( + soft_limits={ + Axis.SHOULDER: (-93.0, 93.0), + Axis.ELBOW: (12.0, 348.0), + Axis.WRIST: (-960.0, 960.0), + } + ) + + def _stub(self, out_of_range: str, recovered: str = "") -> None: + """wherej returns ``out_of_range`` until a MoveOneAxis fires, then ``recovered`` (if given).""" + state = {"recovered": False} + + async def respond(command: str) -> str: + if command == "wherej": + return recovered if (state["recovered"] and recovered) else out_of_range + if command.startswith("Speed "): + return f"{self.arm.profile_index} 50.0" + if command.startswith("MoveOneAxis"): + state["recovered"] = True + return "" + + self.arm.send_command = AsyncMock(side_effect=respond) # type: ignore[method-assign] + + def _cmds(self, prefix: str) -> list[str]: + return [ + c.args[0] + for c in mocked(self.arm.send_command).call_args_list + if c.args[0].startswith(prefix) + ] + + async def test_opted_out_raises_and_does_not_move_or_recover(self): + """Opted out: an out-of-range axis raises OutOfRangeOfMotionError; no recovery, no moveJ.""" + self.arm._recover_out_of_range = False + self._stub("0 93.5 90.0 0.0 0") # base shoulder elbow wrist gripper; shoulder 93.5 > 93 + with self.assertRaises(OutOfRangeOfMotionError) as ctx: + await self.arm.move_to_joint_position({Axis.SHOULDER: 0.0}) + self.assertIn(Axis.SHOULDER, ctx.exception.axes) + self.assertEqual(self._cmds("MoveOneAxis"), []) + self.assertEqual(self._cmds("moveJ"), []) + + async def test_on_recovers_offender_then_retries_move(self): + """Opt-in on: the offending axis is nudged in range (MoveOneAxis), then the moveJ is retried.""" + self.arm._recover_out_of_range = True + self._stub("0 93.5 90.0 0.0 0", recovered="0 92.0 90.0 0.0 0") + with self.assertLogs(_LOGGER, level="INFO") as cm: + await self.arm.move_to_joint_position({Axis.SHOULDER: 0.0}) + self.assertEqual(self._cmds("MoveOneAxis"), ["MoveOneAxis 2 92.0 1"]) # shoulder back in range + self.assertEqual(len(self._cmds("moveJ")), 1) # move retried and sent + log = "\n".join(cm.output) + self.assertIn("commanded move blocked", log) # WARNING on entry + self.assertIn("retried successfully", log) # INFO on success + + async def test_on_but_unrecoverable_reraises_once_without_moving(self): + """Opt-in on but the axis is too far out (recovery skips it): re-raise after one try, no loop.""" + self.arm._recover_out_of_range = True + self._stub("0 120.0 90.0 0.0 0") # shoulder 27 past the limit, beyond the recovery cap + with self.assertLogs(_LOGGER, level="ERROR") as cm: + with self.assertRaises(OutOfRangeOfMotionError): + await self.arm.move_to_joint_position({Axis.SHOULDER: 0.0}) + self.assertEqual(self._cmds("moveJ"), []) # never moved + self.assertIn("auto-recovery did not clear", "\n".join(cm.output)) # ERROR before re-raise + + async def test_in_range_move_reads_position_once(self): + """Happy path: the out-of-range check reuses the merge read, so a move issues a single wherej + before moveJ (no redundant position read).""" + self._stub("0 0.0 90.0 0.0 0") # all axes in range + await self.arm.move_to_joint_position({Axis.SHOULDER: 10.0}) + self.assertEqual(self._cmds("wherej"), ["wherej"]) # exactly one position read + self.assertEqual(len(self._cmds("moveJ")), 1) + + async def test_move_to_location_is_also_guarded(self): + """The Cartesian path funnels through the same guard: an out-of-range axis raises and sends no + moveJ, like the joint path. The IK target is stubbed - it is the guard wiring, not IK, pinned + here.""" + self.arm._recover_out_of_range = False + self._stub("0 93.5 90.0 0.0 0") # current shoulder 93.5 > 93, out of range + in_range = { + Axis.BASE: 0.0, + Axis.SHOULDER: 0.0, + Axis.ELBOW: 90.0, + Axis.WRIST: 0.0, + Axis.GRIPPER: 0.0, + } + with patch.object(self.arm, "_cart_to_joints", AsyncMock(return_value=in_range)): + with self.assertRaises(OutOfRangeOfMotionError) as ctx: + await self.arm.move_to_location(Coordinate(400.0, 0.0, 200.0), 0.0) + self.assertIn(Axis.SHOULDER, ctx.exception.axes) + self.assertEqual(self._cmds("moveJ"), []) diff --git a/pylabrobot/byonoy/__init__.py b/pylabrobot/byonoy/__init__.py new file mode 100644 index 00000000000..9dc945ef596 --- /dev/null +++ b/pylabrobot/byonoy/__init__.py @@ -0,0 +1,34 @@ +from .absorbance_96 import ( + ByonoyAbsorbance96, + ByonoyAbsorbanceBaseUnit, + byonoy_a96a, + byonoy_a96a_detection_unit, + byonoy_a96a_illumination_unit, + byonoy_a96a_parking_unit, + byonoy_sbs_adapter, +) +from .driver import ( + LUM96_PRESET_S, + Abs1StatusError, + Abs96StatusError, + ByonoyDevice, + ByonoyDeviceInfo, + ByonoyEnvironment, + ByonoySlotState, + ByonoyStatus, + ByonoyVersions, + LedEffect, + Lum96IntegrationMode, + encode_well_bitmask, +) +from .luminescence_96 import ( + ByonoyLuminescence96, + ByonoyLuminescenceBaseUnit, + byonoy_l96, + byonoy_l96_base_unit, + byonoy_l96_reader_unit, + byonoy_l96a, + byonoy_l96a_base_unit, + byonoy_l96a_reader_unit, +) +from .results import AbsorbanceResult, LuminescenceResult diff --git a/pylabrobot/byonoy/absorbance_96.py b/pylabrobot/byonoy/absorbance_96.py new file mode 100644 index 00000000000..531a9589d7c --- /dev/null +++ b/pylabrobot/byonoy/absorbance_96.py @@ -0,0 +1,344 @@ +import asyncio +import logging +import time +from datetime import datetime +from typing import List, Optional, Tuple + +from pylabrobot.byonoy.driver import ABS96_ERROR_NAMES, ByonoyDevice, ByonoyDriver +from pylabrobot.byonoy.results import AbsorbanceResult +from pylabrobot.io.binary import Reader, Writer +from pylabrobot.resources import Coordinate, PlateHolder, Resource, ResourceHolder +from pylabrobot.resources.barcode import Barcode +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.rotation import Rotation +from pylabrobot.resources.well import Well +from pylabrobot.utils.list import reshape_2d + +logger = logging.getLogger(__name__) + + +class _ByonoyAbsorbanceReaderPlateHolder(PlateHolder): + """Plate holder with interlock: blocks drops while illumination unit is on the base.""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + pedestal_size_z: float = 0, + child_location: Coordinate = Coordinate.zero(), + category: str = "plate_holder", + model: Optional[str] = None, + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + pedestal_size_z=pedestal_size_z, + child_location=child_location, + category=category, + model=model, + ) + self._byonoy_base: Optional[ByonoyAbsorbanceBaseUnit] = None + + def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: + if self._byonoy_base is None: + raise RuntimeError( + "Plate holder not assigned to a ByonoyAbsorbanceBaseUnit. This should not happen." + ) + if self._byonoy_base.illumination_unit_holder.resource is not None: + raise RuntimeError( + f"Cannot drop resource {resource.name} onto plate holder while illumination unit is on " + "the base. Please remove the illumination unit from the base before dropping a resource." + ) + super().check_can_drop_resource_here(resource, reassign=reassign) + + +class ByonoyAbsorbanceBaseUnit(Resource): + def __init__( + self, + name: str, + size_x: float = 155.26, + size_y: float = 95.48, + size_z: float = 18.5, + rotation: Optional[Rotation] = None, + category: Optional[str] = None, + model: Optional[str] = None, + barcode: Optional[Barcode] = None, + preferred_pickup_location: Optional[Coordinate] = None, + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + barcode=barcode, + preferred_pickup_location=preferred_pickup_location, + ) + + self.plate_holder = _ByonoyAbsorbanceReaderPlateHolder( + name=self.name + "_plate_holder", + size_x=127.76, + size_y=85.59, + size_z=0, + child_location=Coordinate(x=22.5, y=5.0, z=16.0), + pedestal_size_z=0, + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + self.illumination_unit_holder = ResourceHolder( + name=self.name + "_illumination_unit_holder", + size_x=size_x, + size_y=size_y, + size_z=0, + child_location=Coordinate(x=0, y=0, z=14.1), + ) + self.assign_child_resource(self.illumination_unit_holder, location=Coordinate.zero()) + + def assign_child_resource( + self, resource: Resource, location: Optional[Coordinate], reassign: bool = True + ) -> None: + if isinstance(resource, _ByonoyAbsorbanceReaderPlateHolder): + if self.plate_holder._byonoy_base is not None: + raise ValueError("ByonoyDriver can only have one plate holder assigned.") + self.plate_holder._byonoy_base = self + super().assign_child_resource(resource, location, reassign) + + def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: + raise RuntimeError( + "ByonoyDriver does not support assigning child resources directly. " + "Use the plate_holder or illumination_unit_holder to assign plates and the " + "illumination unit, respectively." + ) + + +class ByonoyAbsorbance96(ByonoyAbsorbanceBaseUnit, ByonoyDriver): + """The Byonoy Absorbance 96 Automate reader unit. + + Inherits the base unit so it owns the ``plate_holder`` and + ``illumination_unit_holder``. ``ByonoyAbsorbanceBaseUnit`` comes first in the + MRO so ``Resource``'s ``name`` property (and its setter, which the resource + tree needs) wins over the driver's device-name property. + """ + + _ERROR_NAMES = ABS96_ERROR_NAMES + + def __init__(self, name: str = "byonoy_absorbance_96") -> None: + ByonoyAbsorbanceBaseUnit.__init__(self, name=name + "_base") + ByonoyDriver.__init__( + self, pid=0x1199, device_type=ByonoyDevice.ABSORBANCE_96, name="Byonoy A96" + ) + self.available_wavelengths: List[float] = [] + + async def setup(self) -> None: + await super().setup() + await self.initialize_measurements() + self.available_wavelengths = await self.request_available_absorbance_wavelengths() + logger.info( + "[%s] ready, available wavelengths: %s nm", + self.name, + self.available_wavelengths, + ) + + async def request_available_absorbance_wavelengths(self) -> List[float]: + response = await self.send_command( + report_id=0x0330, + payload=b"\x00" * 60, + wait_for_response=True, + routing_info=b"\x80\x40", + ) + assert response is not None, "Failed to get available wavelengths." + reader = Reader(response[2:]) + available_wavelengths = [reader.i16() for _ in range(30)] + return [w for w in available_wavelengths if w != 0] + + async def _run_abs_measurement(self, signal_wl: int, reference_wl: int, is_reference: bool): + with self._measurement_in_flight(0x0320): + await self.send_command( + report_id=0x0010, + payload=b"\x00" * 60, + wait_for_response=False, + ) + + payload2 = Writer().u16(7).u8(0).raw_bytes(b"\x00" * 52).finish() + await self.send_command( + report_id=0x0200, + payload=payload2, + wait_for_response=False, + ) + + payload3 = Writer().i16(signal_wl).i16(reference_wl).u8(int(is_reference)).u8(0).finish() + await self.send_command( + report_id=0x0320, + payload=payload3, + wait_for_response=False, + routing_info=b"\x00\x40", + ) + + # Index by seq so out-of-order/dropped chunks surface as None slots + # rather than silently shifting subsequent rows into the wrong wells. + rows_by_seq: List[Optional[List[float]]] = [] + flags_by_seq: List[Optional[int]] = [] + expected_chunks: Optional[int] = None + t0 = time.time() + + while True: + if self._abort_requested: + logger.info("[%s] measurement aborted by cancel()", self.name) + raise asyncio.CancelledError("Absorbance measurement aborted via cancel().") + if time.time() - t0 > 120: + logger.error( + "[%s] measurement timed out after 120s (signal=%d nm, ref=%d nm)", + self.name, + signal_wl, + reference_wl, + ) + raise TimeoutError("Measurement timeout.") + + chunk = await self.io.read(64, timeout=30) + if len(chunk) == 0: + continue + + reader = Reader(chunk) + report_id = reader.u16() + + if report_id == 0x0500: + seq = reader.u8() + seq_len = reader.u8() + _ = reader.i16() # signal_wl_nm + _ = reader.i16() # reference_wl_nm + _ = reader.u32() # duration_ms + row = [reader.f32() for _ in range(12)] + flags = reader.u8() + _ = reader.u8() # progress (0..100 running %); not surfaced + + if seq_len == 0: + raise RuntimeError(f"{self.name} firmware sent chunk with seq_len=0") + if expected_chunks is None: + expected_chunks = seq_len + rows_by_seq = [None] * seq_len + flags_by_seq = [None] * seq_len + elif seq_len != expected_chunks: + raise RuntimeError( + f"{self.name} firmware changed seq_len mid-stream: {expected_chunks} → {seq_len}" + ) + if not 0 <= seq < seq_len: + raise RuntimeError(f"{self.name} firmware sent seq={seq} (seq_len={seq_len})") + rows_by_seq[seq] = row + flags_by_seq[seq] = flags + + if all(r is not None for r in rows_by_seq): + break + + if expected_chunks is None: + raise RuntimeError(f"{self.name} absorbance read produced no chunks") + chunk_flags: List[int] = [f for f in flags_by_seq if f is not None] + rows: List[float] = [v for r in rows_by_seq if r is not None for v in r] + + status = await self.request_status() + if status.error_code != 0: + raise RuntimeError( + f"{self.name} firmware error after measurement (signal={signal_wl} nm, " + f"ref={reference_wl} nm): {self.describe_error_code(status.error_code)} " + f"(chunk flags: {[f'0x{f:02x}' for f in chunk_flags]})" + ) + self._warn_chunk_flags(chunk_flags) + + return rows + + async def initialize_measurements(self): + REFERENCE_WL = 0 + SIGNAL_WL = 660 + await self._run_abs_measurement( + signal_wl=SIGNAL_WL, + reference_wl=REFERENCE_WL, + is_reference=True, + ) + + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + ) -> List[AbsorbanceResult]: + if wavelength not in self.available_wavelengths: + raise ValueError( + f"Wavelength {wavelength} nm not in available wavelengths {self.available_wavelengths}." + ) + + logger.info( + "[%s] reading absorbance: plate='%s', wavelength=%d nm, wells=%d/%d", + self.name, + plate.name, + wavelength, + len(wells), + plate.num_items, + ) + rows = await self._run_abs_measurement( + signal_wl=wavelength, + reference_wl=0, + is_reference=False, + ) + + matrix = reshape_2d(rows, (8, 12)) + + return [ + AbsorbanceResult( + data=matrix, + wavelength=wavelength, + temperature=None, + timestamp=datetime.now(), + ) + ] + + +def byonoy_sbs_adapter(name: str) -> ResourceHolder: + """Create a Byonoy SBS adapter ResourceHolder.""" + return ResourceHolder( + name=name, + size_x=127.76, + size_y=85.48, + size_z=17.0, + child_location=Coordinate( + x=-(155.26 - 127.76) / 2, + y=-(95.48 - 85.48) / 2, + z=17.0, + ), + ) + + +def byonoy_a96a_illumination_unit(name: str) -> Resource: + size_x = 155.26 + size_y = 95.48 + return Resource( + name=name, + size_x=size_x, + size_y=size_y, + size_z=42.898, + model="Byonoy A96A Illumination Unit", + preferred_pickup_location=Coordinate(x=size_x / 2, y=size_y / 2, z=29.5), + ) + + +def byonoy_a96a_detection_unit(name: str) -> ByonoyAbsorbance96: + """Create a Byonoy A96A detection unit.""" + return ByonoyAbsorbance96(name=name) + + +def byonoy_a96a_parking_unit(name: str) -> ByonoyAbsorbanceBaseUnit: + """Create a Byonoy A96A detection unit holder (base only, no backend).""" + return ByonoyAbsorbanceBaseUnit(name=name) + + +def byonoy_a96a(name: str, assign: bool = True) -> Tuple[ByonoyAbsorbance96, Resource]: + """Create a full Byonoy A96A setup (reader + illumination unit).""" + reader = byonoy_a96a_detection_unit(name=name + "_reader") + illumination_unit = byonoy_a96a_illumination_unit(name=name + "_illumination_unit") + if assign: + reader.illumination_unit_holder.assign_child_resource(illumination_unit) + return reader, illumination_unit diff --git a/pylabrobot/byonoy/driver.py b/pylabrobot/byonoy/driver.py new file mode 100644 index 00000000000..08939e83ce1 --- /dev/null +++ b/pylabrobot/byonoy/driver.py @@ -0,0 +1,518 @@ +import asyncio +import contextlib +import enum +import logging +import time +from abc import ABCMeta +from dataclasses import dataclass +from typing import Dict, Iterator, List, Literal, Optional, Tuple, cast + +from pylabrobot.io.binary import Reader, Writer +from pylabrobot.io.hid import HID + +logger = logging.getLogger(__name__) + + +class ByonoyDevice(enum.Enum): + ABSORBANCE_96 = enum.auto() + LUMINESCENCE_96 = enum.auto() + + +class ByonoySlotState(enum.IntEnum): + UNKNOWN = 0 + EMPTY = 1 + OCCUPIED = 2 + UNDETERMINED = 3 + + +Lum96IntegrationMode = Literal["rapid", "sensitive", "ultra_sensitive", "custom"] + + +# Preset integration times (matches byonoy_device_library: hidmeasurements.cpp) +LUM96_PRESET_S: Dict[Lum96IntegrationMode, float] = { + "rapid": 0.1, + "sensitive": 2.0, + "ultra_sensitive": 20.0, +} + + +def encode_well_bitmask(selected: List[bool], n: int = 96) -> bytes: + """Pack a length-n bool list into a little-endian bitmask, LSB-first within each byte.""" + if len(selected) != n: + raise ValueError(f"expected {n} bools, got {len(selected)}") + nbytes = (n + 7) // 8 + out = bytearray(nbytes) + for i, b in enumerate(selected): + if b: + out[i // 8] |= 1 << (i % 8) + return bytes(out) + + +@dataclass +class ByonoyStatus: + is_initialized: bool + slot_state: ByonoySlotState + error_code: int + uptime_s: int + is_measuring: bool + boot_completed: bool + + +@dataclass +class ByonoyEnvironment: + temperature_c: float + humidity: float # 0..1 + acceleration_g: Tuple[float, float, float] + + +@dataclass +class ByonoyVersions: + system_version: int + stm_version: int + stm_dev_version: int + esp_version: int + esp_dev_version: int + stm_bootloader_version: int + + @property + def system_version_known(self) -> bool: + return self.system_version != 0 + + @property + def is_production(self) -> bool: + return self.stm_dev_version == 0 and self.esp_dev_version == 0 + + +@dataclass +class ByonoyDeviceInfo: + device_id: str + device_name: str + manufacturer: str + serial_no: str + firmware_version: str + ref_number: str + + +# device_data_field_id (byonoyusbhid.h) +_DD_DEVICE_ID = 0 +_DD_DEVICE_NAME = 1 +_DD_DEVICE_MANUFACTURER = 2 +_DD_SERIAL_NO = 3 +_DD_FIRMWARE_VERSION = 4 +_DD_REF_NUMBER = 8 + +# device_data_field_flags (byonoyusbhid.h) +_FLAG_TYPE_MASK = 0x0F +_FLAG_TYPE_STRING = 0x02 +_FLAG_TYPE_INTEGER = 0x01 +_FLAG_TYPE_FLOAT = 0x04 +_FLAG_TYPE_BOOLEAN = 0x03 +_FLAG_HAS_MORE_DATA = 0x10 + + +LedEffect = Literal["solid", "progress", "cylon", "rainbow", "blinking", "breathing"] + +_LED_EFFECT_CODES: Dict[LedEffect, int] = { + "solid": 0x00, + "progress": 0x01, + "cylon": 0x02, + "rainbow": 0x03, + "blinking": 0x04, + "breathing": 0x05, +} + + +# --- Firmware error codes (per Byonoy hid-reports source) ------------------- +# +# The status_in_t.error_code byte is device-specific. Byonoy's own C library +# defines a Status base class that just stringifies the hex code, with per- +# device subclasses (Abs96Status, Abs1Status) providing named tables. There +# is no documented Lum96 table — Lum96 inherits the generic stringifier. +# +# These mirror the enums in: +# hid-reports/src/hid/report/request/abs96status.cpp +# hid-reports/src/hid/report/request/abs1status.cpp + + +class Abs96StatusError(enum.IntEnum): + NO_ERROR = 0 + ERROR_CALIB = 1 + ERROR_AMBIENT = 2 + ERROR_USB = 3 + ERROR_HARDWARE = 4 + ERROR_TEMPERATURE = 5 + ERROR_NO_MEASUREMENTUNIT = 6 + ERROR_NO_ACK = 10 + + +class Abs1StatusError(enum.IntFlag): + """AbsOne errors are a bit-flag set — multiple can be raised at once.""" + + NO_ERROR = 0 + AMBIENT_LIGHT = 1 + MIN_LIGHT = 2 + USB = 4 + HARDWARE = 8 + EEPROM = 16 + TIMEOUT = 32 + POWER_CALIBRATION = 64 + NOISE_LIMIT = 128 + + +_GENERIC_ERROR_NAMES: Dict[int, str] = {0: "NO_ERROR"} +ABS96_ERROR_NAMES: Dict[int, str] = {e.value: e.name for e in Abs96StatusError} +ABS1_ERROR_NAMES: Dict[int, str] = {e.value: cast(str, e.name) for e in Abs1StatusError} + + +_ACCEL_LSB_PER_G = 16384.0 # 14-bit signed @ ±2 g full scale + + +class ByonoyDriver(metaclass=ABCMeta): + """Shared HID communication logic for Byonoy plate readers.""" + + # Firmware error-code → name mapping. Default mirrors Byonoy's generic + # Status::firmwareErrorId (only NO_ERROR is documented). Subclasses for + # specific devices (e.g. ByonoyAbsorbance96Backend) override with their + # documented tables. Lum96 has no documented table; inherits the default. + _ERROR_NAMES: Dict[int, str] = _GENERIC_ERROR_NAMES + + def __init__(self, pid: int, device_type: ByonoyDevice, name: str) -> None: + super().__init__() + self.io = HID(human_readable_device_name=name, vid=0x16D0, pid=pid) + self._device_type = device_type + self._abort_requested = False + self._in_flight_trigger: Optional[int] = None + # Serializes write+response-sniff in send_command. Does NOT cover the + # measurement read loops in subclasses (which poll io.read directly so + # cancel() can still send the abort while they run). + self._io_lock = asyncio.Lock() + + @property + def name(self) -> str: + return self.io.human_readable_device_name + + async def setup(self) -> None: + await self.io.setup() + logger.info("[%s] connected", self.name) + + async def stop(self) -> None: + if self._in_flight_trigger is not None: + await self.cancel() + await self.io.stop() + logger.info("[%s] disconnected", self.name) + + def _assemble_command(self, report_id: int, payload: bytes, routing_info: bytes) -> bytes: + packet = Writer().u16(report_id).raw_bytes(payload).finish() + packet += b"\x00" * (62 - len(packet)) + routing_info + return packet + + async def send_command( + self, + report_id: int, + payload: bytes, + wait_for_response: bool = True, + routing_info: bytes = b"\x00\x00", + ) -> Optional[bytes]: + command = self._assemble_command(report_id, payload=payload, routing_info=routing_info) + async with self._io_lock: + await self.io.write(command) + if not wait_for_response: + return None + + t0 = time.time() + while True: + if time.time() - t0 > 120: + logger.error( + "[%s] timeout waiting for response to command 0x%04X after 120s", + self.name, + report_id, + ) + raise TimeoutError("Reading data timed out after 2 minutes.") + response = await self.io.read(64, timeout=30) + if len(response) == 0: + continue + response_report_id = Reader(response).u16() + if report_id == response_report_id: + break + return response + + async def request_status(self) -> ByonoyStatus: + """Read REP_STATUS_IN (0x0300): init/slot/error/uptime/measuring/boot.""" + response = await self.send_command( + report_id=0x0300, payload=b"\x00" * 60, routing_info=b"\x80\x40" + ) + assert response is not None + r = Reader(response[2:]) + return ByonoyStatus( + is_initialized=r.u8() != 0, + slot_state=ByonoySlotState(r.u8()), + error_code=r.u8(), + uptime_s=r.u32(), + is_measuring=r.u8() != 0, + boot_completed=r.u8() != 0, + ) + + def _warn_chunk_flags(self, chunk_flags: List[int]) -> None: + """Log non-zero per-chunk flag bytes from a measurement read loop. + + Vendor bit definitions for the measurement-result `flags` byte aren't + published, so we can't decode them — only surface that *something* was + flagged. Subclasses' read loops call this after the loop completes + (after error_code has been checked and didn't raise). + """ + if any(f != 0 for f in chunk_flags): + logger.warning( + "[%s] non-zero chunk flags during read: %s " + "(vendor bit definitions not published; data may be unreliable)", + self.name, + [f"0x{f:02x}" for f in chunk_flags], + ) + + def describe_error_code(self, code: int) -> str: + """Return a human-readable name for a firmware error_code byte. + + Looks up `code` in this backend's `_ERROR_NAMES` table. Unknown codes + fall back to `"errorCode=0xNN"` matching the C library's generic + Status::firmwareErrorId. The default table only has NO_ERROR (0); + subclasses for documented devices (Abs96, AbsOne) populate richer + tables. Lum96 has no documented table — codes other than 0 will + surface as the hex sentinel, which is the honest answer. + """ + if code in self._ERROR_NAMES: + return self._ERROR_NAMES[code] + return f"errorCode=0x{code:02x}" + + async def request_environment(self) -> ByonoyEnvironment: + """Read REP_ENVIRONMENT_IN (0x0310): temperature, humidity, acceleration.""" + response = await self.send_command( + report_id=0x0310, payload=b"\x00" * 60, routing_info=b"\x80\x40" + ) + assert response is not None + r = Reader(response[2:]) + temp_c = r.i16() / 100.0 + humidity = r.i16() / 1000.0 + ax, ay, az = r.i16(), r.i16(), r.i16() + return ByonoyEnvironment( + temperature_c=temp_c, + humidity=humidity, + acceleration_g=(ax / _ACCEL_LSB_PER_G, ay / _ACCEL_LSB_PER_G, az / _ACCEL_LSB_PER_G), + ) + + async def request_api_version(self) -> int: + """Read REP_API_VERSION_IN (0x0050): a single u32.""" + response = await self.send_command( + report_id=0x0050, payload=b"\x00" * 60, routing_info=b"\x80\x40" + ) + assert response is not None + return Reader(response[2:]).u32() + + async def request_supported_reports(self) -> List[int]: + """Read REP_SUPPORTED_REPORTS_IN (0x0010): list of report IDs the device supports. + + Reply is delivered in seq/seq_len chunks of up to 29 u16 ids; zero-valued + entries are padding. Returns the deduplicated, ordered union. + """ + cmd = self._assemble_command(report_id=0x0010, payload=b"\x00" * 60, routing_info=b"\x80\x40") + await self.io.write(cmd) + + seen: List[int] = [] + t0 = time.time() + while True: + if time.time() - t0 > 30: + raise TimeoutError("Timed out reading supported reports.") + chunk = await self.io.read(64, timeout=10) + if len(chunk) == 0: + continue + r = Reader(chunk) + if r.u16() != 0x0010: + continue + seq = r.u8() + seq_len = r.u8() + ids = [r.u16() for _ in range(29)] + seen.extend(i for i in ids if i != 0) + if seq == seq_len - 1: + break + # Preserve order, drop dupes + out: List[int] = [] + for i in seen: + if i not in out: + out.append(i) + return out + + async def _read_data_field(self, field_index: int) -> object: + """Read a named device-data field via REP_DEVICE_DATA_READ_IN (0x0200). + + Returns the field's value typed per the response flags + (str / int / float / bool / bytes). Truncates if HAS_MORE_DATA is set + (shouldn't happen for the short identity strings; log if it does). + + Private — the only documented caller is `request_device_info`, which knows + the field types ahead of time. Promote to public if you find a use case + that needs the polymorphic-by-flag-byte shape. + """ + payload = Writer().u16(field_index).u8(0).raw_bytes(b"\x00" * 57).finish() + response = await self.send_command(report_id=0x0200, payload=payload, routing_info=b"\x80\x40") + assert response is not None + r = Reader(response[2:]) + _ = r.u16() # echoed field_index + flags = r.u8() + data_type = flags & _FLAG_TYPE_MASK + if flags & _FLAG_HAS_MORE_DATA: + logger.warning( + "[%s] field 0x%04X has more data than fits in one report; truncating", + self.name, + field_index, + ) + raw = r.raw_bytes(52) + if data_type == _FLAG_TYPE_STRING: + return raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace") + if data_type == _FLAG_TYPE_INTEGER: + return int.from_bytes(raw[:4], "little", signed=False) + if data_type == _FLAG_TYPE_FLOAT: + return Reader(raw[:4]).f32() + if data_type == _FLAG_TYPE_BOOLEAN: + return raw[0] != 0 + return raw # TypeBytes + + async def request_device_info(self) -> ByonoyDeviceInfo: + """Read identity strings (matches C lib's byonoy_get_device_information).""" + + async def s(idx: int) -> str: + v = await self._read_data_field(idx) + return v if isinstance(v, str) else str(v) + + return ByonoyDeviceInfo( + device_id=await s(_DD_DEVICE_ID), + device_name=await s(_DD_DEVICE_NAME), + manufacturer=await s(_DD_DEVICE_MANUFACTURER), + serial_no=await s(_DD_SERIAL_NO), + firmware_version=await s(_DD_FIRMWARE_VERSION), + ref_number=await s(_DD_REF_NUMBER), + ) + + @contextlib.contextmanager + def _measurement_in_flight(self, report_id: int) -> Iterator[None]: + """Mark `report_id` as the in-flight measurement trigger for the duration + of the `with` block. Subclasses' read methods wrap their trigger + result + loop in this so `cancel()` can find the right report to abort and so a + concurrent second read raises instead of corrupting the read buffer. + """ + if self._in_flight_trigger is not None: + raise RuntimeError( + f"Byonoy device busy: report 0x{self._in_flight_trigger:04X} already in " + f"flight; call cancel() before starting 0x{report_id:04X}." + ) + # Entry-side reset is load-bearing for correctness; exit-side is hygiene + # so a between-reads inspection doesn't see stale True from a prior cancel. + self._in_flight_trigger = report_id + self._abort_requested = False + try: + yield + finally: + self._in_flight_trigger = None + self._abort_requested = False + + async def cancel(self) -> None: + """Abort the in-flight measurement via REP_ABORT_REPORT_OUT (0x0060). + + Uses the report id tracked by the read method's `_measurement_in_flight` + context. If no measurement is in flight, this is a no-op. + + Empirically the firmware stops emitting result chunks but sends no closing + notification, so we also raise `_abort_requested`; subclasses' read loops + poll the flag and bail out instead of waiting 120 s for the hard timeout. + """ + report_id = self._in_flight_trigger + if report_id is None: + logger.info("[%s] cancel(): no measurement in flight; no-op", self.name) + return + self._abort_requested = True + payload = Writer().u16(report_id).raw_bytes(b"\x00" * 58).finish() + await self.send_command(report_id=0x0060, payload=payload, wait_for_response=False) + logger.info("[%s] sent abort for in-flight report 0x%04X", self.name, report_id) + + async def set_led_color( + self, + color: Tuple[int, int, int], + effect: LedEffect = "solid", + *, + low_power: bool = False, + force: bool = False, + effect_state: int = 0, + duration_ms: int = 0, + ) -> None: + """Set the LED bar to a single color via REP_LED_BAR_EFFECTS_OUT (0x0351). + + Mirrors the vendor's user-facing `set_led_effect(effect, color, modes, ...)` + in byonoy_device_library. The firmware renders `effect` over `color`: + "solid" just shows the color; "breathing"/"cylon"/"blinking"/"rainbow"/ + "progress" animate it. + + Packed layout (vendor byonoyusbhid.h led_bar_effects_out_t): + effect:u8 color:(r,g,b u8) effect_state:u8 flags:u8 duration_ms:u32 + + `force` (FLAG_LED_FORCE=0x10) overrides an unexpired previous + `duration_ms`. `low_power` (FLAG_LED_LOWPOWER=0x01) reduces brightness. + + `effect_state` is a 0..255 parameter used only by the "progress" effect + to indicate fill level (0 = empty bar, 255 = full bar). It is ignored + by every other effect ("solid", "breathing", "blinking", "cylon", + "rainbow"); leave it at 0 unless you're driving progress. + + The PC routing tag (request_info=0x4000) is required — the firmware + silently drops LED writes that arrive with the default LEGACY tag. + """ + flags = (0x01 if low_power else 0) | (0x10 if force else 0) + r_, g, b = color + payload = ( + Writer() + .u8(_LED_EFFECT_CODES[effect]) + .u8(r_ & 0xFF) + .u8(g & 0xFF) + .u8(b & 0xFF) + .u8(effect_state & 0xFF) + .u8(flags) + .u32(int(duration_ms)) + .finish() + ) + await self.send_command( + report_id=0x0351, + payload=payload, + wait_for_response=False, + routing_info=b"\x00\x40", + ) + + async def set_led_colors(self, colors: List[Tuple[int, int, int]]) -> None: + """Set each of the 20 LED bar pixels individually via + REP_LED_BAR_COLOURS_OUT (0x0350). Pads with black if fewer than 20 are + given; truncates if more. Fast enough for real-time animation (~30+ fps). + + Like `set_led_color`, requires the PC routing tag (request_info=0x4000); + the firmware silently drops writes with the default LEGACY tag. + """ + pixels = list(colors[:20]) + [(0, 0, 0)] * max(0, 20 - len(colors)) + w = Writer() + for r_, g, b in pixels: + w.u8(r_ & 0xFF).u8(g & 0xFF).u8(b & 0xFF) + await self.send_command( + report_id=0x0350, + payload=w.finish(), + wait_for_response=False, + routing_info=b"\x00\x40", + ) + + async def request_versions(self) -> ByonoyVersions: + """Read REP_VERSIONS_IN (0x0080): system / STM / ESP / bootloader versions.""" + response = await self.send_command( + report_id=0x0080, payload=b"\x00" * 60, routing_info=b"\x80\x40" + ) + assert response is not None + r = Reader(response[2:]) + return ByonoyVersions( + system_version=r.u32(), + stm_version=r.u32(), + stm_dev_version=r.u32(), + esp_version=r.u32(), + esp_dev_version=r.u32(), + stm_bootloader_version=r.u32(), + ) diff --git a/pylabrobot/byonoy/driver_tests.py b/pylabrobot/byonoy/driver_tests.py new file mode 100644 index 00000000000..4480ddd3ae2 --- /dev/null +++ b/pylabrobot/byonoy/driver_tests.py @@ -0,0 +1,141 @@ +import unittest + +from pylabrobot.byonoy.driver import ( + _GENERIC_ERROR_NAMES, + _LED_EFFECT_CODES, + ABS1_ERROR_NAMES, + ABS96_ERROR_NAMES, + LUM96_PRESET_S, + Abs1StatusError, + Abs96StatusError, + ByonoyDevice, + ByonoyDriver, + encode_well_bitmask, +) + + +class EncodeWellBitmaskTests(unittest.TestCase): + def test_all_false_is_zero_filled(self): + self.assertEqual(encode_well_bitmask([False] * 96), b"\x00" * 12) + + def test_all_true_is_all_ones(self): + self.assertEqual(encode_well_bitmask([True] * 96), b"\xff" * 12) + + def test_a1_only_sets_byte0_bit0(self): + bools = [False] * 96 + bools[0] = True + self.assertEqual(encode_well_bitmask(bools), b"\x01" + b"\x00" * 11) + + def test_h12_only_sets_byte11_bit7(self): + bools = [False] * 96 + bools[95] = True + self.assertEqual(encode_well_bitmask(bools), b"\x00" * 11 + b"\x80") + + def test_bits_7_and_8_cross_byte_boundary(self): + bools = [False] * 96 + bools[7] = True # byte 0, bit 7 → 0x80 + bools[8] = True # byte 1, bit 0 → 0x01 + self.assertEqual(encode_well_bitmask(bools), b"\x80\x01" + b"\x00" * 10) + + def test_first_column_has_8_bits_set(self): + bools = [False] * 96 + for r in range(8): + bools[r * 12] = True + result = encode_well_bitmask(bools) + self.assertEqual(sum(bin(b).count("1") for b in result), 8) + + def test_custom_n_size(self): + # bit 0 + bit 2 → 0x05 + self.assertEqual(encode_well_bitmask([True, False, True], n=3), b"\x05") + + def test_length_mismatch_raises(self): + with self.assertRaises(ValueError): + encode_well_bitmask([True] * 95) + with self.assertRaises(ValueError): + encode_well_bitmask([True] * 96, n=24) + + +class IntegrationModePresetTests(unittest.TestCase): + def test_preset_values_match_vendor(self): + self.assertEqual(LUM96_PRESET_S["rapid"], 0.1) + self.assertEqual(LUM96_PRESET_S["sensitive"], 2.0) + self.assertEqual(LUM96_PRESET_S["ultra_sensitive"], 20.0) + + def test_custom_is_not_a_preset(self): + # "custom" is a Literal value but has no preset — read_luminescence + # requires the caller to set integration_time explicitly. + self.assertNotIn("custom", LUM96_PRESET_S) + + +class ErrorTableTests(unittest.TestCase): + def test_generic_table_has_only_no_error(self): + self.assertEqual(_GENERIC_ERROR_NAMES, {0: "NO_ERROR"}) + + def test_abs96_table_round_trips(self): + self.assertEqual(ABS96_ERROR_NAMES[0], "NO_ERROR") + self.assertEqual(ABS96_ERROR_NAMES[1], "ERROR_CALIB") + self.assertEqual(ABS96_ERROR_NAMES[Abs96StatusError.ERROR_NO_ACK], "ERROR_NO_ACK") + + def test_abs1_flag_bit_values(self): + # AbsOne is a bit-flag enum; verify bit positions match the vendor header. + self.assertEqual(Abs1StatusError.AMBIENT_LIGHT.value, 1) + self.assertEqual(Abs1StatusError.MIN_LIGHT.value, 2) + self.assertEqual(Abs1StatusError.USB.value, 4) + self.assertEqual(Abs1StatusError.HARDWARE.value, 8) + self.assertEqual(Abs1StatusError.NOISE_LIMIT.value, 128) + + def test_abs1_supports_combined_flags(self): + combined = Abs1StatusError.AMBIENT_LIGHT | Abs1StatusError.HARDWARE + self.assertEqual(combined.value, 9) + + def test_abs1_table_includes_all_enum_members(self): + for member in Abs1StatusError: + self.assertIn(member.value, ABS1_ERROR_NAMES) + + +class DescribeErrorCodeTests(unittest.TestCase): + """describe_error_code() is pure — bypass __init__ to avoid HID setup.""" + + def _make_driver(self, error_names): + drv = ByonoyDriver.__new__(ByonoyDriver) + drv._ERROR_NAMES = error_names + return drv + + def test_known_code_returns_name(self): + drv = self._make_driver(ABS96_ERROR_NAMES) + self.assertEqual(drv.describe_error_code(1), "ERROR_CALIB") + self.assertEqual(drv.describe_error_code(0), "NO_ERROR") + + def test_unknown_code_falls_back_to_hex(self): + drv = self._make_driver(ABS96_ERROR_NAMES) + self.assertEqual(drv.describe_error_code(0xAB), "errorCode=0xab") + + def test_generic_table_only_knows_no_error(self): + # Lum96 uses the generic table — anything non-zero is the hex sentinel. + drv = self._make_driver(_GENERIC_ERROR_NAMES) + self.assertEqual(drv.describe_error_code(0), "NO_ERROR") + self.assertEqual(drv.describe_error_code(7), "errorCode=0x07") + self.assertEqual(drv.describe_error_code(255), "errorCode=0xff") + + +class LedEffectCodeTests(unittest.TestCase): + def test_codes_cover_all_effect_literals(self): + self.assertEqual( + set(_LED_EFFECT_CODES), + {"solid", "progress", "cylon", "rainbow", "blinking", "breathing"}, + ) + + def test_solid_is_zero(self): + self.assertEqual(_LED_EFFECT_CODES["solid"], 0x00) + + def test_codes_are_unique(self): + self.assertEqual(len(set(_LED_EFFECT_CODES.values())), len(_LED_EFFECT_CODES)) + + +class ByonoyDeviceEnumTests(unittest.TestCase): + def test_distinct_values(self): + self.assertNotEqual(ByonoyDevice.ABSORBANCE_96, ByonoyDevice.LUMINESCENCE_96) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py new file mode 100644 index 00000000000..f245df0fa65 --- /dev/null +++ b/pylabrobot/byonoy/luminescence_96.py @@ -0,0 +1,387 @@ +import asyncio +import logging +import time +from datetime import datetime +from typing import List, Optional, Tuple + +from pylabrobot.byonoy.driver import ( + LUM96_PRESET_S, + ByonoyDevice, + ByonoyDriver, + Lum96IntegrationMode, + encode_well_bitmask, +) +from pylabrobot.byonoy.results import LuminescenceResult +from pylabrobot.io.binary import Reader, Writer +from pylabrobot.resources import Coordinate, PlateHolder, Resource, ResourceHolder +from pylabrobot.resources.barcode import Barcode +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.rotation import Rotation +from pylabrobot.resources.well import Well +from pylabrobot.utils.list import reshape_2d + +logger = logging.getLogger(__name__) + + +class ByonoyLuminescence96(Resource, ByonoyDriver): + """The Byonoy Luminescence 96 Automate reader unit. + + ``Resource`` comes first in the MRO so its ``name`` property (and its setter, + which the resource tree needs) wins over the driver's device-name property. + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + rotation: Optional[Rotation] = None, + category: Optional[str] = None, + model: Optional[str] = "Byonoy L96 Reader Unit", + barcode: Optional[Barcode] = None, + preferred_pickup_location: Optional[Coordinate] = None, + ) -> None: + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + barcode=barcode, + preferred_pickup_location=preferred_pickup_location, + ) + ByonoyDriver.__init__( + self, pid=0x119B, device_type=ByonoyDevice.LUMINESCENCE_96, name="Byonoy L96" + ) + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + mode: Lum96IntegrationMode = "sensitive", + integration_time: Optional[float] = None, + ) -> List[LuminescenceResult]: + """Read luminescence. + + Args: + plate: The plate being read. + wells: Wells to measure. + focal_height: Required by the abstract :class:`LuminescenceBackend` + contract but **ignored on the Byonoy L96** — the device has a + fixed optical configuration (the detector unit clamps onto the + base; the optical path is determined by plate + base + detector + geometry, not user-tunable). Passing any value is harmless; + passing 0 is conventional. + mode: One of "rapid" (100 ms), "sensitive" (2 s, default), + "ultra_sensitive" (20 s), or "custom". Presets match the + byonoy_device_library mapping. + integration_time: Integration time in seconds. If set, forces "custom" + mode regardless of `mode`. Required when `mode == "custom"`. + """ + # Resolve mode + integration time + if integration_time is not None: + mode = "custom" + elif mode == "custom": + raise ValueError("'custom' mode requires integration_time to be set.") + else: + integration_time = LUM96_PRESET_S[mode] + + # Firmware always scans all 96 wells; this mask only filters which are + # reported (others come back as 0.0). Single source of truth: the wells arg. + well_set = set(id(w) for w in wells) + mask_bools = [id(w) in well_set for w in plate.get_all_items()] + + well_mask = encode_well_bitmask(mask_bools, n=96) + logger.info( + "[%s] reading luminescence: plate='%s', mode=%s, integration_time=%.3fs, wells=%d/96", + self.name, + plate.name, + mode, + integration_time, + sum(mask_bools), + ) + + with self._measurement_in_flight(0x0340): + await self.send_command( + report_id=0x0010, + payload=b"\x00" * 60, + wait_for_response=False, + ) + + payload2 = Writer().u16(7).u8(0).raw_bytes(b"\x00" * 52).finish() + await self.send_command( + report_id=0x0200, + payload=payload2, + wait_for_response=False, + ) + + payload3 = ( + Writer() + .i32(int(integration_time * 1_000_000)) + .raw_bytes(well_mask) + .u8(0) # is_reference_measurement + .u8(0) # flags + .finish() + ) + await self.send_command( + report_id=0x0340, + payload=payload3, + wait_for_response=False, + ) + + t0 = time.time() + # Index by seq so an out-of-order or dropped chunk surfaces as a None + # slot instead of silently shifting subsequent rows into the wrong wells. + rows_by_seq: List[Optional[List[float]]] = [] + flags_by_seq: List[Optional[int]] = [] + expected_chunks: Optional[int] = None + + while True: + if self._abort_requested: + logger.info("[%s] read aborted by cancel()", self.name) + raise asyncio.CancelledError("Luminescence read aborted via cancel().") + if time.time() - t0 > 120: + logger.error("[%s] luminescence read timed out after 120s", self.name) + raise TimeoutError("Reading luminescence data timed out after 2 minutes.") + + chunk = await self.io.read(64, timeout=2) + if len(chunk) == 0: + continue + + reader = Reader(chunk) + report_id = reader.u16() + + if report_id == 0x0600: + seq = reader.u8() + seq_len = reader.u8() + _ = reader.u32() # integration_time_us + _ = reader.u32() # duration_ms + row = [reader.f32() for _ in range(12)] + flags = reader.u8() + _ = reader.u8() # progress (0..100 running %); not surfaced + + if seq_len == 0: + raise RuntimeError(f"{self.name} firmware sent chunk with seq_len=0") + if expected_chunks is None: + expected_chunks = seq_len + rows_by_seq = [None] * seq_len + flags_by_seq = [None] * seq_len + elif seq_len != expected_chunks: + raise RuntimeError( + f"{self.name} firmware changed seq_len mid-stream: {expected_chunks} → {seq_len}" + ) + if not 0 <= seq < seq_len: + raise RuntimeError(f"{self.name} firmware sent seq={seq} (seq_len={seq_len})") + rows_by_seq[seq] = row + flags_by_seq[seq] = flags + + if all(r is not None for r in rows_by_seq): + break + + if expected_chunks is None: + raise RuntimeError(f"{self.name} luminescence read produced no chunks") + chunk_flags: List[int] = [f for f in flags_by_seq if f is not None] + all_rows: List[float] = [v for row in rows_by_seq if row is not None for v in row] + + # Check firmware health before trusting the data. error_code is the + # authoritative post-measurement status byte; per-chunk flags are + # undocumented but a non-zero value means the firmware flagged the chunk. + status = await self.request_status() + if status.error_code != 0: + raise RuntimeError( + f"{self.name} firmware error after read: " + f"{self.describe_error_code(status.error_code)} " + f"(chunk flags: {[f'0x{f:02x}' for f in chunk_flags]})" + ) + self._warn_chunk_flags(chunk_flags) + if len(all_rows) != 96: + raise RuntimeError( + f"{self.name} luminescence read produced {len(all_rows)} values (expected 96)" + ) + + # Firmware zero-fills wells outside the mask. Convert those to None per + # the LuminescenceResult contract ("None for unmeasured wells") — 0.0 is + # a legitimate measurement (baseline subtraction can yield ~0 or negative). + masked: List[Optional[float]] = [v if m else None for v, m in zip(all_rows, mask_bools)] + + return [ + LuminescenceResult( + data=reshape_2d(masked, (8, 12)), + temperature=None, + timestamp=datetime.now(), + ) + ] + + +class _ByonoyLuminescenceReaderPlateHolder(PlateHolder): + """Plate holder with interlock: blocks drops while reader unit is on the base.""" + + def __init__( + self, + name: str, + child_location: Coordinate = Coordinate.zero(), + category: str = "plate_holder", + model: Optional[str] = None, + ): + super().__init__( + name=name, + size_x=127.76, + size_y=85.59, + size_z=0, + pedestal_size_z=0, + child_location=child_location, + category=category, + model=model, + ) + self._byonoy_base: Optional[ByonoyLuminescenceBaseUnit] = None + + def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: + if self._byonoy_base is None: + raise RuntimeError( + "Plate holder not assigned to a ByonoyLuminescenceBaseUnit. This should not happen." + ) + if self._byonoy_base.reader_unit_holder.resource is not None: + raise RuntimeError( + f"Cannot drop resource {resource.name} onto plate holder while reader unit is on " + "the base. Please remove the reader unit from the base before dropping a resource." + ) + super().check_can_drop_resource_here(resource, reassign=reassign) + + +class ByonoyLuminescenceBaseUnit(Resource): + """Base unit for the Byonoy L96/L96A luminescence reader.""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + plate_holder_child_location: Coordinate, + reader_unit_holder_child_location: Coordinate, + rotation: Optional[Rotation] = None, + category: Optional[str] = None, + model: Optional[str] = "Byonoy L96 Reader Unit", + barcode: Optional[Barcode] = None, + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + barcode=barcode, + ) + + self.plate_holder = _ByonoyLuminescenceReaderPlateHolder( + name=self.name + "_plate_holder", + child_location=plate_holder_child_location, + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + self.reader_unit_holder = ResourceHolder( + name=self.name + "_reader_unit_holder", + size_x=size_x, + size_y=size_y, + size_z=0, + child_location=reader_unit_holder_child_location, + ) + self.assign_child_resource(self.reader_unit_holder, location=Coordinate.zero()) + + def assign_child_resource( + self, resource: Resource, location: Optional[Coordinate], reassign: bool = True + ) -> None: + if isinstance(resource, _ByonoyLuminescenceReaderPlateHolder): + if self.plate_holder._byonoy_base is not None: + raise ValueError("ByonoyDriver can only have one plate holder assigned.") + self.plate_holder._byonoy_base = self + super().assign_child_resource(resource, location, reassign) + + def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: + raise RuntimeError( + "ByonoyDriver does not support assigning child resources directly. " + "Use the plate_holder or reader_unit_holder to assign plates and the reader unit, " + "respectively." + ) + + def serialize(self) -> dict: + return {**Resource.serialize(self)} + + +# --------------------------------------------------------------------------- +# Factory functions +# --------------------------------------------------------------------------- + + +def byonoy_l96_reader_unit(name: str) -> ByonoyLuminescence96: + """Create a Byonoy L96 reader unit (non-automate, no preferred pickup).""" + return ByonoyLuminescence96( + name=name, + size_x=139.7, + size_y=97.5, + size_z=35, + preferred_pickup_location=None, + ) + + +def byonoy_l96_base_unit(name: str) -> ByonoyLuminescenceBaseUnit: + """Create a Byonoy L96 base unit.""" + return ByonoyLuminescenceBaseUnit( + name=name, + size_x=139.7, + size_y=97.5, + size_z=9.4, + plate_holder_child_location=Coordinate(x=6.25, y=6.1, z=2.64), + reader_unit_holder_child_location=Coordinate(x=0, y=0, z=7.2), + ) + + +def byonoy_l96( + name: str, assign: bool = True +) -> Tuple[ByonoyLuminescenceBaseUnit, ByonoyLuminescence96]: + """Create a full Byonoy L96 setup (base + reader).""" + base_unit = byonoy_l96_base_unit(name=name + "_base") + reader_unit = byonoy_l96_reader_unit(name=name + "_reader") + if assign: + base_unit.reader_unit_holder.assign_child_resource(reader_unit) + return base_unit, reader_unit + + +def byonoy_l96a_reader_unit(name: str) -> ByonoyLuminescence96: + """Create a Byonoy L96A reader unit (automate, with preferred pickup).""" + return ByonoyLuminescence96( + name=name, + size_x=138, + size_y=97.5, + size_z=41.7, + preferred_pickup_location=Coordinate(x=69, y=48.75, z=33.2), + ) + + +def byonoy_l96a_base_unit(name: str) -> ByonoyLuminescenceBaseUnit: + """Create a Byonoy L96A base unit.""" + return ByonoyLuminescenceBaseUnit( + name=name, + size_x=138, + size_y=97.5, + size_z=10.7, + plate_holder_child_location=Coordinate(x=5.1, y=4.75, z=8), + reader_unit_holder_child_location=Coordinate(x=0, y=0, z=6.3), + ) + + +def byonoy_l96a( + name: str, assign: bool = True +) -> Tuple[ByonoyLuminescenceBaseUnit, ByonoyLuminescence96]: + """Create a full Byonoy L96A setup (base + reader).""" + base_unit = byonoy_l96a_base_unit(name=name + "_base") + reader_unit = byonoy_l96a_reader_unit(name=name + "_reader") + if assign: + base_unit.reader_unit_holder.assign_child_resource(reader_unit) + return base_unit, reader_unit diff --git a/pylabrobot/byonoy/results.py b/pylabrobot/byonoy/results.py new file mode 100644 index 00000000000..1bab90c9c9a --- /dev/null +++ b/pylabrobot/byonoy/results.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import List, Optional + + +@dataclass +class AbsorbanceResult: + """Result of an absorbance measurement. + + Attributes: + data: 2D array indexed [row][col]. ``None`` for unmeasured wells. + wavelength: Wavelength in nm. + temperature: Temperature in degrees C, or ``None`` if not available. + timestamp: When the measurement was taken. + """ + + data: List[List[Optional[float]]] + wavelength: int + temperature: Optional[float] + timestamp: datetime + + +@dataclass +class LuminescenceResult: + """Result of a luminescence measurement. + + Attributes: + data: 2D array indexed [row][col]. ``None`` for unmeasured wells. + temperature: Temperature in degrees C, or ``None`` if not available. + timestamp: When the measurement was taken. + """ + + data: List[List[Optional[float]]] + temperature: Optional[float] + timestamp: datetime diff --git a/pylabrobot/centrifuge/__init__.py b/pylabrobot/centrifuge/__init__.py index f910c01a64e..0d09060a8eb 100644 --- a/pylabrobot/centrifuge/__init__.py +++ b/pylabrobot/centrifuge/__init__.py @@ -1,17 +1,9 @@ -from .access2 import Access2 -from .centrifuge import Centrifuge, Loader -from .highres import ( - MicroSpin, - MicroSpinAbortedError, - MicroSpinBackend, - MicroSpinError, - MicroSpinProtocolError, +import warnings + +warnings.warn( + "Importing from pylabrobot.centrifuge is deprecated. Use pylabrobot.legacy.centrifuge instead.", + DeprecationWarning, + stacklevel=2, ) -from .standard import ( - BucketHasPlateError, - BucketNoPlateError, - CentrifugeDoorError, - LoaderNoPlateError, - NotAtBucketError, -) -from .vspin_backend import Access2Backend, VSpinBackend + +from pylabrobot.legacy.centrifuge import * # noqa: F401,F403,E402 diff --git a/pylabrobot/cole_parmer/__init__.py b/pylabrobot/cole_parmer/__init__.py new file mode 100644 index 00000000000..6d1cc25e954 --- /dev/null +++ b/pylabrobot/cole_parmer/__init__.py @@ -0,0 +1 @@ +from .masterflex import Masterflex diff --git a/pylabrobot/cole_parmer/masterflex.py b/pylabrobot/cole_parmer/masterflex.py new file mode 100644 index 00000000000..b005b46bf1c --- /dev/null +++ b/pylabrobot/cole_parmer/masterflex.py @@ -0,0 +1,92 @@ +import logging + +from pylabrobot.io.serial import Serial + +try: + import serial # type: ignore + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e + +logger = logging.getLogger(__name__) + + +class Masterflex: + """Serial driver for Cole Parmer Masterflex L/S pumps. + + tested on: + 07551-20 + + should be same as: + 07522-20 + 07522-30 + 07551-30 + 07575-30 + 07575-40 + + Documentation available at: + - https://pim-resources.coleparmer.com/instruction-manual/a-1299-1127b-en.pdf + - https://web.archive.org/web/20210924061132/https://pim-resources.coleparmer.com/ + instruction-manual/a-1299-1127b-en.pdf + """ + + def __init__(self, com_port: str): + super().__init__() + if not HAS_SERIAL: + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) + self.com_port = com_port + self.io = Serial( + port=self.com_port, + baudrate=4800, + timeout=1, + parity=serial.PARITY_ODD, + stopbits=serial.STOPBITS_ONE, + bytesize=serial.SEVENBITS, + human_readable_device_name="Masterflex Pump", + ) + + async def setup(self): + await self.io.setup() + await self.io.write(b"\x05") # Enquiry; ready to send. + await self.io.write(b"\x05P02\r") + logger.info("[Masterflex %s] connected", self.com_port) + + async def stop(self): + await self.io.stop() + logger.info("[Masterflex %s] disconnected", self.com_port) + + async def send_command(self, command: str) -> bytes: + command = "\x02P02" + command + "\x0d" + await self.io.write(command.encode()) + return await self.io.read() + + async def run_revolutions(self, num_revolutions: float): + num_revolutions = round(num_revolutions, 2) + logger.info("[Masterflex %s] dispensing %.2f revolutions", self.com_port, num_revolutions) + cmd = f"V{num_revolutions}G" + await self.send_command(cmd) + + async def run_continuously(self, speed: float): + if speed == 0: + await self.halt() + return + + logger.info( + "[Masterflex %s] pumping continuously at speed=%s direction=%s", + self.com_port, + abs(speed), + "forward" if speed > 0 else "reverse", + ) + direction = "+" if speed > 0 else "-" + speed_int = int(abs(speed)) + cmd = f"S{direction}{speed_int}G0" + await self.send_command(cmd) + + async def halt(self): + logger.info("[Masterflex %s] halting", self.com_port) + await self.send_command("H") diff --git a/pylabrobot/curiox/__init__.py b/pylabrobot/curiox/__init__.py new file mode 100644 index 00000000000..6f87ef06aab --- /dev/null +++ b/pylabrobot/curiox/__init__.py @@ -0,0 +1,9 @@ +from .ht2000 import ( + CurioxHT2000, + CurioxHT2000Error, + HT2000Mode, + HT2000Status, + PrimeMode, + StatusReport, + TrayPosition, +) diff --git a/pylabrobot/curiox/ht2000.py b/pylabrobot/curiox/ht2000.py new file mode 100644 index 00000000000..7b276018b49 --- /dev/null +++ b/pylabrobot/curiox/ht2000.py @@ -0,0 +1,419 @@ +import asyncio +import logging +from typing import Literal, Optional, Tuple + +from pylabrobot.io.serial import Serial + +logger = logging.getLogger(__name__) + +__all__ = [ + "CurioxHT2000", + "CurioxHT2000Error", + "HT2000Status", + "HT2000Mode", + "TrayPosition", + "PrimeMode", + "StatusReport", +] + + +# Frame markers for the binary command envelope. +PREAMBLE = bytes([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01]) +TRAILER = 0xFF + +# Fixed reply lengths, in bytes. +PING_REPLY_LENGTH = 15 +ACK_REPLY_LENGTH = 11 +REPORT_REPLY_LENGTH = 42 + +# Byte offsets of the status digit within each reply. +PING_STATUS_OFFSET = 9 +ACK_STATUS_OFFSET = 7 +REPORT_STATUS_OFFSET = 9 + +# Trailer appended to a wash-parameter upload after the caller-set fields. These +# are fixed values the firmware expects for the remaining wash parameters. +WASH_PARAM_TRAILER = "025060090000012" + +MIN_WASH_NUMBER = 1 +MAX_WASH_NUMBER = 19 +MIN_INITIAL_VOLUME = 0 +MAX_INITIAL_VOLUME = 99 +MIN_FLOW_RATE = 2 +MAX_FLOW_RATE = 20 + +# Error codes returned in the status reply (two ASCII digits). +DEVICE_ERRORS = { + "01": "No plate.", + "02": "Cannot retract.", + "11": "Head home error.", + "12": "Upper-right home error.", + "13": "Upper-left home error.", + "14": "Lower-right home error.", + "15": "Lower-left home error.", + "16": "Plate-feeder home error.", + "21": "Load-plate error.", + "22": "Head home-signal error.", + "23": "Plate-feeder home error.", + "24": "Upper-block home error.", + "25": "Lower-block home error.", + "31": "Plate-feeder home error.", + "41": "Back-lash limit error.", + "42": "Back-lash limit error.", + "43": "Back-lash limit error.", + "44": "Back-lash limit error.", +} + + +# Instrument status, decoded from the status digit of a reply. +HT2000Status = Literal["ready", "busy", "error", "stopped"] +STATUS_BY_DIGIT: Tuple[HT2000Status, ...] = ("ready", "busy", "error", "stopped") + +# Operating mode of the instrument. +HT2000Mode = Literal["operation", "service"] + +# Plate-tray position. +TrayPosition = Literal["in", "out"] + + +# Fluidics prime routine, mapped to its command code. +PrimeMode = Literal["standard", "head", "pump", "short"] +PRIME_COMMANDS = {"standard": "221", "head": "222", "pump": "223", "short": "224"} + + +class Command: + """ASCII command payloads.""" + + PING = "0" + ENQUIRE_REPORT = "4" + RECOVER_HOME = "201" + SWITCH_MODE = "202" + STANDARD_WASH = "210" + TOGGLE_TRAY = "211" + DRAIN_ASPIRATOR = "231" + DRAIN_SPILL_TRAY = "232" + + +class StatusReport: + """Decoded fields of the enquire-report reply.""" + + def __init__( + self, + mode: HT2000Mode, + status: HT2000Status, + error: Optional[str], + tray_position: TrayPosition, + spill_tray_active: bool, + tray_loaded: bool, + ) -> None: + self.mode = mode + self.status = status + self.error = error + self.tray_position = tray_position + self.spill_tray_active = spill_tray_active + self.tray_loaded = tray_loaded + + def __repr__(self) -> str: + return ( + f"StatusReport(mode={self.mode!r}, status={self.status!r}, " + f"error={self.error!r}, tray_position={self.tray_position!r}, " + f"spill_tray_active={self.spill_tray_active}, tray_loaded={self.tray_loaded})" + ) + + +class CurioxHT2000Error(Exception): + """Exceptions raised by a Curiox HT2000.""" + + def __init__(self, title: str, message: Optional[str] = None) -> None: + self.title = title + self.message = message + + def __str__(self) -> str: + return f"{self.title}: {self.message}" if self.message else self.title + + +class CurioxHT2000: + """Curiox Laminar Wash HT2000 system. + + A benchtop Laminar Wash station for Curiox DropArray plates. Commands are + ASCII payloads wrapped in a fixed binary frame; replies are fixed-length and + carry a status digit (0 ready, 1 busy, 2 error, 3 stopped). + + Serial settings: + 115200 baud, 8 data bits, no parity, 1 stop bit, no handshake. + + Frame layout of a command: + FF FF FF FF FF 01 | | | | FF + + where is the payload length and the checksum is the low 16 bits of + (sum of payload bytes + length), sent high byte first. + + Commands: + + :: + + 0 ping / status (15-byte reply) + 4 enquire report (42-byte reply) + 201 recover home + 202 switch from service to operation mode + 210 standard wash + 211 toggle tray + 221/222/223/224 prime: standard / head / pump / short + 231 drain aspirator + 232 drain spill tray + + Every action other than ping and enquire-report returns an 11-byte acknowledgement. + + Not verified: has NOT been tested against hardware in PyLabRobot. A warning is + emitted at setup. + """ + + def __init__( + self, + port: str, + timeout: float = 10.0, + wash_start_settle: float = 8.0, + prime_settle: float = 8.0, + mode_switch_settle: float = 3.0, + set_settle: float = 2.0, + poll_interval: float = 3.0, + ) -> None: + self.wash_start_settle = wash_start_settle + self.prime_settle = prime_settle + self.mode_switch_settle = mode_switch_settle + self.set_settle = set_settle + self.poll_interval = poll_interval + self.io = Serial( + human_readable_device_name="Curiox HT2000", + port=port, + baudrate=115200, + bytesize=8, + parity="N", + stopbits=1, + timeout=timeout, + write_timeout=timeout, + ) + + async def setup(self) -> None: + logger.warning( + "CurioxHT2000 has NOT been tested against hardware in PyLabRobot. " + "Please make a PR to remove this message if you have verified it on your hardware." + ) + await self.io.setup() + status, _mode, error = await self.ping() + if status == "error": + raise CurioxHT2000Error(title="HT2000 reported an error on connect", message=error) + logger.info("[HT2000 %s] connected: status=%s", self.io.port, status) + + async def stop(self) -> None: + """Close the serial connection.""" + await self.io.stop() + + # === Frame layer === + + @staticmethod + def _build_frame(payload: str) -> bytes: + body = payload.encode("ascii") + checksum = (sum(body) + len(body)) & 0xFFFF + return bytes([*PREAMBLE, len(body), *body, (checksum >> 8) & 0xFF, checksum & 0xFF, TRAILER]) + + async def _send_command(self, payload: str) -> None: + await self.io.write(self._build_frame(payload)) + logger.debug("[HT2000] send: %s", payload) + + async def _read_exact(self, length: int) -> bytes: + """Read exactly ``length`` bytes, or raise if the device falls short.""" + buf = bytearray() + while len(buf) < length: + chunk = await self.io.read(length - len(buf)) + if not chunk: + break + buf += chunk + if len(buf) != length: + raise CurioxHT2000Error( + title="No acknowledgement from HT2000", + message=f"expected {length} bytes, got {len(buf)}", + ) + return bytes(buf) + + @staticmethod + def _decode_status(digit: int) -> HT2000Status: + return STATUS_BY_DIGIT[digit - ord("0")] + + @staticmethod + def _decode_mode(byte: int) -> HT2000Mode: + return "operation" if byte == ord("0") else "service" + + async def _send_and_ack(self, payload: str) -> None: + """Send a command and consume its acknowledgement, raising on an error status.""" + await self._send_command(payload) + reply = await self._read_exact(ACK_REPLY_LENGTH) + status = self._decode_status(reply[ACK_STATUS_OFFSET]) + if status == "error": + raise CurioxHT2000Error(title=f"Command {payload} was rejected") + + # === Status === + + async def ping(self) -> tuple: + """Poll status. Returns ``(status, mode, error)``; error is a code string or None.""" + await self._send_command(Command.PING) + reply = await self._read_exact(PING_REPLY_LENGTH) + mode = self._decode_mode(reply[8]) + status = self._decode_status(reply[PING_STATUS_OFFSET]) + error = None + if status == "error": + code = reply[10:12].decode("ascii", errors="replace") + error = DEVICE_ERRORS.get(code, code) + return status, mode, error + + async def enquire_report(self) -> StatusReport: + """Read the full status report (mode, status, tray state).""" + await self._send_command(Command.ENQUIRE_REPORT) + reply = await self._read_exact(REPORT_REPLY_LENGTH) + mode = self._decode_mode(reply[8]) + status = self._decode_status(reply[REPORT_STATUS_OFFSET]) + error = None + if status == "error": + code = reply[10:12].decode("ascii", errors="replace") + error = DEVICE_ERRORS.get(code, code) + return StatusReport( + mode=mode, + status=status, + error=error, + tray_position="in" if reply[12] == ord("0") else "out", + spill_tray_active=reply[13] != ord("0"), + tray_loaded=reply[14] != ord("0"), + ) + + # === Operations === + + async def home(self) -> None: + """Recover home.""" + await self._send_and_ack(Command.RECOVER_HOME) + logger.info("[HT2000 %s] homed", self.io.port) + + async def move_tray_out(self) -> None: + """Move the tray out. Idempotent: a no-op if it is already out.""" + await self._move_tray("out") + + async def move_tray_in(self) -> None: + """Move the tray in. Idempotent: a no-op if it is already in.""" + await self._move_tray("in") + + async def _toggle_tray(self) -> None: + """Move the tray to the opposite position (the raw, non-idempotent primitive).""" + await self._send_and_ack(Command.TOGGLE_TRAY) + + async def _move_tray(self, target: TrayPosition) -> None: + if (await self.enquire_report()).tray_position == target: + return + await self._toggle_tray() + reached = (await self.enquire_report()).tray_position + if reached != target: + raise CurioxHT2000Error( + title="Tray did not reach the target position", + message=f"wanted {target}, got {reached}", + ) + + async def drain_aspirator(self) -> None: + """Drain the aspirator.""" + await self._send_and_ack(Command.DRAIN_ASPIRATOR) + + async def drain_spill_tray(self) -> None: + """Drain the spill tray.""" + await self._send_and_ack(Command.DRAIN_SPILL_TRAY) + + async def prime(self, mode: PrimeMode = "standard") -> None: + """Run a fluidics prime routine and wait for it to settle. + + Args: + mode: prime routine, one of "standard", "head", "pump", or "short". + """ + if mode not in PRIME_COMMANDS: + raise ValueError(f"mode must be one of {sorted(PRIME_COMMANDS)}") + await self._send_and_ack(PRIME_COMMANDS[mode]) + await asyncio.sleep(self.prime_settle) + logger.info("[HT2000 %s] primed (%s)", self.io.port, mode) + + async def _set_wash_parameters( + self, wash_number: int, initial_volume: int, flow_rate: int, channel: int + ) -> None: + if not MIN_WASH_NUMBER <= wash_number <= MAX_WASH_NUMBER: + raise ValueError(f"wash_number must be {MIN_WASH_NUMBER}..{MAX_WASH_NUMBER}") + if not MIN_INITIAL_VOLUME <= initial_volume <= MAX_INITIAL_VOLUME: + raise ValueError(f"initial_volume must be {MIN_INITIAL_VOLUME}..{MAX_INITIAL_VOLUME}") + if not MIN_FLOW_RATE <= flow_rate <= MAX_FLOW_RATE: + raise ValueError(f"flow_rate must be {MIN_FLOW_RATE}..{MAX_FLOW_RATE}") + if not (0 <= channel <= 5 or 11 <= channel <= 15): + raise ValueError("channel must be 0..5 or 11..15") + payload = ( + "1" + + f"{wash_number:02d}" + + f"{initial_volume:03d}" + + f"{flow_rate:02d}" + + f"{channel:02d}" + + WASH_PARAM_TRAILER + ) + await self._send_and_ack(payload) + + async def wash( + self, + wash_number: int = 3, + initial_volume: int = 50, + flow_rate: int = 5, + channel: int = 0, + max_operation_duration: float = 300.0, + ) -> None: + """Upload wash parameters and run a wash cycle. + + Switches to operation mode if needed, applies the parameters, starts the + wash, then polls until the wash cycle completes (the instrument finishes + washing and returns to ready). + + Args: + wash_number: number of wash repeats (1..19). + initial_volume: initial volume in device units (0..99). + flow_rate: flow rate in device units (2..20). + channel: fluid channel to use (0..5 or 11..15). + max_operation_duration: seconds to wait for the wash to complete before + raising a timeout. + """ + _status, mode, _error = await self.ping() + if mode == "service": + await self._send_and_ack(Command.SWITCH_MODE) + await asyncio.sleep(self.mode_switch_settle) + + await self._set_wash_parameters(wash_number, initial_volume, flow_rate, channel) + await asyncio.sleep(self.set_settle) + await self._send_and_ack(Command.STANDARD_WASH) + await asyncio.sleep(self.wash_start_settle) + logger.info("[HT2000 %s] washing (n=%d)", self.io.port, wash_number) + await self._wait_for_wash(max_operation_duration) + + async def _wait_for_wash(self, max_operation_duration: float) -> None: + """Poll the report until the wash cycle completes, or raise on error/timeout. + + Waits for the instrument to reach the running state (operation mode, busy, + tray out, plate loaded) and then for it to return to ready, which marks the + end of the cycle. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + max_operation_duration + started = False + while loop.time() < deadline: + report = await self.enquire_report() + if report.status == "stopped": + raise CurioxHT2000Error(title="HT2000 stopped during wash") + if not report.tray_loaded: + raise CurioxHT2000Error(title="No plate on the tray") + if report.status == "error": + raise CurioxHT2000Error(title="HT2000 error during wash", message=report.error) + running = ( + report.mode == "operation" and report.status == "busy" and report.tray_position == "out" + ) + if running: + started = True + elif started and report.status == "ready": + return + await asyncio.sleep(self.poll_interval) + raise CurioxHT2000Error(title="Timed out waiting for the wash to complete") diff --git a/pylabrobot/curiox/ht2000_tests.py b/pylabrobot/curiox/ht2000_tests.py new file mode 100644 index 00000000000..2c365650be2 --- /dev/null +++ b/pylabrobot/curiox/ht2000_tests.py @@ -0,0 +1,239 @@ +import unittest +from typing import List, cast +from unittest.mock import AsyncMock, patch + +from pylabrobot.curiox.ht2000 import CurioxHT2000 +from pylabrobot.io.serial import Serial + + +def ping_reply(mode: str = "0", status: str = "0", error: str = "00") -> bytes: + """A 15-byte ping reply: mode at [8], status at [9], error code at [10:12].""" + buf = bytearray(b"\x00" * 15) + buf[8] = ord(mode) + buf[9] = ord(status) + buf[10:12] = error.encode("ascii") + return bytes(buf) + + +def ack_reply(status: str = "0") -> bytes: + """An 11-byte acknowledgement with the status digit at [7].""" + buf = bytearray(b"\x00" * 11) + buf[7] = ord(status) + return bytes(buf) + + +def report_reply( + mode: str = "0", + status: str = "1", + tray_out: bool = True, + loaded: bool = True, +) -> bytes: + """A 42-byte report: mode [8], status [9], tray [12], spill [13], load [14].""" + buf = bytearray(b"\x00" * 42) + buf[8] = ord(mode) + buf[9] = ord(status) + buf[12] = ord("1" if tray_out else "0") + buf[14] = ord("1" if loaded else "0") + return bytes(buf) + + +def make_device(replies: List[bytes]) -> CurioxHT2000: + """Build a device whose ``io`` is an AsyncMock replaying one reply per write. + + Writes are recorded by the mock itself, so assert against ``device.io.write`` + (``assert_any_await``, ``await_count``, ``call_args_list``). + """ + io = AsyncMock(spec=Serial) + io.port = "FAKE" + + rx = bytearray() + pending = list(replies) + + async def write(data: bytes) -> None: + if pending: + rx.extend(pending.pop(0)) + + async def read(num_bytes: int = 1) -> bytes: + out = bytes(rx[:num_bytes]) + del rx[:num_bytes] + return out + + io.write.side_effect = write + io.read.side_effect = read + + with patch("pylabrobot.curiox.ht2000.Serial", return_value=io): + device = CurioxHT2000( + port="FAKE", + wash_start_settle=0, + prime_settle=0, + mode_switch_settle=0, + set_settle=0, + poll_interval=0, + ) + return device + + +def writes(device: CurioxHT2000) -> AsyncMock: + """The mock standing in for ``device.io.write``.""" + return cast(AsyncMock, device.io.write) + + +def written_payloads(device: CurioxHT2000) -> List[str]: + """The ASCII payload of every frame written, in order.""" + return [call.args[0][7:-3].decode("ascii") for call in writes(device).call_args_list] + + +class HT2000FrameTests(unittest.TestCase): + def test_build_frame_ping(self): + # "0": len 1, checksum = (0x30 + 1) = 0x0031. + self.assertEqual( + CurioxHT2000._build_frame("0"), + bytes([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0x01, 0x30, 0x00, 0x31, 0xFF]), + ) + + def test_build_frame_wash(self): + # "210": len 3, checksum = (0x32 + 0x31 + 0x30 + 3) = 0x0096. + self.assertEqual( + CurioxHT2000._build_frame("210"), + bytes([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0x03, 0x32, 0x31, 0x30, 0x00, 0x96, 0xFF]), + ) + + +class HT2000ProtocolTests(unittest.IsolatedAsyncioTestCase): + async def test_ping_ready(self): + device = make_device([ping_reply(mode="0", status="0")]) + await device.io.setup() + status, mode, error = await device.ping() + self.assertEqual(status, "ready") + self.assertEqual(mode, "operation") + self.assertIsNone(error) + + async def test_ping_error_decodes_code(self): + device = make_device([ping_reply(status="2", error="01")]) + await device.io.setup() + status, _mode, error = await device.ping() + self.assertEqual(status, "error") + self.assertEqual(error, "No plate.") + + async def test_wash_uploads_parameters_and_runs(self): + device = make_device( + [ + ping_reply(mode="0", status="0"), # ping: operation mode + ack_reply(), # set parameters + ack_reply(), # standard wash + report_reply(status="1", tray_out=True, loaded=True), # running + report_reply(status="0", tray_out=True, loaded=True), # complete (ready) + ] + ) + await device.io.setup() + await device.wash(wash_number=5, initial_volume=40, flow_rate=8, channel=2) + + # The parameter upload is "1" + wash + vol + flow + channel + trailer. + params = "1" + "05" + "040" + "08" + "02" + "025060090000012" + writes(device).assert_any_await(CurioxHT2000._build_frame(params)) + # ping, parameter upload, then the standard-wash command. + self.assertEqual(written_payloads(device)[:3], ["0", params, "210"]) + + async def test_wash_waits_for_cycle_completion(self): + device = make_device( + [ + ping_reply(mode="0", status="0"), + ack_reply(), # set parameters + ack_reply(), # standard wash + report_reply(status="1", tray_out=True, loaded=True), # running + report_reply(status="1", tray_out=True, loaded=True), # still running + report_reply(status="0", tray_out=True, loaded=True), # complete + ] + ) + await device.io.setup() + await device.wash() + # ping + set + wash + three enquire polls. + self.assertEqual(writes(device).await_count, 6) + self.assertEqual(written_payloads(device)[2:], ["210", "4", "4", "4"]) + + async def test_wait_for_wash_raises_on_missing_plate(self): + device = make_device( + [ + ping_reply(mode="0", status="0"), + ack_reply(), + ack_reply(), + report_reply(status="1", tray_out=True, loaded=False), # no plate + ] + ) + await device.io.setup() + with self.assertRaises(Exception): + await device.wash() + + async def test_move_tray_out_when_already_out_is_noop(self): + device = make_device([report_reply(tray_out=True, loaded=True)]) + await device.io.setup() + await device.move_tray_out() + # Only the status read; no toggle command issued. + writes(device).assert_awaited_once_with(CurioxHT2000._build_frame("4")) + + async def test_move_tray_out_toggles_when_in(self): + device = make_device( + [ + report_reply(tray_out=False, loaded=True), # currently in + ack_reply(), # toggle + report_reply(tray_out=True, loaded=True), # confirm out + ] + ) + await device.io.setup() + await device.move_tray_out() + # read + toggle + confirm read. + self.assertEqual(written_payloads(device), ["4", "211", "4"]) + + async def test_move_tray_in_toggles_when_out(self): + device = make_device( + [ + report_reply(tray_out=True, loaded=True), # currently out + ack_reply(), # toggle + report_reply(tray_out=False, loaded=True), # confirm in + ] + ) + await device.io.setup() + await device.move_tray_in() + self.assertEqual(written_payloads(device), ["4", "211", "4"]) + + async def test_move_tray_raises_if_position_not_reached(self): + device = make_device( + [ + report_reply(tray_out=False, loaded=True), # currently in + ack_reply(), # toggle + report_reply(tray_out=False, loaded=True), # still in -> failed + ] + ) + await device.io.setup() + with self.assertRaises(Exception): + await device.move_tray_out() + + async def test_enquire_report_decodes_tray(self): + device = make_device([report_reply(status="1", tray_out=False, loaded=True)]) + await device.io.setup() + report = await device.enquire_report() + self.assertEqual(report.tray_position, "in") + self.assertTrue(report.tray_loaded) + + def test_wash_parameter_validation(self): + device = make_device([]) + for kwargs in ( + {"wash_number": 0}, + {"wash_number": 20}, + {"initial_volume": 100}, + {"flow_rate": 1}, + {"channel": 6}, + {"channel": 10}, + ): + with self.assertRaises(ValueError): + import asyncio + + asyncio.run( + device._set_wash_parameters( + **{"wash_number": 3, "initial_volume": 50, "flow_rate": 5, "channel": 0, **kwargs} + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/hamilton/__init__.py b/pylabrobot/hamilton/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/star/__init__.py b/pylabrobot/hamilton/star/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/star/lock.py b/pylabrobot/hamilton/star/lock.py new file mode 100644 index 00000000000..9fec9ad836e --- /dev/null +++ b/pylabrobot/hamilton/star/lock.py @@ -0,0 +1,76 @@ +import asyncio +import logging +from contextlib import AsyncExitStack, asynccontextmanager +from typing import Optional + +logger = logging.getLogger(__name__) + + +class _FirmwareLock: + """Coordinates firmware commands across modules. + + Two layers, both handled by ``slave_module(module)`` / ``c0()`` so callers never touch the + internals: + + * A readers/writer gate between the C0 master and the slave modules. A slave-module + command blocks the C0 master while in flight; a C0 master command (``c0()``) is + *exclusive*: it waits for all slave-module commands to drain, then runs alone. So no + slave-module command ever overlaps a C0 master command. + + * A per-module mutex so at most one command per module is in flight. Different modules + still overlap — an X0 arm move can run alongside an H0 head move and Px channel commands + — but you never have two X0, two H0, etc. at once. Modules without a dedicated mutex are + gated but not per-module serialized. + + Read-only request (``R*``) and query (``Q*``) commands are not coordinated here at all — + they take no lock and run fully in parallel. + + The first slave-module command acquires the exclusive lock and the last one releases it, + so a C0 command simply takes the same lock and automatically waits the slaves out. + """ + + # Slave modules that serialize against themselves: H0 (96-head), X0 (X-drives), + # R0 (iSWAP), I0 (autoload). All Px pipetting channels (P1..PG) share one mutex. + _SERIALIZED_MODULES = ("H0", "X0", "R0", "I0") + + def __init__(self): + # Per-module mutexes: at most one in-flight command per module. + self._px_lock = asyncio.Lock() # shared by all P1..PG pipetting channels + self._module_locks = {module: asyncio.Lock() for module in self._SERIALIZED_MODULES} + + # Readers/writer gate: slave-module commands vs the exclusive C0 master. + self._slave_count = 0 + self._slave_count_lock = asyncio.Lock() + self._exclusive_lock = asyncio.Lock() + + def _module_lock(self, module: str) -> Optional[asyncio.Lock]: + """The per-module mutex for ``module``, or None if it needs no per-module serialization.""" + if module.startswith("P"): + return self._px_lock # P1..PG pipetting channels + return self._module_locks.get(module) + + @asynccontextmanager + async def slave_module(self, module: str): + """Run a slave-module command: serialize on its module mutex and block the C0 master.""" + module_lock = self._module_lock(module) + async with AsyncExitStack() as stack: + if module_lock is not None: + await stack.enter_async_context(module_lock) + # Join the slave group; first in acquires the exclusive lock, last out releases it. + async with self._slave_count_lock: + self._slave_count += 1 + if self._slave_count == 1: + await self._exclusive_lock.acquire() + try: + yield + finally: + async with self._slave_count_lock: + self._slave_count -= 1 + if self._slave_count == 0: + self._exclusive_lock.release() + + @asynccontextmanager + async def c0(self): + """Run an exclusive C0 master command. Waits for all slave commands, then runs alone.""" + async with self._exclusive_lock: + yield diff --git a/pylabrobot/hamilton/transport/tcp/__init__.py b/pylabrobot/hamilton/transport/tcp/__init__.py new file mode 100644 index 00000000000..78f60160c79 --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/__init__.py @@ -0,0 +1,24 @@ +"""Shared Hamilton TCP protocol layer for TCP-based instruments (Nimbus, Prep, etc.).""" + +from pylabrobot.hamilton.transport.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.transport.tcp.introspection import HamiltonIntrospection +from pylabrobot.hamilton.transport.tcp.messages import ( + CommandMessage, + CommandResponse, + HoiParams, + HoiParamsParser, + InitMessage, + InitResponse, + RegistrationMessage, + RegistrationResponse, +) +from pylabrobot.hamilton.transport.tcp.packets import Address, HarpPacket, HoiPacket, IpPacket +from pylabrobot.hamilton.transport.tcp.protocol import ( + HamiltonDataType, + HamiltonProtocol, + HarpTransportableProtocol, + Hoi2Action, + HoiRequestId, + RegistrationActionCode, + RegistrationOptionType, +) diff --git a/pylabrobot/hamilton/transport/tcp/commands.py b/pylabrobot/hamilton/transport/tcp/commands.py new file mode 100644 index 00000000000..0b8cd8c025c --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/commands.py @@ -0,0 +1,179 @@ +"""Hamilton command architecture using new simplified TCP stack. + +This module provides the HamiltonCommand base class that uses the new refactored +architecture: Wire -> HoiParams -> Packets -> Messages -> Commands. +""" + +from __future__ import annotations + +import inspect +from typing import Optional + +from pylabrobot.hamilton.transport.tcp.messages import ( + CommandMessage, + CommandResponse, + HoiParams, +) +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.hamilton.transport.tcp.protocol import HamiltonProtocol + + +class HamiltonCommand: + """Base class for Hamilton commands using new simplified architecture. + + This replaces the old HamiltonCommand from tcp_codec.py with a cleaner design: + - Explicitly uses CommandMessage for building packets + - build_parameters() returns HoiParams object (not bytes) + - Uses Address instead of ObjectAddress + - Cleaner separation of concerns + + Example: + class MyCommand(HamiltonCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 42 + + def __init__(self, dest: Address, value: int): + super().__init__(dest) + self.value = value + + def build_parameters(self) -> HoiParams: + return HoiParams().i32(self.value) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + parser = HoiParamsParser(data) + _, result = parser.parse_next() + return {'result': result} + """ + + # Class-level attributes that subclasses must override + protocol: Optional[HamiltonProtocol] = None + interface_id: Optional[int] = None + command_id: Optional[int] = None + + # Action configuration (can be overridden by subclasses) + action_code: int = 3 # Default: COMMAND_REQUEST + harp_protocol: int = 2 # Default: HOI2 + ip_protocol: int = 6 # Default: OBJECT_DISCOVERY + + def __init__(self, dest: Address): + """Initialize Hamilton command. + + Args: + dest: Destination address for this command + """ + if self.protocol is None: + raise ValueError(f"{self.__class__.__name__} must define protocol") + if self.interface_id is None: + raise ValueError(f"{self.__class__.__name__} must define interface_id") + if self.command_id is None: + raise ValueError(f"{self.__class__.__name__} must define command_id") + + self.dest = dest + self.dest_address = dest # Alias for compatibility + self.sequence_number = 0 + self.source_address: Optional[Address] = None + + def build_parameters(self) -> HoiParams: + """Build HOI parameters for this command. + + Override this method in subclasses to provide command-specific parameters. + Return a HoiParams object (not bytes!). + + Returns: + HoiParams object with command parameters + """ + return HoiParams() + + def get_log_params(self) -> dict: + """Get parameters to log for this command. + + Lazily computes the parameters by inspecting the __init__ signature + and reading current attribute values from self. + + Subclasses can override to customize formatting (e.g., unit conversions, + array truncation). + + Returns: + Dictionary of parameter names to values + """ + exclude = {"self", "dest"} + sig = inspect.signature(type(self).__init__) + params = {} + for param_name in sig.parameters: + if param_name not in exclude and hasattr(self, param_name): + params[param_name] = getattr(self, param_name) + return params + + def build( + self, src: Optional[Address] = None, seq: Optional[int] = None, response_required: bool = True + ) -> bytes: + """Build complete Hamilton message using CommandMessage. + + Args: + src: Source address (uses self.source_address if None) + seq: Sequence number (uses self.sequence_number if None) + response_required: Whether a response is expected + + Returns: + Complete packet bytes ready to send over TCP + """ + # Use instance attributes if not provided + source = src if src is not None else self.source_address + sequence = seq if seq is not None else self.sequence_number + + if source is None: + raise ValueError("Source address not set - backend should set this before building") + + # Ensure required attributes are set (they should be by subclasses) + if self.interface_id is None: + raise ValueError(f"{self.__class__.__name__} must define interface_id") + if self.command_id is None: + raise ValueError(f"{self.__class__.__name__} must define command_id") + + # Build parameters using command-specific logic + params = self.build_parameters() + + # Create CommandMessage and set parameters directly + # This avoids wasteful serialization/parsing round-trip + msg = CommandMessage( + dest=self.dest, + interface_id=self.interface_id, + method_id=self.command_id, + params=params, + action_code=self.action_code, + harp_protocol=self.harp_protocol, + ip_protocol=self.ip_protocol, + ) + + # Build final packet + return msg.build(source, sequence, harp_response_required=response_required) + + def interpret_response(self, response: CommandResponse) -> Optional[dict]: + """Interpret success response. + + This is the new interface used by the backend. Default implementation + directly calls parse_response_parameters for efficiency. + + Args: + response: CommandResponse from network + + Returns: + Dictionary with parsed response data, or None if no data to extract + """ + return self.parse_response_parameters(response.hoi.params) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> Optional[dict]: + """Parse response parameters from HOI payload. + + Override this method in subclasses to parse command-specific responses. + + Args: + data: Raw bytes from HOI fragments field + + Returns: + Dictionary with parsed response data, or None if no data to extract + """ + return None diff --git a/pylabrobot/hamilton/transport/tcp/introspection.py b/pylabrobot/hamilton/transport/tcp/introspection.py new file mode 100644 index 00000000000..4a5fff0bbfd --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/introspection.py @@ -0,0 +1,832 @@ +"""Hamilton TCP Introspection API. + +This module provides dynamic discovery of Hamilton instrument capabilities +using Interface 0 introspection methods. It allows discovering available +objects, methods, interfaces, enums, and structs at runtime. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List + +from pylabrobot.hamilton.transport.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.transport.tcp.messages import ( + HoiParams, + HoiParamsParser, +) +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.hamilton.transport.tcp.protocol import ( + HamiltonDataType, + HamiltonProtocol, +) + +logger = logging.getLogger(__name__) + +# ============================================================================ +# TYPE RESOLUTION HELPERS +# ============================================================================ + + +def resolve_type_id(type_id: int) -> str: + """Resolve Hamilton type ID to readable name. + + Args: + type_id: Hamilton data type ID + + Returns: + Human-readable type name + """ + try: + return HamiltonDataType(type_id).name + except ValueError: + return f"UNKNOWN_TYPE_{type_id}" + + +def resolve_type_ids(type_ids: List[int]) -> List[str]: + """Resolve list of Hamilton type IDs to readable names. + + Args: + type_ids: List of Hamilton data type IDs + + Returns: + List of human-readable type names + """ + return [resolve_type_id(tid) for tid in type_ids] + + +# ============================================================================ +# INTROSPECTION TYPE MAPPING +# ============================================================================ +# Introspection type IDs are separate from HamiltonDataType wire encoding types. +# These are used for method signature display/metadata, not binary encoding. + +# Type ID ranges for categorization: +# - Argument types: Method parameters (input) +# - ReturnElement types: Multiple return values (struct fields) +# - ReturnValue types: Single return value + +_INTROSPECTION_TYPE_NAMES: dict[int, str] = { + # Argument types (1-8, 33, 41, 45, 49, 53, 61, 66, 82, 102) + 1: "i8", + 2: "u8", + 3: "i16", + 4: "u16", + 5: "i32", + 6: "u32", + 7: "str", + 8: "bytes", + 33: "bool", + 41: "List[i16]", + 45: "List[u16]", + 49: "List[i32]", + 53: "List[u32]", + 61: "List[struct]", # Complex type, needs source_id + struct_id + 66: "List[bool]", + 82: "List[enum]", # Complex type, needs source_id + enum_id + 102: "f32", + # ReturnElement types (18-24, 35, 43, 47, 51, 55, 68, 76) + 18: "u8", + 19: "i16", + 20: "u16", + 21: "i32", + 22: "u32", + 23: "str", + 24: "bytes", + 35: "bool", + 43: "List[i16]", + 47: "List[u16]", + 51: "List[i32]", + 55: "List[u32]", + 68: "List[bool]", + 76: "List[str]", + # ReturnValue types (25-32, 36, 44, 48, 52, 56, 69, 81, 85, 104, 105) + 25: "i8", + 26: "u8", + 27: "i16", + 28: "u16", + 29: "i32", + 30: "u32", + 31: "str", + 32: "bytes", + 36: "bool", + 44: "List[i16]", + 48: "List[u16]", + 52: "List[i32]", + 56: "List[u32]", + 69: "List[bool]", + 81: "enum", # Complex type, needs source_id + enum_id + 85: "enum", # Complex type, needs source_id + enum_id + 104: "f32", + 105: "f32", + # Complex types (60, 64, 78) - these need source_id + id + 60: "struct", # ReturnValue, needs source_id + struct_id + 64: "struct", # ReturnValue, needs source_id + struct_id + 78: "enum", # Argument, needs source_id + enum_id +} + +# Type ID sets for categorization +_ARGUMENT_TYPE_IDS = {1, 2, 3, 4, 5, 6, 7, 8, 33, 41, 45, 49, 53, 61, 66, 82, 102} +_RETURN_ELEMENT_TYPE_IDS = {18, 19, 20, 21, 22, 23, 24, 35, 43, 47, 51, 55, 68, 76} +_RETURN_VALUE_TYPE_IDS = {25, 26, 27, 28, 29, 30, 31, 32, 36, 44, 48, 52, 56, 69, 81, 85, 104, 105} +_COMPLEX_TYPE_IDS = {60, 61, 64, 78, 81, 82, 85} # Types that need additional bytes + + +def get_introspection_type_category(type_id: int) -> str: + """Get category for introspection type ID. + + Args: + type_id: Introspection type ID + + Returns: + Category: "Argument", "ReturnElement", "ReturnValue", or "Unknown" + """ + if type_id in _ARGUMENT_TYPE_IDS: + return "Argument" + elif type_id in _RETURN_ELEMENT_TYPE_IDS: + return "ReturnElement" + elif type_id in _RETURN_VALUE_TYPE_IDS: + return "ReturnValue" + else: + return "Unknown" + + +def resolve_introspection_type_name(type_id: int) -> str: + """Resolve introspection type ID to readable name. + + Args: + type_id: Introspection type ID + + Returns: + Human-readable type name + """ + return _INTROSPECTION_TYPE_NAMES.get(type_id, f"UNKNOWN_TYPE_{type_id}") + + +def is_complex_introspection_type(type_id: int) -> bool: + """Check if introspection type is complex (needs additional bytes). + + Complex types require 3 bytes total: type_id, source_id, struct_id/enum_id + + Args: + type_id: Introspection type ID + + Returns: + True if type is complex + """ + return type_id in _COMPLEX_TYPE_IDS + + +# ============================================================================ +# DATA STRUCTURES +# ============================================================================ + + +@dataclass +class ObjectInfo: + """Object metadata from introspection.""" + + name: str + version: str + method_count: int + subobject_count: int + address: Address + + +@dataclass +class MethodInfo: + """Method signature from introspection.""" + + interface_id: int + call_type: int + method_id: int + name: str + parameter_types: list[int] = field( + default_factory=list + ) # Decoded parameter type IDs (Argument category) + parameter_labels: list[str] = field(default_factory=list) # Parameter names (if available) + return_types: list[int] = field( + default_factory=list + ) # Decoded return type IDs (ReturnElement/ReturnValue category) + return_labels: list[str] = field(default_factory=list) # Return names (if available) + + def get_signature_string(self) -> str: + """Get method signature as a readable string.""" + # Decode parameter types to readable names + if self.parameter_types: + param_type_names = [resolve_introspection_type_name(tid) for tid in self.parameter_types] + + # If we have labels, use them; otherwise just show types + if self.parameter_labels and len(self.parameter_labels) == len(param_type_names): + # Format as "param1: type1, param2: type2" + params = [ + f"{label}: {type_name}" + for label, type_name in zip(self.parameter_labels, param_type_names) + ] + param_str = ", ".join(params) + else: + # Just show types + param_str = ", ".join(param_type_names) + else: + param_str = "void" + + # Decode return types to readable names + if self.return_types: + return_type_names = [resolve_introspection_type_name(tid) for tid in self.return_types] + return_categories = [get_introspection_type_category(tid) for tid in self.return_types] + + # Format return based on category + if any(cat == "ReturnElement" for cat in return_categories): + # Multiple return values -> struct format + if self.return_labels and len(self.return_labels) == len(return_type_names): + # Format as "{ label1: type1, label2: type2 }" + returns = [ + f"{label}: {type_name}" + for label, type_name in zip(self.return_labels, return_type_names) + ] + return_str = f"{{ {', '.join(returns)} }}" + else: + # Just show types + return_str = f"{{ {', '.join(return_type_names)} }}" + elif len(return_type_names) == 1: + # Single return value + if self.return_labels and len(self.return_labels) == 1: + return_str = f"{self.return_labels[0]}: {return_type_names[0]}" + else: + return_str = return_type_names[0] + else: + return_str = "void" + else: + return_str = "void" + + return f"{self.name}({param_str}) -> {return_str}" + + +@dataclass +class InterfaceInfo: + """Interface metadata from introspection.""" + + interface_id: int + name: str + version: str + + +@dataclass +class EnumInfo: + """Enum definition from introspection.""" + + enum_id: int + name: str + values: Dict[str, int] + + +@dataclass +class StructInfo: + """Struct definition from introspection.""" + + struct_id: int + name: str + fields: Dict[str, int] # field_name -> type_id + + @property + def field_type_names(self) -> Dict[str, str]: + """Get human-readable field type names.""" + return {field_name: resolve_type_id(type_id) for field_name, type_id in self.fields.items()} + + def get_struct_string(self) -> str: + """Get struct definition as a readable string.""" + field_strs = [ + f"{field_name}: {resolve_type_id(type_id)}" for field_name, type_id in self.fields.items() + ] + fields_str = "\n ".join(field_strs) if field_strs else " (empty)" + return f"struct {self.name} {{\n {fields_str}\n}}" + + +# ============================================================================ +# INTROSPECTION COMMAND CLASSES +# ============================================================================ + + +class GetObjectCommand(HamiltonCommand): + """Get object metadata (command_id=1).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 1 + action_code = 0 # QUERY + + def __init__(self, object_address: Address): + super().__init__(object_address) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse get_object response.""" + # Parse HOI2 DataFragments + parser = HoiParamsParser(data) + + _, name = parser.parse_next() + _, version = parser.parse_next() + _, method_count = parser.parse_next() + _, subobject_count = parser.parse_next() + + return { + "name": name, + "version": version, + "method_count": method_count, + "subobject_count": subobject_count, + } + + +class GetMethodCommand(HamiltonCommand): + """Get method signature (command_id=2).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 2 + action_code = 0 # QUERY + + def __init__(self, object_address: Address, method_index: int): + super().__init__(object_address) + self.method_index = method_index + + def build_parameters(self) -> HoiParams: + """Build parameters for get_method command.""" + return HoiParams().u32(self.method_index) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse get_method response.""" + parser = HoiParamsParser(data) + + _, interface_id = parser.parse_next() + _, call_type = parser.parse_next() + _, method_id = parser.parse_next() + _, name = parser.parse_next() + + # The remaining fragments are STRING types containing type IDs as bytes + # Hamilton sends ONE combined list where type IDs encode category (Argument/ReturnElement/ReturnValue) + # First STRING after method name is parameter_types (each byte is a type ID - can be Argument or Return) + # Second STRING (if present) is parameter_labels (comma-separated names - includes both params and returns) + parameter_types_str = None + parameter_labels_str = None + + if parser.has_remaining(): + _, parameter_types_str = parser.parse_next() + + if parser.has_remaining(): + _, parameter_labels_str = parser.parse_next() + + # Decode string bytes to type IDs (like piglet does: .as_bytes().to_vec()) + all_type_ids: list[int] = [] + if parameter_types_str: + all_type_ids = [ord(c) for c in parameter_types_str] + + # Parse all labels (comma-separated - includes both parameters and returns) + all_labels: list[str] = [] + if parameter_labels_str: + all_labels = [label.strip() for label in parameter_labels_str.split(",") if label.strip()] + + # Categorize by type ID ranges (like piglet does) + # Split into arguments vs returns based on type ID category + parameter_types: list[int] = [] + parameter_labels: list[str] = [] + return_types: list[int] = [] + return_labels: list[str] = [] + + for i, type_id in enumerate(all_type_ids): + category = get_introspection_type_category(type_id) + label = all_labels[i] if i < len(all_labels) else None + + if category == "Argument": + parameter_types.append(type_id) + if label: + parameter_labels.append(label) + elif category in ("ReturnElement", "ReturnValue"): + return_types.append(type_id) + if label: + return_labels.append(label) + # Unknown types - could be parameters or returns, default to parameters + else: + parameter_types.append(type_id) + if label: + parameter_labels.append(label) + + return { + "interface_id": interface_id, + "call_type": call_type, + "method_id": method_id, + "name": name, + "parameter_types": parameter_types, # Decoded type IDs (Argument category only) + "parameter_labels": parameter_labels, # Parameter names only + "return_types": return_types, # Decoded type IDs (ReturnElement/ReturnValue only) + "return_labels": return_labels, # Return names only + } + + +class GetSubobjectAddressCommand(HamiltonCommand): + """Get subobject address (command_id=3).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 3 + action_code = 0 # QUERY + + def __init__(self, object_address: Address, subobject_index: int): + super().__init__(object_address) + self.subobject_index = subobject_index + + def build_parameters(self) -> HoiParams: + """Build parameters for get_subobject_address command.""" + return HoiParams().u16(self.subobject_index) # Use u16, not u32 + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse get_subobject_address response.""" + parser = HoiParamsParser(data) + + _, module_id = parser.parse_next() + _, node_id = parser.parse_next() + _, object_id = parser.parse_next() + + return {"address": Address(module_id, node_id, object_id)} + + +class GetInterfacesCommand(HamiltonCommand): + """Get available interfaces (command_id=4).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 4 + action_code = 0 # QUERY + + def __init__(self, object_address: Address): + super().__init__(object_address) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse get_interfaces response.""" + parser = HoiParamsParser(data) + + interfaces = [] + _, interface_count = parser.parse_next() + + for _ in range(interface_count): + _, interface_id = parser.parse_next() + _, name = parser.parse_next() + _, version = parser.parse_next() + interfaces.append({"interface_id": interface_id, "name": name, "version": version}) + + return {"interfaces": interfaces} + + +class GetEnumsCommand(HamiltonCommand): + """Get enum definitions (command_id=5).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 5 + action_code = 0 # QUERY + + def __init__(self, object_address: Address, target_interface_id: int): + super().__init__(object_address) + self.target_interface_id = target_interface_id + + def build_parameters(self) -> HoiParams: + """Build parameters for get_enums command.""" + return HoiParams().u8(self.target_interface_id) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse get_enums response.""" + parser = HoiParamsParser(data) + + enums = [] + _, enum_count = parser.parse_next() + + for _ in range(enum_count): + _, enum_id = parser.parse_next() + _, name = parser.parse_next() + + # Parse enum values + _, value_count = parser.parse_next() + values = {} + for _ in range(value_count): + _, value_name = parser.parse_next() + _, value_value = parser.parse_next() + values[value_name] = value_value + + enums.append({"enum_id": enum_id, "name": name, "values": values}) + + return {"enums": enums} + + +class GetStructsCommand(HamiltonCommand): + """Get struct definitions (command_id=6).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 6 + action_code = 0 # QUERY + + def __init__(self, object_address: Address, target_interface_id: int): + super().__init__(object_address) + self.target_interface_id = target_interface_id + + def build_parameters(self) -> HoiParams: + """Build parameters for get_structs command.""" + return HoiParams().u8(self.target_interface_id) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse get_structs response.""" + parser = HoiParamsParser(data) + + structs = [] + _, struct_count = parser.parse_next() + + for _ in range(struct_count): + _, struct_id = parser.parse_next() + _, name = parser.parse_next() + + # Parse struct fields + _, field_count = parser.parse_next() + fields = {} + for _ in range(field_count): + _, field_name = parser.parse_next() + _, field_type = parser.parse_next() + fields[field_name] = field_type + + structs.append({"struct_id": struct_id, "name": name, "fields": fields}) + + return {"structs": structs} + + +# ============================================================================ +# HIGH-LEVEL INTROSPECTION API +# ============================================================================ + + +class HamiltonIntrospection: + """High-level API for Hamilton introspection.""" + + def __init__(self, backend): + """Initialize introspection API. + + Args: + backend: TCPBackend instance + """ + self.backend = backend + + async def get_object(self, address: Address) -> ObjectInfo: + """Get object metadata. + + Args: + address: Object address to query + + Returns: + Object metadata + """ + command = GetObjectCommand(address) + response = await self.backend.send_command(command) + + return ObjectInfo( + name=response["name"], + version=response["version"], + method_count=response["method_count"], + subobject_count=response["subobject_count"], + address=address, + ) + + async def get_method(self, address: Address, method_index: int) -> MethodInfo: + """Get method signature. + + Args: + address: Object address + method_index: Method index to query + + Returns: + Method signature + """ + command = GetMethodCommand(address, method_index) + response = await self.backend.send_command(command) + + return MethodInfo( + interface_id=response["interface_id"], + call_type=response["call_type"], + method_id=response["method_id"], + name=response["name"], + parameter_types=response.get("parameter_types", []), + parameter_labels=response.get("parameter_labels", []), + return_types=response.get("return_types", []), + return_labels=response.get("return_labels", []), + ) + + async def get_subobject_address(self, address: Address, subobject_index: int) -> Address: + """Get subobject address. + + Args: + address: Parent object address + subobject_index: Subobject index + + Returns: + Subobject address + """ + command = GetSubobjectAddressCommand(address, subobject_index) + response = await self.backend.send_command(command) + + # Type: ignore needed because response dict is typed as dict[str, Any] + # but we know 'address' key contains Address object + return response["address"] # type: ignore[no-any-return, return-value] + + async def get_interfaces(self, address: Address) -> List[InterfaceInfo]: + """Get available interfaces. + + Args: + address: Object address + + Returns: + List of interface information + """ + command = GetInterfacesCommand(address) + response = await self.backend.send_command(command) + + return [ + InterfaceInfo( + interface_id=iface["interface_id"], name=iface["name"], version=iface["version"] + ) + for iface in response["interfaces"] + ] + + async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo]: + """Get enum definitions. + + Args: + address: Object address + interface_id: Interface ID + + Returns: + List of enum definitions + """ + command = GetEnumsCommand(address, interface_id) + response = await self.backend.send_command(command) + + return [ + EnumInfo(enum_id=enum_def["enum_id"], name=enum_def["name"], values=enum_def["values"]) + for enum_def in response["enums"] + ] + + async def get_structs(self, address: Address, interface_id: int) -> List[StructInfo]: + """Get struct definitions. + + Args: + address: Object address + interface_id: Interface ID + + Returns: + List of struct definitions + """ + command = GetStructsCommand(address, interface_id) + response = await self.backend.send_command(command) + + return [ + StructInfo( + struct_id=struct_def["struct_id"], name=struct_def["name"], fields=struct_def["fields"] + ) + for struct_def in response["structs"] + ] + + async def get_all_methods(self, address: Address) -> List[MethodInfo]: + """Get all methods for an object. + + Args: + address: Object address + + Returns: + List of all method signatures + """ + # First get object info to know how many methods there are + object_info = await self.get_object(address) + + methods = [] + for i in range(object_info.method_count): + try: + method = await self.get_method(address, i) + methods.append(method) + except Exception as e: + logger.warning(f"Failed to get method {i} for {address}: {e}") + + return methods + + async def discover_hierarchy(self, root_address: Address) -> Dict[str, Any]: + """Recursively discover object hierarchy. + + Args: + root_address: Root object address + + Returns: + Nested dictionary of discovered objects + """ + hierarchy = {} + + try: + # Get root object info + root_info = await self.get_object(root_address) + # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility + hierarchy["info"] = root_info # type: ignore[assignment] + + # Discover subobjects + subobjects = {} + for i in range(root_info.subobject_count): + try: + subaddress = await self.get_subobject_address(root_address, i) + subobjects[f"subobject_{i}"] = await self.discover_hierarchy(subaddress) + except Exception as e: + logger.warning(f"Failed to discover subobject {i}: {e}") + + # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility + hierarchy["subobjects"] = subobjects # type: ignore[assignment] + + # Discover methods + methods = await self.get_all_methods(root_address) + # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility + hierarchy["methods"] = methods # type: ignore[assignment] + + except Exception as e: + logger.error(f"Failed to discover hierarchy for {root_address}: {e}") + # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility + hierarchy["error"] = str(e) # type: ignore[assignment] + + return hierarchy + + async def discover_all_objects(self, root_addresses: List[Address]) -> Dict[str, Any]: + """Discover all objects starting from root addresses. + + Args: + root_addresses: List of root addresses to start discovery from + + Returns: + Dictionary mapping address strings to discovered hierarchies + """ + all_objects = {} + + for root_address in root_addresses: + try: + hierarchy = await self.discover_hierarchy(root_address) + all_objects[str(root_address)] = hierarchy + except Exception as e: + logger.error(f"Failed to discover objects from {root_address}: {e}") + all_objects[str(root_address)] = {"error": str(e)} + + return all_objects + + def print_method_signatures(self, methods: List[MethodInfo]) -> None: + """Print method signatures in a readable format. + + Args: + methods: List of MethodInfo objects to print + """ + print("Method Signatures:") + print("=" * 50) + for method in methods: + print(f" {method.get_signature_string()}") + print(f" Interface: {method.interface_id}, Method ID: {method.method_id}") + print() + + def print_struct_definitions(self, structs: List[StructInfo]) -> None: + """Print struct definitions in a readable format. + + Args: + structs: List of StructInfo objects to print + """ + print("Struct Definitions:") + print("=" * 50) + for struct in structs: + print(struct.get_struct_string()) + print() + + def get_methods_by_name(self, methods: List[MethodInfo], name_pattern: str) -> List[MethodInfo]: + """Filter methods by name pattern. + + Args: + methods: List of MethodInfo objects to filter + name_pattern: Name pattern to search for (case-insensitive) + + Returns: + List of methods matching the name pattern + """ + return [method for method in methods if name_pattern.lower() in method.name.lower()] + + def get_methods_by_interface( + self, methods: List[MethodInfo], interface_id: int + ) -> List[MethodInfo]: + """Filter methods by interface ID. + + Args: + methods: List of MethodInfo objects to filter + interface_id: Interface ID to filter by + + Returns: + List of methods from the specified interface + """ + return [method for method in methods if method.interface_id == interface_id] diff --git a/pylabrobot/hamilton/transport/tcp/messages.py b/pylabrobot/hamilton/transport/tcp/messages.py new file mode 100644 index 00000000000..ea1952b245c --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/messages.py @@ -0,0 +1,857 @@ +"""High-level Hamilton message builders and response parsers. + +This module provides user-facing message builders and their corresponding +response parsers. Each message type is paired with its response type: + +Request Builders: +- InitMessage: Builds IP[Connection] for initialization +- RegistrationMessage: Builds IP[HARP[Registration]] for discovery +- CommandMessage: Builds IP[HARP[HOI]] for method calls + +Response Parsers: +- InitResponse: Parses initialization responses +- RegistrationResponse: Parses registration responses +- CommandResponse: Parses command responses + +This pairing creates symmetry and makes correlation explicit. + +Architectural Note: +Parameter encoding (HoiParams/HoiParamsParser) is conceptually a separate layer +in the Hamilton protocol architecture (per documented architecture), but is +implemented here for efficiency since it's exclusively used by HOI messages. +This preserves the conceptual separation while optimizing implementation. + +Example: + # Build and send + msg = CommandMessage(dest, interface_id=0, method_id=42) + msg.add_i32(100) + packet_bytes = msg.build(src, seq=1) + + # Parse response + response = CommandResponse.from_bytes(received_bytes) + params = response.hoi.params +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from pylabrobot.hamilton.transport.tcp.packets import ( + Address, + HarpPacket, + HoiPacket, + IpPacket, + RegistrationPacket, +) +from pylabrobot.hamilton.transport.tcp.protocol import ( + HamiltonDataType, + HarpTransportableProtocol, + RegistrationOptionType, +) +from pylabrobot.io.binary import Reader, Writer + +# ============================================================================ +# HOI PARAMETER ENCODING - DataFragment wrapping for HOI protocol +# ============================================================================ +# +# Note: This is conceptually a separate layer in the Hamilton protocol +# architecture, but implemented here for efficiency since it's exclusively +# used by HOI messages (CommandMessage). +# ============================================================================ + + +class HoiParams: + """Builder for HOI parameters with automatic DataFragment wrapping. + + Each parameter is wrapped with DataFragment header before being added: + [type_id:1][flags:1][length:2][data:n] + + This ensures HOI parameters are always correctly formatted and eliminates + the possibility of forgetting to add DataFragment headers. + + Example: + Creates concatenated DataFragments: + [0x03|0x00|0x04|0x00|100][0x0F|0x00|0x05|0x00|"test\0"][0x1C|0x00|...array...] + + params = (HoiParams() + .i32(100) + .string("test") + .u32_array([1, 2, 3]) + .build()) + """ + + def __init__(self): + self._fragments: list[bytes] = [] + + def _add_fragment(self, type_id: int, data: bytes, flags: int = 0) -> "HoiParams": + """Add a DataFragment with the given type_id and data. + + Creates: [type_id:1][flags:1][length:2][data:n] + + Args: + type_id: Data type ID + data: Fragment data bytes + flags: Fragment flags (default: 0, but BOOL_ARRAY uses 0x01) + """ + fragment = Writer().u8(type_id).u8(flags).u16(len(data)).raw_bytes(data).finish() + self._fragments.append(fragment) + return self + + # Scalar integer types + def i8(self, value: int) -> "HoiParams": + """Add signed 8-bit integer parameter.""" + data = Writer().i8(value).finish() + return self._add_fragment(HamiltonDataType.I8, data) + + def i16(self, value: int) -> "HoiParams": + """Add signed 16-bit integer parameter.""" + data = Writer().i16(value).finish() + return self._add_fragment(HamiltonDataType.I16, data) + + def i32(self, value: int) -> "HoiParams": + """Add signed 32-bit integer parameter.""" + data = Writer().i32(value).finish() + return self._add_fragment(HamiltonDataType.I32, data) + + def i64(self, value: int) -> "HoiParams": + """Add signed 64-bit integer parameter.""" + data = Writer().i64(value).finish() + return self._add_fragment(HamiltonDataType.I64, data) + + def u8(self, value: int) -> "HoiParams": + """Add unsigned 8-bit integer parameter.""" + data = Writer().u8(value).finish() + return self._add_fragment(HamiltonDataType.U8, data) + + def u16(self, value: int) -> "HoiParams": + """Add unsigned 16-bit integer parameter.""" + data = Writer().u16(value).finish() + return self._add_fragment(HamiltonDataType.U16, data) + + def u32(self, value: int) -> "HoiParams": + """Add unsigned 32-bit integer parameter.""" + data = Writer().u32(value).finish() + return self._add_fragment(HamiltonDataType.U32, data) + + def u64(self, value: int) -> "HoiParams": + """Add unsigned 64-bit integer parameter.""" + data = Writer().u64(value).finish() + return self._add_fragment(HamiltonDataType.U64, data) + + # Floating-point types + def f32(self, value: float) -> "HoiParams": + """Add 32-bit float parameter.""" + data = Writer().f32(value).finish() + return self._add_fragment(HamiltonDataType.F32, data) + + def f64(self, value: float) -> "HoiParams": + """Add 64-bit double parameter.""" + data = Writer().f64(value).finish() + return self._add_fragment(HamiltonDataType.F64, data) + + # String and bool + def string(self, value: str) -> "HoiParams": + """Add null-terminated string parameter.""" + data = Writer().string(value).finish() + return self._add_fragment(HamiltonDataType.STRING, data) + + def bool_value(self, value: bool) -> "HoiParams": + """Add boolean parameter.""" + data = Writer().u8(1 if value else 0).finish() + return self._add_fragment(HamiltonDataType.BOOL, data) + + # Array types + def i8_array(self, values: list[int]) -> "HoiParams": + """Add array of signed 8-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.i8(val) + return self._add_fragment(HamiltonDataType.I8_ARRAY, writer.finish()) + + def i16_array(self, values: list[int]) -> "HoiParams": + """Add array of signed 16-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.i16(val) + return self._add_fragment(HamiltonDataType.I16_ARRAY, writer.finish()) + + def i32_array(self, values: list[int]) -> "HoiParams": + """Add array of signed 32-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.i32(val) + return self._add_fragment(HamiltonDataType.I32_ARRAY, writer.finish()) + + def i64_array(self, values: list[int]) -> "HoiParams": + """Add array of signed 64-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.i64(val) + return self._add_fragment(HamiltonDataType.I64_ARRAY, writer.finish()) + + def u8_array(self, values: list[int]) -> "HoiParams": + """Add array of unsigned 8-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.u8(val) + return self._add_fragment(HamiltonDataType.U8_ARRAY, writer.finish()) + + def u16_array(self, values: list[int]) -> "HoiParams": + """Add array of unsigned 16-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.u16(val) + return self._add_fragment(HamiltonDataType.U16_ARRAY, writer.finish()) + + def u32_array(self, values: list[int]) -> "HoiParams": + """Add array of unsigned 32-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.u32(val) + return self._add_fragment(HamiltonDataType.U32_ARRAY, writer.finish()) + + def u64_array(self, values: list[int]) -> "HoiParams": + """Add array of unsigned 64-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.u64(val) + return self._add_fragment(HamiltonDataType.U64_ARRAY, writer.finish()) + + def f32_array(self, values: list[float]) -> "HoiParams": + """Add array of 32-bit floats. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.f32(val) + return self._add_fragment(HamiltonDataType.F32_ARRAY, writer.finish()) + + def f64_array(self, values: list[float]) -> "HoiParams": + """Add array of 64-bit doubles. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.f64(val) + return self._add_fragment(HamiltonDataType.F64_ARRAY, writer.finish()) + + def bool_array(self, values: list[bool]) -> "HoiParams": + """Add array of booleans (stored as u8: 0 or 1). + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + + Note: BOOL_ARRAY uses flags=0x01 in the DataFragment header (unlike other types which use 0x00). + """ + writer = Writer() + for val in values: + writer.u8(1 if val else 0) + return self._add_fragment(HamiltonDataType.BOOL_ARRAY, writer.finish(), flags=0x01) + + def string_array(self, values: list[str]) -> "HoiParams": + """Add array of null-terminated strings. + + Format: [count:4][str0\0][str1\0]... + """ + writer = Writer().u32(len(values)) + for val in values: + writer.string(val) + return self._add_fragment(HamiltonDataType.STRING_ARRAY, writer.finish()) + + def build(self) -> bytes: + """Return concatenated DataFragments.""" + return b"".join(self._fragments) + + def count(self) -> int: + """Return number of fragments (parameters).""" + return len(self._fragments) + + +class HoiParamsParser: + """Parser for HOI DataFragment parameters. + + Parses DataFragment-wrapped values from HOI response payloads. + """ + + def __init__(self, data: bytes): + self._data = data + self._offset = 0 + + def parse_next(self) -> tuple[int, Any]: + """Parse the next DataFragment and return (type_id, value). + + Returns: + Tuple of (type_id, parsed_value) + + Raises: + ValueError: If data is malformed or insufficient + """ + if self._offset + 4 > len(self._data): + raise ValueError(f"Insufficient data for DataFragment header at offset {self._offset}") + + # Parse DataFragment header + reader = Reader(self._data[self._offset :]) + type_id = reader.u8() + _flags = reader.u8() # Read but unused + length = reader.u16() + + data_start = self._offset + 4 + data_end = data_start + length + + if data_end > len(self._data): + raise ValueError( + f"DataFragment data extends beyond buffer: need {data_end}, have {len(self._data)}" + ) + + # Extract data payload + fragment_data = self._data[data_start:data_end] + value = self._parse_value(type_id, fragment_data) + + # Move offset past this fragment + self._offset = data_end + + return (type_id, value) + + def _parse_value(self, type_id: int, data: bytes) -> Any: + """Parse value based on type_id using dispatch table.""" + reader = Reader(data) + + # Dispatch table for scalar types + scalar_parsers = { + HamiltonDataType.I8: reader.i8, + HamiltonDataType.I16: reader.i16, + HamiltonDataType.I32: reader.i32, + HamiltonDataType.I64: reader.i64, + HamiltonDataType.U8: reader.u8, + HamiltonDataType.U16: reader.u16, + HamiltonDataType.U32: reader.u32, + HamiltonDataType.U64: reader.u64, + HamiltonDataType.F32: reader.f32, + HamiltonDataType.F64: reader.f64, + HamiltonDataType.STRING: reader.string, + } + + # Check scalar types first + # Cast int to HamiltonDataType enum for dict lookup + try: + data_type = HamiltonDataType(type_id) + if data_type in scalar_parsers: + return scalar_parsers[data_type]() + except ValueError: + pass # Not a valid enum value, continue to other checks + + # Special case: bool + if type_id == HamiltonDataType.BOOL: + return reader.u8() == 1 + + # Dispatch table for array element parsers + array_element_parsers = { + HamiltonDataType.I8_ARRAY: reader.i8, + HamiltonDataType.I16_ARRAY: reader.i16, + HamiltonDataType.I32_ARRAY: reader.i32, + HamiltonDataType.I64_ARRAY: reader.i64, + HamiltonDataType.U8_ARRAY: reader.u8, + HamiltonDataType.U16_ARRAY: reader.u16, + HamiltonDataType.U32_ARRAY: reader.u32, + HamiltonDataType.U64_ARRAY: reader.u64, + HamiltonDataType.F32_ARRAY: reader.f32, + HamiltonDataType.F64_ARRAY: reader.f64, + HamiltonDataType.STRING_ARRAY: reader.string, + } + + # Handle arrays + # Arrays don't have a count prefix - count is derived from DataFragment length + # Calculate element size based on type + element_sizes = { + HamiltonDataType.I8_ARRAY: 1, + HamiltonDataType.I16_ARRAY: 2, + HamiltonDataType.I32_ARRAY: 4, + HamiltonDataType.I64_ARRAY: 8, + HamiltonDataType.U8_ARRAY: 1, + HamiltonDataType.U16_ARRAY: 2, + HamiltonDataType.U32_ARRAY: 4, + HamiltonDataType.U64_ARRAY: 8, + HamiltonDataType.F32_ARRAY: 4, + HamiltonDataType.F64_ARRAY: 8, + HamiltonDataType.STRING_ARRAY: None, # Variable length, handled separately + } + + # Cast int to HamiltonDataType enum for dict lookup + try: + data_type = HamiltonDataType(type_id) + if data_type in array_element_parsers: + element_size = element_sizes.get(data_type) + if element_size is not None: + # Fixed-size elements: calculate count from data length + count = len(data) // element_size + return [array_element_parsers[data_type]() for _ in range(count)] + elif data_type == HamiltonDataType.STRING_ARRAY: + # String arrays: [count:4][str0\0][str1\0]... + count = Reader(data[:4]).u32() + strings = [] + offset = 4 + for _ in range(count): + end = data.index(0, offset) + strings.append(data[offset:end].decode("utf-8", errors="replace")) + offset = end + 1 + return strings + except ValueError: + # Not a valid enum value, continue to other checks + # This shouldn't happen for valid Hamilton types, but we continue anyway + pass + + # Special case: bool array (1 byte per element) + if type_id == HamiltonDataType.BOOL_ARRAY: + count = len(data) // 1 # Each bool is 1 byte + return [reader.u8() == 1 for _ in range(count)] + + # Unknown type + raise ValueError(f"Unknown or unsupported type_id: {type_id}") + + def has_remaining(self) -> bool: + """Check if there are more DataFragments to parse.""" + return self._offset < len(self._data) + + def parse_all(self) -> list[tuple[int, Any]]: + """Parse all remaining DataFragments. + + Returns: + List of (type_id, value) tuples + """ + results = [] + while self.has_remaining(): + results.append(self.parse_next()) + return results + + +# ============================================================================ +# MESSAGE BUILDERS +# ============================================================================ + + +class CommandMessage: + """Build HOI command messages for method calls. + + Creates complete IP[HARP[HOI]] packets with proper protocols and actions. + Parameters are automatically wrapped with DataFragment headers via HoiParams. + + Example: + msg = CommandMessage(dest, interface_id=0, method_id=42) + msg.add_i32(100).add_string("test") + packet_bytes = msg.build(src, seq=1) + """ + + def __init__( + self, + dest: Address, + interface_id: int, + method_id: int, + params: HoiParams, + action_code: int = 3, # Default: COMMAND_REQUEST + harp_protocol: int = 2, # Default: HOI2 + ip_protocol: int = 6, # Default: OBJECT_DISCOVERY + ): + """Initialize command message. + + Args: + dest: Destination object address + interface_id: Interface ID (typically 0 for main interface, 1 for extended) + method_id: Method/action ID to invoke + action_code: HOI action code (default 3=COMMAND_REQUEST) + harp_protocol: HARP protocol identifier (default 2=HOI2) + ip_protocol: IP protocol identifier (default 6=OBJECT_DISCOVERY) + """ + self.dest = dest + self.interface_id = interface_id + self.method_id = method_id + self.params = params + self.action_code = action_code + self.harp_protocol = harp_protocol + self.ip_protocol = ip_protocol + + def build( + self, + src: Address, + seq: int, + harp_response_required: bool = True, + hoi_response_required: bool = False, + ) -> bytes: + """Build complete IP[HARP[HOI]] packet. + + Args: + src: Source address (client address) + seq: Sequence number for this request + harp_response_required: Set bit 4 in HARP action byte (default True) + hoi_response_required: Set bit 4 in HOI action byte (default False) + + Returns: + Complete packet bytes ready to send over TCP + """ + # Build HOI - it handles its own action byte construction + hoi = HoiPacket( + interface_id=self.interface_id, + action_code=self.action_code, + action_id=self.method_id, + params=self.params.build(), + response_required=hoi_response_required, + ) + + # Build HARP - it handles its own action byte construction + harp = HarpPacket( + src=src, + dst=self.dest, + seq=seq, + protocol=self.harp_protocol, + action_code=self.action_code, + payload=hoi.pack(), + response_required=harp_response_required, + ) + + # Wrap in IP packet + ip = IpPacket(protocol=self.ip_protocol, payload=harp.pack()) + + return ip.pack() + + +class RegistrationMessage: + """Build Registration messages for object discovery. + + Creates complete IP[HARP[Registration]] packets for discovering modules, + objects, and capabilities on the Hamilton instrument. + + Example: + msg = RegistrationMessage(dest, action_code=12) + msg.add_registration_option(RegistrationOptionType.HARP_PROTOCOL_REQUEST, protocol=2, request_id=1) + packet_bytes = msg.build(src, req_addr, res_addr, seq=1) + """ + + def __init__( + self, + dest: Address, + action_code: int, + response_code: int = 0, # Default: no error + harp_protocol: int = 3, # Default: Registration + ip_protocol: int = 6, # Default: OBJECT_DISCOVERY + ): + """Initialize registration message. + + Args: + dest: Destination address (typically 0:0:65534 for registration service) + action_code: Registration action code (e.g., 12=HARP_PROTOCOL_REQUEST) + response_code: Response code (default 0=no error) + harp_protocol: HARP protocol identifier (default 3=Registration) + ip_protocol: IP protocol identifier (default 6=OBJECT_DISCOVERY) + """ + self.dest = dest + self.action_code = action_code + self.response_code = response_code + self.harp_protocol = harp_protocol + self.ip_protocol = ip_protocol + self.options = bytearray() + + def add_registration_option( + self, option_type: RegistrationOptionType, protocol: int = 2, request_id: int = 1 + ) -> "RegistrationMessage": + """Add a registration packet option. + + Args: + option_type: Type of registration option (from RegistrationOptionType enum) + protocol: For HARP_PROTOCOL_REQUEST: protocol type (2=HOI, default) + request_id: For HARP_PROTOCOL_REQUEST: what to discover (1=root, 2=global) + + Returns: + Self for method chaining + """ + # Registration option format: [option_id:1][length:1][data...] + # For HARP_PROTOCOL_REQUEST (option 5): data is [protocol:1][request_id:1] + data = Writer().u8(protocol).u8(request_id).finish() + option = Writer().u8(option_type).u8(len(data)).raw_bytes(data).finish() + self.options.extend(option) + return self + + def build( + self, + src: Address, + req_addr: Address, + res_addr: Address, + seq: int, + harp_action_code: int = 3, # Default: COMMAND_REQUEST + harp_response_required: bool = True, # Default: request with response + ) -> bytes: + """Build complete IP[HARP[Registration]] packet. + + Args: + src: Source address (client address) + req_addr: Request address (for registration context) + res_addr: Response address (for registration context) + seq: Sequence number for this request + harp_action_code: HARP action code (default 3=COMMAND_REQUEST) + harp_response_required: Whether response required (default True) + + Returns: + Complete packet bytes ready to send over TCP + """ + # Build Registration packet + reg = RegistrationPacket( + action_code=self.action_code, + response_code=self.response_code, + req_address=req_addr, + res_address=res_addr, + options=bytes(self.options), + ) + + # Wrap in HARP packet + harp = HarpPacket( + src=src, + dst=self.dest, + seq=seq, + protocol=self.harp_protocol, + action_code=harp_action_code, + payload=reg.pack(), + response_required=harp_response_required, + ) + + # Wrap in IP packet + ip = IpPacket(protocol=self.ip_protocol, payload=harp.pack()) + + return ip.pack() + + +class InitMessage: + """Build Connection initialization messages. + + Creates complete IP[Connection] packets for establishing a connection + with the Hamilton instrument. Uses Protocol 7 (INITIALIZATION) which + has a different structure than HARP-based messages. + + Example: + msg = InitMessage(timeout=30) + packet_bytes = msg.build() + """ + + def __init__( + self, + timeout: int = 30, + connection_type: int = 1, # Default: standard connection + protocol_version: int = 0x30, # Default: 3.0 + ip_protocol: int = 7, # Default: INITIALIZATION + ): + """Initialize connection message. + + Args: + timeout: Connection timeout in seconds (default 30) + connection_type: Connection type (default 1=standard) + protocol_version: Protocol version byte (default 0x30=3.0) + ip_protocol: IP protocol identifier (default 7=INITIALIZATION) + """ + self.timeout = timeout + self.connection_type = connection_type + self.protocol_version = protocol_version + self.ip_protocol = ip_protocol + + def build(self) -> bytes: + """Build complete IP[Connection] packet. + + Returns: + Complete packet bytes ready to send over TCP + """ + # Build raw connection parameters (NOT DataFragments) + # Frame: [version:1][message_id:1][count:1][unknown:1] + # Parameters: [id:1][type:1][reserved:2][value:2] repeated + params = ( + Writer() + # Frame + .u8(0) # version + .u8(0) # message_id + .u8(3) # count (3 parameters) + .u8(0) # unknown + # Parameter 1: connection_id (request allocation) + .u8(1) # param id + .u8(16) # param type + .u16(0) # reserved + .u16(0) # value (0 = request allocation) + # Parameter 2: connection_type + .u8(2) # param id + .u8(16) # param type + .u16(0) # reserved + .u16(self.connection_type) # value + # Parameter 3: timeout + .u8(4) # param id + .u8(16) # param type + .u16(0) # reserved + .u16(self.timeout) # value + .finish() + ) + + # Build IP packet + packet_size = 1 + 1 + 2 + len(params) # protocol + version + opts_len + params + + return ( + Writer() + .u16(packet_size) + .u8(self.ip_protocol) + .u8(self.protocol_version) + .u16(0) # options_length + .raw_bytes(params) + .finish() + ) + + +# ============================================================================ +# RESPONSE PARSERS - Paired with message builders above +# ============================================================================ + + +@dataclass +class InitResponse: + """Parsed initialization response. + + Pairs with InitMessage - parses Protocol 7 (INITIALIZATION) responses. + """ + + raw_bytes: bytes + client_id: int + connection_type: int + timeout: int + + @classmethod + def from_bytes(cls, data: bytes) -> "InitResponse": + """Parse initialization response. + + Args: + data: Raw bytes from TCP socket + + Returns: + Parsed InitResponse with connection parameters + """ + # Skip IP header (size + protocol + version + opts_len = 6 bytes) + parser = Reader(data[6:]) + + # Parse frame + _version = parser.u8() # Read but unused + _message_id = parser.u8() # Read but unused + _count = parser.u8() # Read but unused + _unknown = parser.u8() # Read but unused + + # Parse parameter 1 (client_id) + _param1_id = parser.u8() # Read but unused + _param1_type = parser.u8() # Read but unused + _param1_reserved = parser.u16() # Read but unused + client_id = parser.u16() + + # Parse parameter 2 (connection_type) + _param2_id = parser.u8() # Read but unused + _param2_type = parser.u8() # Read but unused + _param2_reserved = parser.u16() # Read but unused + connection_type = parser.u16() + + # Parse parameter 4 (timeout) + _param4_id = parser.u8() # Read but unused + _param4_type = parser.u8() # Read but unused + _param4_reserved = parser.u16() # Read but unused + timeout = parser.u16() + + return cls( + raw_bytes=data, client_id=client_id, connection_type=connection_type, timeout=timeout + ) + + +@dataclass +class RegistrationResponse: + """Parsed registration response. + + Pairs with RegistrationMessage - parses IP[HARP[Registration]] responses. + """ + + raw_bytes: bytes + ip: IpPacket + harp: HarpPacket + registration: RegistrationPacket + + @classmethod + def from_bytes(cls, data: bytes) -> "RegistrationResponse": + """Parse registration response. + + Args: + data: Raw bytes from TCP socket + + Returns: + Parsed RegistrationResponse with all layers + """ + ip = IpPacket.unpack(data) + harp = HarpPacket.unpack(ip.payload) + registration = RegistrationPacket.unpack(harp.payload) + + return cls(raw_bytes=data, ip=ip, harp=harp, registration=registration) + + @property + def sequence_number(self) -> int: + """Get sequence number from HARP layer.""" + return self.harp.seq + + +@dataclass +class CommandResponse: + """Parsed command response. + + Pairs with CommandMessage - parses IP[HARP[HOI]] responses. + """ + + raw_bytes: bytes + ip: IpPacket + harp: HarpPacket + hoi: HoiPacket + + @classmethod + def from_bytes(cls, data: bytes) -> "CommandResponse": + """Parse command response. + + Args: + data: Raw bytes from TCP socket + + Returns: + Parsed CommandResponse with all layers + + Raises: + ValueError: If response is not HOI protocol + """ + ip = IpPacket.unpack(data) + harp = HarpPacket.unpack(ip.payload) + + if harp.protocol != HarpTransportableProtocol.HOI2: + raise ValueError(f"Expected HOI2 protocol, got {harp.protocol}") + + hoi = HoiPacket.unpack(harp.payload) + + return cls(raw_bytes=data, ip=ip, harp=harp, hoi=hoi) + + @property + def sequence_number(self) -> int: + """Get sequence number from HARP layer.""" + return self.harp.seq diff --git a/pylabrobot/hamilton/transport/tcp/packets.py b/pylabrobot/hamilton/transport/tcp/packets.py new file mode 100644 index 00000000000..42d308f1c48 --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/packets.py @@ -0,0 +1,427 @@ +"""Hamilton TCP packet structures. + +This module defines the packet layer of the Hamilton protocol stack: +- IpPacket: Transport layer (size, protocol, version, payload) +- HarpPacket: Protocol layer (addressing, sequence, action, payload) +- HoiPacket: HOI application layer (interface_id, action_id, DataFragment params) +- RegistrationPacket: Registration protocol payload +- ConnectionPacket: Connection initialization payload + +Each packet knows how to pack/unpack itself using the Wire serialization layer. +""" + +from __future__ import annotations + +import logging +import struct +from dataclasses import dataclass + +from pylabrobot.io.binary import Reader, Writer + +logger = logging.getLogger(__name__) + +# Hamilton protocol version +HAMILTON_PROTOCOL_VERSION_MAJOR = 3 +HAMILTON_PROTOCOL_VERSION_MINOR = 0 + + +def encode_version_byte(major: int, minor: int) -> int: + """Pack Hamilton version byte (two 4-bit fields packed into one byte). + + Args: + major: Major version (0-15, stored in upper 4 bits) + minor: Minor version (0-15, stored in lower 4 bits) + """ + if not 0 <= major <= 15: + raise ValueError(f"major version must be 0-15, got {major}") + if not 0 <= minor <= 15: + raise ValueError(f"minor version must be 0-15, got {minor}") + version_byte = (minor & 0xF) | ((major & 0xF) << 4) + return version_byte + + +def decode_version_byte(version_byte: int) -> tuple[int, int]: + """Decode Hamilton version byte and return (major, minor). + + Returns: + Tuple of (major_version, minor_version), each 0-15 + """ + minor = version_byte & 0xF + major = (version_byte >> 4) & 0xF + return (major, minor) + + +@dataclass(frozen=True) +class Address: + """Hamilton network address (module_id, node_id, object_id).""" + + module: int # u16 + node: int # u16 + object: int # u16 + + def pack(self) -> bytes: + """Serialize address to 6 bytes.""" + return Writer().u16(self.module).u16(self.node).u16(self.object).finish() + + @classmethod + def unpack(cls, data: bytes) -> "Address": + """Deserialize address from bytes.""" + r = Reader(data) + return cls(module=r.u16(), node=r.u16(), object=r.u16()) + + def __str__(self) -> str: + return f"{self.module}:{self.node}:{self.object}" + + +@dataclass +class IpPacket: + """Hamilton IpPacket2 - Transport layer. + + Structure: + Bytes 00-01: size (2) + Bytes 02: protocol (1) + Bytes 03: version byte (major.minor) + Bytes 04-05: options_length (2) + Bytes 06+: options (x bytes) + Bytes: payload + """ + + protocol: int # Protocol identifier (6=OBJECT_DISCOVERY, 7=INITIALIZATION) + payload: bytes + options: bytes = b"" + + def pack(self) -> bytes: + """Serialize IP packet.""" + # Calculate size: protocol(1) + version(1) + opts_len(2) + options + payload + packet_size = 1 + 1 + 2 + len(self.options) + len(self.payload) + + return ( + Writer() + .u16(packet_size) + .u8(self.protocol) + .u8(encode_version_byte(HAMILTON_PROTOCOL_VERSION_MAJOR, HAMILTON_PROTOCOL_VERSION_MINOR)) + .u16(len(self.options)) + .raw_bytes(self.options) + .raw_bytes(self.payload) + .finish() + ) + + @classmethod + def unpack(cls, data: bytes) -> "IpPacket": + """Deserialize IP packet.""" + r = Reader(data) + _size = r.u16() # Read but unused + protocol = r.u8() + major, minor = decode_version_byte(r.u8()) + + # Validate version + if major != HAMILTON_PROTOCOL_VERSION_MAJOR or minor != HAMILTON_PROTOCOL_VERSION_MINOR: + logger.warning( + "Hamilton protocol version mismatch: expected %d.%d, got %d.%d", + HAMILTON_PROTOCOL_VERSION_MAJOR, + HAMILTON_PROTOCOL_VERSION_MINOR, + major, + minor, + ) + + opts_len = r.u16() + options = r.raw_bytes(opts_len) if opts_len > 0 else b"" + payload = r.remaining() + + return cls(protocol=protocol, payload=payload, options=options) + + +@dataclass +class HarpPacket: + """Hamilton HarpPacket2 - Protocol layer. + + Structure: + Bytes 00-05: src address (module, node, object) + Bytes 06-11: dst address (module, node, object) + Byte 12: sequence number + Byte 13: reserved + Byte 14: protocol (2=HOI, 3=Registration) + Byte 15: action + Bytes 16-17: message length + Bytes 18-19: options length + Bytes 20+: options + Bytes: version byte (major.minor) + Byte: reserved2 + Bytes: payload + """ + + src: Address + dst: Address + seq: int + protocol: int # 2=HOI, 3=Registration + action_code: int # Base action code (0-15) + payload: bytes + options: bytes = b"" + response_required: bool = True # Controls bit 4 of action byte + + @property + def action(self) -> int: + """Compute action byte from action_code and response_required flag. + + Returns: + Action byte with bit 4 set if response required + """ + return self.action_code | (0x10 if self.response_required else 0x00) + + def pack(self) -> bytes: + """Serialize HARP packet.""" + # Message length includes: src(6) + dst(6) + seq(1) + reserved(1) + protocol(1) + + # action(1) + msg_len(2) + opts_len(2) + options + version(1) + reserved2(1) + payload + # = 20 (fixed header) + options + version + reserved2 + payload + msg_len = 20 + len(self.options) + 1 + 1 + len(self.payload) + + return ( + Writer() + .raw_bytes(self.src.pack()) + .raw_bytes(self.dst.pack()) + .u8(self.seq) + .u8(0) # reserved + .u8(self.protocol) + .u8(self.action) # Uses computed property + .u16(msg_len) + .u16(len(self.options)) + .raw_bytes(self.options) + .u8(0) # version byte - C# DLL uses 0, not 3.0 + .u8(0) # reserved2 + .raw_bytes(self.payload) + .finish() + ) + + @classmethod + def unpack(cls, data: bytes) -> "HarpPacket": + """Deserialize HARP packet.""" + r = Reader(data) + + # Parse addresses + src = Address.unpack(r.raw_bytes(6)) + dst = Address.unpack(r.raw_bytes(6)) + + seq = r.u8() + _reserved = r.u8() # Read but unused + protocol = r.u8() + action_byte = r.u8() + _msg_len = r.u16() # Read but unused + opts_len = r.u16() + + options = r.raw_bytes(opts_len) if opts_len > 0 else b"" + _version = r.u8() # version byte (C# DLL uses 0) - Read but unused + _reserved2 = r.u8() # Read but unused + payload = r.remaining() + + # Decompose action byte into action_code and response_required flag + action_code = action_byte & 0x0F + response_required = bool(action_byte & 0x10) + + return cls( + src=src, + dst=dst, + seq=seq, + protocol=protocol, + action_code=action_code, + payload=payload, + options=options, + response_required=response_required, + ) + + +@dataclass +class HoiPacket: + """Hamilton HoiPacket2 - HOI application layer. + + Structure: + Byte 00: interface_id + Byte 01: action + Bytes 02-03: action_id + Byte 04: version byte (major.minor) + Byte 05: number of fragments + Bytes 06+: DataFragments + + Note: params must be DataFragment-wrapped (use HoiParams to build). + """ + + interface_id: int + action_code: int # Base action code (0-15) + action_id: int + params: bytes # Already DataFragment-wrapped via HoiParams + response_required: bool = False # Controls bit 4 of action byte + + @property + def action(self) -> int: + """Compute action byte from action_code and response_required flag. + + Returns: + Action byte with bit 4 set if response required + """ + return self.action_code | (0x10 if self.response_required else 0x00) + + def pack(self) -> bytes: + """Serialize HOI packet.""" + num_fragments = self._count_fragments(self.params) + + return ( + Writer() + .u8(self.interface_id) + .u8(self.action) # Uses computed property + .u16(self.action_id) + .u8(0) # version byte - always 0 for HOI packets (not 0x30!) + .u8(num_fragments) + .raw_bytes(self.params) + .finish() + ) + + @classmethod + def unpack(cls, data: bytes) -> "HoiPacket": + """Deserialize HOI packet.""" + r = Reader(data) + + interface_id = r.u8() + action_byte = r.u8() + action_id = r.u16() + major, minor = decode_version_byte(r.u8()) + _num_fragments = r.u8() # Read but unused + params = r.remaining() + + # Decompose action byte into action_code and response_required flag + action_code = action_byte & 0x0F + response_required = bool(action_byte & 0x10) + + return cls( + interface_id=interface_id, + action_code=action_code, + action_id=action_id, + params=params, + response_required=response_required, + ) + + @staticmethod + def _count_fragments(data: bytes) -> int: + """Count DataFragments in params. + + Each DataFragment has format: [type_id:1][flags:1][length:2][data:n] + """ + if len(data) == 0: + return 0 + + count = 0 + offset = 0 + + while offset < len(data): + if offset + 4 > len(data): + break # Not enough bytes for a fragment header + + # Read fragment length + fragment_length = struct.unpack(" bytes: + """Serialize Registration packet.""" + return ( + Writer() + .u16(self.action_code) + .u16(self.response_code) + .u8(0) # version byte - DLL uses 0.0, not 3.0 + .u8(0) # reserved + .raw_bytes(self.req_address.pack()) + .raw_bytes(self.res_address.pack()) + .u16(len(self.options)) + .raw_bytes(self.options) + .finish() + ) + + @classmethod + def unpack(cls, data: bytes) -> "RegistrationPacket": + """Deserialize Registration packet.""" + r = Reader(data) + + action_code = r.u16() + response_code = r.u16() + _version = r.u8() # version byte (DLL uses 0, not packed 3.0) - Read but unused + _reserved = r.u8() # Read but unused + req_address = Address.unpack(r.raw_bytes(6)) + res_address = Address.unpack(r.raw_bytes(6)) + opts_len = r.u16() + options = r.raw_bytes(opts_len) if opts_len > 0 else b"" + + return cls( + action_code=action_code, + response_code=response_code, + req_address=req_address, + res_address=res_address, + options=options, + ) + + +@dataclass +class ConnectionPacket: + """Hamilton ConnectionPacket - Connection initialization payload. + + Used for Protocol 7 (INITIALIZATION). Has a different structure than + HARP-based packets - uses raw parameter encoding, NOT DataFragments. + + Structure: + Byte 00: version + Byte 01: message_id + Byte 02: count (number of parameters) + Byte 03: unknown + Bytes 04+: raw parameters [id|type|reserved|value] repeated + """ + + params: bytes # Raw format (NOT DataFragments) + + def pack_into_ip(self) -> bytes: + """Build complete IP packet for connection initialization. + + Returns full IP packet with protocol=7. + """ + # Connection packet size: just the params (frame is included in params) + packet_size = 1 + 1 + 2 + len(self.params) + + return ( + Writer() + .u16(packet_size) + .u8(7) # INITIALIZATION protocol + .u8(encode_version_byte(HAMILTON_PROTOCOL_VERSION_MAJOR, HAMILTON_PROTOCOL_VERSION_MINOR)) + .u16(0) # options_length + .raw_bytes(self.params) + .finish() + ) + + @classmethod + def unpack_from_ip_payload(cls, data: bytes) -> "ConnectionPacket": + """Extract ConnectionPacket from IP packet payload. + + Assumes IP header has already been parsed. + """ + return cls(params=data) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py b/pylabrobot/hamilton/transport/tcp/protocol.py similarity index 100% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py rename to pylabrobot/hamilton/transport/tcp/protocol.py diff --git a/pylabrobot/hamilton/transport/tcp/tcp.py b/pylabrobot/hamilton/transport/tcp/tcp.py new file mode 100644 index 00000000000..7c31e56c291 --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/tcp.py @@ -0,0 +1,584 @@ +"""Hamilton TCP Handler base class for TCP-based instruments (Nimbus, Prep, etc.).""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from typing import Dict, Optional, Union + +from pylabrobot.hamilton.transport.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.transport.tcp.messages import ( + CommandResponse, + InitMessage, + InitResponse, + RegistrationMessage, + RegistrationResponse, +) +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.hamilton.transport.tcp.protocol import ( + Hoi2Action, + HoiRequestId, + RegistrationActionCode, + RegistrationOptionType, +) +from pylabrobot.io.binary import Reader +from pylabrobot.io.socket import Socket + +logger = logging.getLogger(__name__) + + +@dataclass +class HamiltonError: + """Hamilton error response.""" + + error_code: int + error_message: str + interface_id: int + action_id: int + + +class ErrorParser: + """Parse Hamilton error responses.""" + + @staticmethod + def parse_error(data: bytes) -> HamiltonError: + """Parse error response from Hamilton instrument.""" + # Error responses have a specific format + # This is a simplified implementation - real errors may vary + if len(data) < 8: + raise ValueError("Error response too short") + + # Parse error structure (simplified) + error_code = Reader(data).u32() + error_message = data[4:].decode("utf-8", errors="replace") + + return HamiltonError( + error_code=error_code, error_message=error_message, interface_id=0, action_id=0 + ) + + +class HamiltonTCPHandler: + """Base driver for all Hamilton TCP instruments. + + Hamilton TCP instruments include the Nimbus and the Prep, using Hoi and Harp. + STAR and Vantage use the other Hamilton protocol that works over USB. + + This class provides: + - Connection management via Socket (wrapped with state tracking) + - Protocol 7 initialization + - Protocol 3 registration + - Generic command execution + - Object discovery via introspection + + Hamilton uses strict request-response protocol (no unsolicited messages), + so we use simple direct read/write instead of complex routing. + """ + + def __init__( + self, + host: str, + port: int, + read_timeout: float = 30.0, + write_timeout: float = 30.0, + auto_reconnect: bool = True, + max_reconnect_attempts: int = 3, + ): + """Initialize Hamilton TCP handler. + + Args: + host: Hamilton instrument IP address + port: Hamilton instrument port + read_timeout: Read timeout in seconds + write_timeout: Write timeout in seconds + auto_reconnect: Enable automatic reconnection + max_reconnect_attempts: Maximum reconnection attempts + """ + + super().__init__() + + self.io = Socket( + human_readable_device_name="Hamilton Liquid Handler", + host=host, + port=port, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + + # Connection state tracking (wrapping Socket) + self._connected = False + self._reconnect_attempts = 0 + self.auto_reconnect = auto_reconnect + self.max_reconnect_attempts = max_reconnect_attempts + + # Hamilton-specific state + self._client_id: Optional[int] = None + self.client_address: Optional[Address] = None + self._sequence_numbers: Dict[Address, int] = {} + self._discovered_objects: Dict[str, list[Address]] = {} + + # Instrument-specific addresses (set by subclasses) + self._instrument_addresses: Dict[str, Address] = {} + + async def _ensure_connected(self): + """Ensure connection is healthy before operations.""" + if not self._connected: + if not self.auto_reconnect: + raise ConnectionError( + f"{self.io._unique_id} Connection not established and auto-reconnect disabled" + ) + logger.info(f"{self.io._unique_id} Connection not established, attempting to reconnect...") + await self._reconnect() + + async def _reconnect(self): + """Attempt to reconnect with exponential backoff.""" + if not self.auto_reconnect: + raise ConnectionError(f"{self.io._unique_id} Auto-reconnect disabled") + + for attempt in range(self.max_reconnect_attempts): + try: + logger.info( + f"{self.io._unique_id} Reconnection attempt {attempt + 1}/{self.max_reconnect_attempts}" + ) + + # Clean up existing connection + try: + await self.stop() + except Exception: + pass + + # Wait before reconnecting (exponential backoff) + if attempt > 0: + wait_time = 1.0 * (2 ** (attempt - 1)) # 1s, 2s, 4s, etc. + await asyncio.sleep(wait_time) + + # Attempt to reconnect + await self.setup() + self._reconnect_attempts = 0 + logger.info(f"{self.io._unique_id} Reconnection successful") + return + + except Exception as e: + logger.warning(f"{self.io._unique_id} Reconnection attempt {attempt + 1} failed: {e}") + + # All reconnection attempts failed + self._connected = False + raise ConnectionError( + f"{self.io._unique_id} Failed to reconnect after {self.max_reconnect_attempts} attempts" + ) + + async def write(self, data: bytes, timeout: Optional[float] = None): + """Write data to the socket with connection state tracking. + + Args: + data: The data to write. + timeout: The timeout for writing to the server in seconds. If `None`, use the default timeout. + """ + await self._ensure_connected() + + try: + await self.io.write(data, timeout=timeout) + self._connected = True + except (ConnectionError, OSError, TimeoutError): + self._connected = False + raise + + async def read(self, num_bytes: int = 128, timeout: Optional[float] = None) -> bytes: + """Read data from the socket with connection state tracking. + + Args: + num_bytes: Maximum number of bytes to read. Defaults to 128. + timeout: The timeout for reading from the server in seconds. If `None`, use the default + timeout. + + Returns: + The data read from the socket. + """ + await self._ensure_connected() + + try: + data = await self.io.read(num_bytes, timeout=timeout) + self._connected = True + return data + except (ConnectionError, OSError, TimeoutError): + self._connected = False + raise + + async def read_exact(self, num_bytes: int, timeout: Optional[float] = None) -> bytes: + """Read exactly num_bytes with connection state tracking. + + Args: + num_bytes: The exact number of bytes to read. + timeout: The timeout for reading from the server in seconds. If `None`, use the default + timeout. + + Returns: + Exactly num_bytes of data. + + Raises: + ConnectionError: If the connection is closed before num_bytes are read. + """ + await self._ensure_connected() + + try: + data = await self.io.read_exact(num_bytes, timeout=timeout) + self._connected = True + return data + except (ConnectionError, OSError, TimeoutError): + self._connected = False + raise + + @property + def is_connected(self) -> bool: + """Check if the connection is currently established.""" + return self._connected + + async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse]: + """Read one complete Hamilton packet and parse based on protocol. + + Hamilton packets are length-prefixed: + - First 2 bytes: packet size (little-endian) + - Next packet_size bytes: packet payload + + The method inspects the IP protocol field and, for Protocol 6 (HARP), + also checks the HARP protocol field to dispatch correctly. + + Returns: + Union[RegistrationResponse, CommandResponse]: Parsed response + + Raises: + ConnectionError: If connection is lost + TimeoutError: If no message received within timeout + ValueError: If protocol type is unknown + """ + + # Read packet size (2 bytes, little-endian) + size_data = await self.read_exact(2) + packet_size = Reader(size_data).u16() + + # Read packet payload + payload_data = await self.read_exact(packet_size) + complete_data = size_data + payload_data + + # Parse IP packet to get protocol field (byte 2) + # Format: [size:2][ip_protocol:1][version:1][options_len:2][options:x][payload:n] + ip_protocol = complete_data[2] + + # Dispatch based on IP protocol + if ip_protocol == 6: + # Protocol 6: HARP wrapper - need to check HARP protocol field + # IP header: [size:2][protocol:1][version:1][options_len:2] + ip_options_len = int.from_bytes(complete_data[4:6], "little") + harp_start = 6 + ip_options_len + + # HARP header: [src:6][dst:6][seq:1][unk:1][harp_protocol:1][action:1]... + # HARP protocol is at offset 14 within HARP packet + harp_protocol_offset = harp_start + 14 + harp_protocol = complete_data[harp_protocol_offset] + + if harp_protocol == 2: + # HARP Protocol 2: HOI2 + return CommandResponse.from_bytes(complete_data) + if harp_protocol == 3: + # HARP Protocol 3: Registration2 + return RegistrationResponse.from_bytes(complete_data) + logger.warning(f"Unknown HARP protocol: {harp_protocol}, attempting CommandResponse parse") + return CommandResponse.from_bytes(complete_data) + + logger.warning(f"Unknown IP protocol: {ip_protocol}, attempting CommandResponse parse") + return CommandResponse.from_bytes(complete_data) + + async def setup(self): + """Initialize Hamilton connection and discover objects. + + Hamilton uses strict request-response protocol: + 1. Establish TCP connection + 2. Protocol 7 initialization (get client ID) + 3. Protocol 3 registration + 4. Discover objects via Protocol 3 introspection + """ + + # Step 1: Establish TCP connection + await self.io.setup() + + # Set connection state after successful connection + self._connected = True + self._reconnect_attempts = 0 + + # Step 2: Initialize connection (Protocol 7) + await self._initialize_connection() + + # Step 3: Register client (Protocol 3) + await self._register_client() + + # Step 4: Discover root objects + await self._discover_root() + + logger.info(f"Hamilton handler setup complete. Client ID: {self._client_id}") + + async def _initialize_connection(self): + """Initialize connection using Protocol 7 (ConnectionPacket). + + Note: Protocol 7 doesn't have sequence numbers, so we send the packet + and read the response directly (blocking) rather than using the + normal routing mechanism. + """ + logger.info("Initializing Hamilton connection...") + + # Build Protocol 7 ConnectionPacket using new InitMessage + packet = InitMessage(timeout=30).build() + + logger.info("[INIT] Sending Protocol 7 initialization packet:") + logger.info(f"[INIT] Length: {len(packet)} bytes") + logger.info(f"[INIT] Hex: {packet.hex(' ')}") + + # Send packet + await self.write(packet) + + # Read response directly (blocking - safe because this is first communication) + # Read packet size (2 bytes, little-endian) + size_data = await self.read_exact(2) + packet_size = Reader(size_data).u16() + + # Read packet payload + payload_data = await self.read_exact(packet_size) + response_bytes = size_data + payload_data + + logger.info("[INIT] Received response:") + logger.info(f"[INIT] Length: {len(response_bytes)} bytes") + logger.info(f"[INIT] Hex: {response_bytes.hex(' ')}") + + # Parse response using InitResponse + response = InitResponse.from_bytes(response_bytes) + + self._client_id = response.client_id + # Controller module is 2, node is client_id, object 65535 for general addressing + self.client_address = Address(2, response.client_id, 65535) + + logger.info(f"[INIT] Client ID: {self._client_id}, Address: {self.client_address}") + + async def _register_client(self): + """Register client using Protocol 3.""" + logger.info("Registering Hamilton client...") + + # Registration service address (DLL uses 0:0:65534, Piglet comment confirms) + registration_service = Address(0, 0, 65534) + + # Step 1: Initial registration (action_code=0) + reg_msg = RegistrationMessage( + dest=registration_service, action_code=RegistrationActionCode.REGISTRATION_REQUEST + ) + + # Ensure client is initialized + if self.client_address is None or self._client_id is None: + raise RuntimeError("Client not initialized - call _initialize_connection() first") + + # Build and send registration packet + seq = self._allocate_sequence_number(registration_service) + packet = reg_msg.build( + src=self.client_address, + req_addr=Address(2, self._client_id, 65535), # C# DLL: 2:{client_id}:65535 + res_addr=Address(0, 0, 0), # C# DLL: 0:0:0 + seq=seq, + harp_action_code=3, # COMMAND_REQUEST + harp_response_required=False, # DLL uses 0x03 (no response flag) + ) + + logger.info("[REGISTER] Sending registration packet:") + logger.info(f"[REGISTER] Length: {len(packet)} bytes, Seq: {seq}") + logger.info(f"[REGISTER] Hex: {packet.hex(' ')}") + logger.info(f"[REGISTER] Src: {self.client_address}, Dst: {registration_service}") + + # Send registration packet + await self.write(packet) + + # Read response + response = await self._read_one_message() + + logger.info("[REGISTER] Received response:") + logger.info(f"[REGISTER] Length: {len(response.raw_bytes)} bytes") + logger.debug(f"[REGISTER] Hex: {response.raw_bytes.hex(' ')}") + + logger.info("[REGISTER] Registration complete") + + async def _discover_root(self): + """Discover root objects via Protocol 3 HARP_PROTOCOL_REQUEST""" + logger.info("Discovering Hamilton root objects...") + + registration_service = Address(0, 0, 65534) + + # Request root objects (request_id=1) + root_msg = RegistrationMessage( + dest=registration_service, action_code=RegistrationActionCode.HARP_PROTOCOL_REQUEST + ) + root_msg.add_registration_option( + RegistrationOptionType.HARP_PROTOCOL_REQUEST, + protocol=2, + request_id=HoiRequestId.ROOT_OBJECT_OBJECT_ID, + ) + + # Ensure client is initialized + if self.client_address is None or self._client_id is None: + raise RuntimeError("Client not initialized - call _initialize_connection() first") + + seq = self._allocate_sequence_number(registration_service) + packet = root_msg.build( + src=self.client_address, + req_addr=Address(0, 0, 0), + res_addr=Address(0, 0, 0), + seq=seq, + harp_action_code=3, # COMMAND_REQUEST + harp_response_required=True, # Request with response + ) + + logger.info("[DISCOVER_ROOT] Sending root object discovery:") + logger.info(f"[DISCOVER_ROOT] Length: {len(packet)} bytes, Seq: {seq}") + logger.info(f"[DISCOVER_ROOT] Hex: {packet.hex(' ')}") + + # Send request + await self.write(packet) + + # Read response + response = await self._read_one_message() + assert isinstance(response, RegistrationResponse) + + logger.debug(f"[DISCOVER_ROOT] Received response: {len(response.raw_bytes)} bytes") + + # Parse registration response to extract root object IDs + root_objects = self._parse_registration_response(response) + logger.info(f"[DISCOVER_ROOT] Found {len(root_objects)} root objects") + + # Store discovered root objects + self._discovered_objects["root"] = root_objects + + logger.info(f"Discovery complete: {len(root_objects)} root objects") + + def _parse_registration_response(self, response: RegistrationResponse) -> list[Address]: + """Parse registration response options to extract object addresses. + + From Piglet: Option type 6 (HARP_PROTOCOL_RESPONSE) contains object IDs + as a packed list of u16 values. + + Args: + response: Parsed RegistrationResponse + + Returns: + List of discovered object addresses + """ + objects: list[Address] = [] + options_data = response.registration.options + + if not options_data: + logger.debug("No options in registration response (no objects found)") + return objects + + # Parse options: [option_id:1][length:1][data:x] + reader = Reader(options_data) + + while reader.has_remaining(): + option_id = reader.u8() + length = reader.u8() + + if option_id == RegistrationOptionType.HARP_PROTOCOL_RESPONSE: + if length > 0: + # Skip padding u16 + _ = reader.u16() + + # Read object IDs (u16 each) + num_objects = (length - 2) // 2 + for _ in range(num_objects): + object_id = reader.u16() + # Objects are at Address(1, 1, object_id) + objects.append(Address(1, 1, object_id)) + else: + logger.warning(f"Unknown registration option ID: {option_id}, skipping {length} bytes") + # Skip unknown option data + reader.raw_bytes(length) + + return objects + + def _allocate_sequence_number(self, dest_address: Address) -> int: + """Allocate next sequence number for destination. + + Args: + dest_address: Destination object address + + Returns: + Next sequence number for this destination + """ + current = self._sequence_numbers.get(dest_address, 0) + next_seq = (current + 1) % 256 # Wrap at 8 bits (1 byte) + self._sequence_numbers[dest_address] = next_seq + return next_seq + + async def send_command(self, command: HamiltonCommand, timeout: float = 10.0) -> Optional[dict]: + """Send Hamilton command and wait for response. + + Sets source_address if not already set by caller (for testing). + Uses handler's client_address assigned during Protocol 7 initialization. + + Args: + command: Hamilton command to execute + timeout: Maximum time to wait for response + + Returns: + Parsed response dictionary, or None if command has no information to extract + + Raises: + TimeoutError: If no response received within timeout + HamiltonError: If command returned an error + """ + # Set source address with smart fallback + if command.source_address is None: + if self.client_address is None: + raise RuntimeError("Handler not initialized - call setup() first to assign client_address") + command.source_address = self.client_address + + # Allocate sequence number for this command + command.sequence_number = self._allocate_sequence_number(command.dest_address) + + # Build command message + message = command.build() + + # Log command parameters for debugging + log_params = command.get_log_params() + logger.info(f"{command.__class__.__name__} parameters:") + for key, value in log_params.items(): + # Format arrays nicely if very long + if isinstance(value, list) and len(value) > 8: + logger.info(f" {key}: {value[:4]}... ({len(value)} items)") + else: + logger.info(f" {key}: {value}") + + # Send command + await self.write(message) + + # Read response, honoring the per-call timeout when provided. + if timeout is None: + response_message = await self._read_one_message() + else: + response_message = await asyncio.wait_for(self._read_one_message(), timeout) + assert isinstance(response_message, CommandResponse) + + # Check for error actions + action = Hoi2Action(response_message.hoi.action_code) + if action in ( + Hoi2Action.STATUS_EXCEPTION, + Hoi2Action.COMMAND_EXCEPTION, + Hoi2Action.INVALID_ACTION_RESPONSE, + ): + error_message = f"Error response (action={action:#x}): {response_message.hoi.params.hex()}" + logger.error(f"Hamilton error {action}: {error_message}") + raise RuntimeError(f"Hamilton error {action}: {error_message}") + + return command.interpret_response(response_message) + + async def stop(self): + """Stop the handler and close connection.""" + try: + await self.io.stop() + except Exception as e: + logger.warning(f"Error during stop: {e}") + finally: + self._connected = False + logger.info("Hamilton handler stopped") diff --git a/pylabrobot/hamilton/transport/tcp/tests/__init__.py b/pylabrobot/hamilton/transport/tcp/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/transport/usb/__init__.py b/pylabrobot/hamilton/transport/usb/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/transport/usb/protocol.py b/pylabrobot/hamilton/transport/usb/protocol.py new file mode 100644 index 00000000000..889156421f2 --- /dev/null +++ b/pylabrobot/hamilton/transport/usb/protocol.py @@ -0,0 +1,161 @@ +"""STAR firmware response parsing utilities.""" + +import datetime +import re + + +def parse_star_fw_string(resp: str, fmt: str = "") -> dict: + """Parse a machine command or response string according to a format string. + + The format contains names of parameters (always length 2), + followed by an arbitrary number of the following, but always + the same: + - '&': char + - '#': decimal + - '*': hex + + The order of parameters in the format and response string do not + have to (and often do not) match. + + The identifier parameter (id####) is added automatically. + + TODO: string parsing + The firmware docs mention strings in the following format: '...' + However, the length of these is always known (except when reading + barcodes), so it is easier to convert strings to the right number + of '&'. With barcode reading the length of the barcode is included + with the response string. We'll probably do a custom implementation + for that. + + TODO: spaces + We should also parse responses where integers are separated by spaces, + like this: `ua#### #### ###### ###### ###### ######` + + Args: + resp: The response string to parse. + fmt: The format string. + + Raises: + ValueError: if the format string is incompatible with the response. + + Returns: + A dictionary containing the parsed values. + + Examples: + Parsing a string containing decimals (`1111`), hex (`0xB0B`) and chars (`'rw'`): + + ``` + >>> parse_fw_string("aa1111bbrwccB0B", "aa####bb&&cc***") + {'aa': 1111, 'bb': 'rw', 'cc': 2827} + ``` + """ + + # Remove device and cmd identifier from response. + resp = resp[4:] + + # Parse the parameters in the fmt string. + info = {} + + def find_param(param): + name, data = param[0:2], param[2:] + type_ = {"#": "int", "*": "hex", "&": "str"}[data[0]] + + # Build a regex to match this parameter. + exp = { + "int": r"[-+]?[\d ]", + "hex": r"[\da-fA-F ]", + "str": ".", + }[type_] + len_ = len(data.split(" ")[0]) # Get length of first block. + regex = f"{name}((?:{exp}{ {len_} }" + + if param.endswith(" (n)"): + regex += " ?)+)" + is_list = True + else: + regex += "))" + is_list = False + + # Match response against regex, save results in right datatype. + r = re.search(regex, resp) + if r is None: + raise ValueError(f"could not find matches for parameter {name}") + + g = r.groups() + if len(g) == 0: + raise ValueError(f"could not find value for parameter {name}") + m = g[0] + + if is_list: + m = m.split(" ") + + if type_ == "str": + info[name] = m + elif type_ == "int": + info[name] = [int(m_) for m_ in m if m_ != ""] + elif type_ == "hex": + info[name] = [int(m_, base=16) for m_ in m if m_ != ""] + else: + if type_ == "str": + info[name] = m + elif type_ == "int": + info[name] = int(m) + elif type_ == "hex": + info[name] = int(m, base=16) + + # Find params in string. All params are identified by 2 lowercase chars. + param = "" + prevchar = None + for char in fmt: + if char.islower() and prevchar != "(": + if len(param) > 2: + find_param(param) + param = "" + param += char + prevchar = char + if param != "": + find_param(param) # last parameter is not closed by loop. + + # If id not in fmt, add it. + if "id" not in info: + find_param("id####") + + return info + + +def parse_star_firmware_version_date(fw_version: str) -> datetime.date: + """Extract a date from a firmware version string. + + Supports several common Hamilton firmware version formats: + - Full dates: ``"v2021.03.15"`` or ``"2023_01_05"`` or ``"2020-06-12"`` + - Quarter formats: ``"2023_Q2"`` -> first day of the quarter (2023-04-01) + - Year only: ``"2021"`` -> January 1st of that year + + Args: + fw_version: Firmware version string. + + Returns: + A ``datetime.date`` representing the extracted date. + + Raises: + ValueError: If no year can be parsed from the string. + """ + # Prefer full date patterns like YYYY.MM.DD / YYYY_MM_DD / YYYY-MM-DD + date_match = re.search(r"\b(20\d{2})[._-](\d{2})[._-](\d{2})\b", fw_version) + if date_match: + y, m, d = map(int, date_match.groups()) + return datetime.date(y, m, d) + + # Handle quarter formats like 2023_Q2 -> first day of the quarter + q_match = re.search(r"\b(20\d{2})_Q([1-4])\b", fw_version, flags=re.IGNORECASE) + if q_match: + y = int(q_match.group(1)) + q = int(q_match.group(2)) + month = (q - 1) * 3 + 1 + return datetime.date(y, month, 1) + + # Fall back to year only -> Jan 1st of that year + year_match = re.search(r"\b(20\d{2})\b", fw_version) + if year_match is None: + raise ValueError(f"Could not parse year from firmware version string: '{fw_version}'") + return datetime.date(int(year_match.group(1)), 1, 1) diff --git a/pylabrobot/hamilton/transport/usb/usb.py b/pylabrobot/hamilton/transport/usb/usb.py new file mode 100644 index 00000000000..d0e37ac5cd3 --- /dev/null +++ b/pylabrobot/hamilton/transport/usb/usb.py @@ -0,0 +1,397 @@ +import asyncio +import datetime +import logging +import threading +import time +from abc import ABCMeta, abstractmethod +from dataclasses import dataclass +from typing import ( + Any, + List, + Optional, + Tuple, + TypeVar, +) + +from pylabrobot.io.usb import USB + +T = TypeVar("T") + +logger = logging.getLogger(__name__) + + +@dataclass +class HamiltonTask: + """A command that has been sent, awaiting a response.""" + + id_: Optional[int] + loop: asyncio.AbstractEventLoop + fut: asyncio.Future + cmd: str + timeout_time: float + + +class HamiltonUSBDriver(metaclass=ABCMeta): + """Base class for Hamilton devices that communicate over USB firmware protocol. + + Provides USB I/O, firmware command assembly / parsing, and a background + thread that continuously reads responses and matches them to pending tasks. + """ + + @abstractmethod + def __init__( + self, + id_product: int, + device_address: Optional[int] = None, + serial_number: Optional[str] = None, + packet_read_timeout: int = 3, + read_timeout: int = 30, + write_timeout: int = 30, + ): + """ + Args: + id_product: The USB product ID for the Hamilton device. + device_address: The USB address of the Hamilton device. Only useful if using more than one + Hamilton device. + serial_number: The serial number of the Hamilton device. Only useful if using more than one + Hamilton device. + packet_read_timeout: The timeout for reading packets from the Hamilton machine in seconds. + read_timeout: The timeout for reading from the Hamilton machine in seconds. + """ + + self.io = USB( + human_readable_device_name="Hamilton", + id_vendor=0x08AF, + id_product=id_product, + device_address=device_address, + write_timeout=write_timeout, + serial_number=serial_number, + ) + self.packet_read_timeout = packet_read_timeout + self.read_timeout = read_timeout + + self.id_ = 0 + + self._reading_thread: Optional[threading.Thread] = None + self._reading_thread_stop = threading.Event() + self._waiting_tasks: List[HamiltonTask] = [] + + async def setup(self): + await self.io.setup() + self._reading_thread_stop.clear() + self._reading_thread = threading.Thread(target=self._reading_thread_main, daemon=True) + self._reading_thread.start() + + async def stop(self): + self._reading_thread_stop.set() + if self._reading_thread is not None: + self._reading_thread.join(timeout=10) + self._reading_thread = None + for task in self._waiting_tasks: + task.loop.call_soon_threadsafe( + task.fut.set_exception, RuntimeError("Stopping HamiltonUSBDriver.") + ) + self._waiting_tasks.clear() + await self.io.stop() + + @property + @abstractmethod + def module_id_length(self) -> int: + """The length of the module identifier in firmware commands.""" + + @property + def num_channels(self) -> int: + """The number of pipette channels present on the robot. + + Defaults to 0 for non-liquid-handler devices. Liquid handler subclasses must override. + """ + return 0 + + def _generate_id(self) -> int: + """continuously generate unique ids 0 <= x < 10000.""" + self.id_ += 1 + return self.id_ % 10000 + + def _to_list(self, val: List[T], tip_pattern: List[bool]) -> List[T]: + """Convert a list of values to a list of values with the correct length. + + This is roughly one-hot encoding. STAR expects a value for a list parameter at the position + for the corresponding channel. If `tip_pattern` is False, there, the value itself is ignored, + but it must be present. + + Args: + val: A list of values, exactly one for each channel that is involved in the operation. + tip_pattern: A list of booleans indicating whether a channel is involved in the operation. + + Returns: + A list of values with the correct length. Each value that is not involved in the operation + is set to the first value in `val`, which is ignored by STAR. + """ + + # use the default value if a channel is not involved, otherwise use the value in val + if len(val) == 0: + raise ValueError("val must not be empty") + if len(val) > len(tip_pattern): + raise ValueError(f"val has more entries ({len(val)}) than tip_pattern ({len(tip_pattern)})") + + result: List[T] = [] + arg_index = 0 + for channel_involved in tip_pattern: + if channel_involved: + if arg_index >= len(val): + raise ValueError(f"Too few values for tip pattern {tip_pattern}: {val}") + result.append(val[arg_index]) + arg_index += 1 + else: + # this value will be ignored, so just use a value we know is valid + result.append(val[0]) + if arg_index < len(val): + raise ValueError(f"Too many values for tip pattern {tip_pattern}: {val}") + return result + + def _assemble_command( + self, + module: str, + command: str, + auto_id: bool, + tip_pattern: Optional[List[bool]], + **kwargs, + ) -> Tuple[str, Optional[int]]: + """Assemble a firmware command to the Hamilton machine. + + Args: + module: 2 character module identifier (C0 for master, ...) + command: 2 character command identifier (QM for request status, ...) + tip_pattern: A list of booleans indicating whether a channel is involved in the operation. + This value will be used to convert the list values in kwargs to the correct length. + kwargs: any named parameters. the parameter name should also be 2 characters long. The value + can be any size. + + Returns: + A string containing the assembled command. + """ + + cmd = module + command + if auto_id: + cmd_id = self._generate_id() + cmd += f"id{cmd_id:04}" # id has to be the first param + else: + cmd_id = None + + for k, v in kwargs.items(): + if isinstance(v, datetime.datetime): + v = v.strftime("%Y-%m-%d %h:%M") + elif isinstance(v, bool): + v = 1 if v else 0 + elif isinstance(v, list): + # If this command is 'one-hot' encoded, for the channels, then the list should be the + # same length as the 'one-hot' encoding key (tip_pattern.) If the list is shorter than + # that, it will be 'one-hot encoded automatically. Note that this may raise an error if + # the number of values provided is not the same as the number of channels used. + if tip_pattern is not None: + if len(v) != len(tip_pattern): + # convert one-hot encoded list to int list + v = self._to_list(v, tip_pattern) + # list is now of length len(tip_pattern) + if isinstance(v[0], bool): # convert bool list to int list + v = [int(x) for x in v] + v = " ".join([str(e) for e in v]) + ("&" if len(v) < self.num_channels else "") + if k.endswith("_"): # workaround for kwargs named in, as, ... + k = k[:-1] + if len(k) != 2: + raise ValueError("Keyword arguments should be 2 characters long, but got: " + k) + cmd += f"{k}{v}" + + return cmd, cmd_id + + async def send_command( + self, + module: str, + command: str, + auto_id=True, + tip_pattern: Optional[List[bool]] = None, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + wait=True, + fmt: Optional[Any] = None, + **kwargs, + ): + """Send a firmware command to the Hamilton machine. + + Args: + module: 2 character module identifier (C0 for master, ...) + command: 2 character command identifier (QM for request status) + auto_id: auto generate id if True, otherwise use the id in kwargs (or None if not present) + write_timeout: write timeout in seconds. If None, `self.write_timeout` is used. + read_timeout: read timeout in seconds. If None, `self.read_timeout` is used. + wait: If True, wait for a response. If False, return `None` immediately after sending the + command. + fmt: A format to use for the response. If `None`, the response is not parsed. + kwargs: any named parameters. The parameter name should also be 2 characters long. The value + can be of any size. + + Returns: + A dictionary containing the parsed response, or None if no response was read within `timeout`. + """ + + cmd, id_ = self._assemble_command( + module=module, + command=command, + tip_pattern=tip_pattern, + auto_id=auto_id, + **kwargs, + ) + resp = await self._write_and_read_command( + id_=id_, + cmd=cmd, + write_timeout=write_timeout, + read_timeout=read_timeout, + wait=wait, + ) + if resp is not None and fmt is not None: + return self._parse_response(resp, fmt) + return resp + + async def _write_and_read_command( + self, + id_: Optional[int], + cmd: str, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + wait: bool = True, + ) -> Optional[str]: + """Write a command to the Hamilton machine and read the response.""" + await self.io.write(cmd.encode(), timeout=write_timeout) + + if not wait: + return None + + # Attempt to read packets until timeout, or when we identify the right id. + if read_timeout is None: + read_timeout = self.read_timeout + + loop = asyncio.get_event_loop() + fut: asyncio.Future[str] = loop.create_future() + self._start_reading(id_, loop, fut, cmd, read_timeout) + result = await fut + return result + + def _start_reading( + self, + id_: Optional[int], + loop: asyncio.AbstractEventLoop, + fut: asyncio.Future, + cmd: str, + timeout: int, + ) -> None: + """Submit a task to the reading thread.""" + + timeout_time = time.time() + timeout + self._waiting_tasks.append( + HamiltonTask(id_=id_, loop=loop, fut=fut, cmd=cmd, timeout_time=timeout_time) + ) + + if self._reading_thread is None or not self._reading_thread.is_alive(): + self._reading_thread_stop.clear() + self._reading_thread = threading.Thread(target=self._reading_thread_main, daemon=True) + self._reading_thread.start() + + @abstractmethod + def get_id_from_fw_response(self, resp: str) -> Optional[int]: + """Get the id from a firmware response.""" + + @abstractmethod + def check_fw_string_error(self, resp: str): + """Raise an error if the firmware response is an error response.""" + + @abstractmethod + def _parse_response(self, resp: str, fmt: Any) -> dict: + """Parse a firmware response.""" + + def _reading_thread_main(self) -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(self._continuously_read()) + + async def _continuously_read(self) -> None: + """Continuously read from the USB port until stop is requested. + + Tasks are stored in the `self._waiting_tasks` list, and contain a future that will be + completed when the task is finished. Tasks are submitted to the list using the + `self._start_reading` method. + + On each iteration, read the USB port. If a response is received, parse it and check if it is + relevant to any of the tasks. If so, complete the future and remove the task from the + list. If a task has timed out, complete the future with a `TimeoutError`. + """ + + while not self._reading_thread_stop.is_set(): + for idx in range(len(self._waiting_tasks) - 1, -1, -1): # reverse order to allow deletion + task = self._waiting_tasks[idx] + if time.time() > task.timeout_time: + logger.warning("Timeout while waiting for response to command %s.", task.cmd) + task.loop.call_soon_threadsafe( + task.fut.set_exception, + TimeoutError(f"Timeout while waiting for response to command {task.cmd}."), + ) + del self._waiting_tasks[idx] + + if len(self._waiting_tasks) == 0: + await asyncio.sleep(0.01) + continue + + try: + resp = (await self.io.read()).decode("utf-8") + except TimeoutError: + continue + + if resp == "": + continue + + # Parse response. + try: + response_id = self.get_id_from_fw_response(resp) + except ValueError as e: + logger.warning("Could not parse response: %s (%s)", resp, e) + continue + + module_and_command = resp[: self.module_id_length + 2] + for idx in range(len(self._waiting_tasks)): + task = self._waiting_tasks[idx] + # if the command has no id, we have to check the command itself + if response_id == task.id_ or ( + task.id_ is None and task.cmd.startswith(module_and_command) + ): + try: + self.check_fw_string_error(resp) + except Exception as e: + task.loop.call_soon_threadsafe(task.fut.set_exception, e) + else: + task.loop.call_soon_threadsafe(task.fut.set_result, resp) + del self._waiting_tasks[idx] + break + + async def send_raw_command( + self, + command: str, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + wait: bool = True, + ) -> Optional[str]: + """Send a raw command to the machine.""" + id_index = command.find("id") + if id_index != -1: + id_str = command[id_index + 2 : id_index + 6] + if not id_str.isdigit(): + raise ValueError("Id must be a 4 digit int.") + id_ = int(id_str) + else: + id_ = None + + return await self._write_and_read_command( + id_=id_, + cmd=command, + write_timeout=write_timeout, + read_timeout=read_timeout, + wait=wait, + ) diff --git a/pylabrobot/heating_shaking/__init__.py b/pylabrobot/heating_shaking/__init__.py index a11c255c17f..5dfb4a2f790 100644 --- a/pylabrobot/heating_shaking/__init__.py +++ b/pylabrobot/heating_shaking/__init__.py @@ -1,16 +1,10 @@ -"""A hybrid between pylabrobot.shaking and pylabrobot.temperature_controlling""" +import warnings -from pylabrobot.heating_shaking.backend import HeaterShakerBackend -from pylabrobot.heating_shaking.bioshake_backend import BioShake -from pylabrobot.heating_shaking.chatterbox import HeaterShakerChatterboxBackend -from pylabrobot.heating_shaking.hamilton_backend import ( - HamiltonHeaterShakerBackend, - HamiltonHeaterShakerBox, +warnings.warn( + "Importing from pylabrobot.heating_shaking is deprecated. " + "Use pylabrobot.legacy.heating_shaking instead.", + DeprecationWarning, + stacklevel=2, ) -from pylabrobot.heating_shaking.heater_shaker import HeaterShaker -from pylabrobot.heating_shaking.inheco.thermoshake import ( - inheco_thermoshake, - inheco_thermoshake_ac, - inheco_thermoshake_rm, -) -from pylabrobot.heating_shaking.inheco.thermoshake_backend import InhecoThermoshakeBackend + +from pylabrobot.legacy.heating_shaking import * # noqa: F401,F403,E402 diff --git a/pylabrobot/heating_shaking/chatterbox.py b/pylabrobot/heating_shaking/chatterbox.py deleted file mode 100644 index a3d7776cdca..00000000000 --- a/pylabrobot/heating_shaking/chatterbox.py +++ /dev/null @@ -1,9 +0,0 @@ -from pylabrobot.heating_shaking import HeaterShakerBackend -from pylabrobot.shaking import ShakerChatterboxBackend -from pylabrobot.temperature_controlling import TemperatureControllerChatterboxBackend - - -class HeaterShakerChatterboxBackend( - HeaterShakerBackend, ShakerChatterboxBackend, TemperatureControllerChatterboxBackend -): - pass diff --git a/pylabrobot/high_res/__init__.py b/pylabrobot/high_res/__init__.py new file mode 100644 index 00000000000..fc28cb0882f --- /dev/null +++ b/pylabrobot/high_res/__init__.py @@ -0,0 +1,13 @@ +from pylabrobot.high_res.lid_valet import ( + HighResLidValet, + HighResLidValetError, + LidValetState, +) +from pylabrobot.high_res.settings import HighResLidValetSettings + +__all__ = [ + "HighResLidValet", + "HighResLidValetError", + "HighResLidValetSettings", + "LidValetState", +] diff --git a/pylabrobot/high_res/lid_valet.py b/pylabrobot/high_res/lid_valet.py new file mode 100644 index 00000000000..08fc3895acf --- /dev/null +++ b/pylabrobot/high_res/lid_valet.py @@ -0,0 +1,428 @@ +import asyncio +import logging +import re +import time +from typing import Dict, Literal, Optional + +from pylabrobot.high_res.settings import HighResLidValetSettings +from pylabrobot.io.socket import Socket + +logger = logging.getLogger(__name__) + + +# The controller acknowledges an accepted command with "ACK! " + the command, +# then streams progress until it reports one of these completion sentinels. +ACK = "ACK! " +OK = "OK!" +ERROR = "ERROR!" +ABORTED = "ABORTED!" + +# State of a nest reported by the "status" command. +LidValetState = Literal["busy", "open", "has_lid", "error", "unknown"] + +MSG_ABORTED = "Operation has been aborted" +MSG_BUSY = "LidValet is busy" +MSG_COMMUNICATION_TIMEOUT = "Timeout waiting for response from LidValet" +MSG_INVALID_RESPONSE = "Invalid response from the instrument" +MSG_HAS_LID = "Suction cup is holding a lid" +MSG_NO_LID = "Suction cup has no lid" +MSG_OPERATION_TIMEOUT = "Timeout waiting for operation to complete" + + +class HighResLidValetError(Exception): + """Exceptions raised by a HighRes LidValet.""" + + def __init__(self, title: str, message: Optional[str] = None) -> None: + self.title = title + self.message = message + + def __str__(self) -> str: + return f"{self.title}: {self.message}" if self.message else self.title + + +class HighResLidValet: + """HighRes LidValet delidder. + + A benchtop delidder that removes and replaces plate lids with vacuum suction + cups. It has one or more nests, each addressed by a 1-based number; a mover + presents a lidded plate to a nest, the LidValet lifts the lid into its suction + cup ("delid"), and later lowers it back onto the plate ("lid"). + + The controller is a TCP device server (default ``192.168.127.60:1000``, + printed on the back of the device) speaking an ASCII command/ACK protocol. + A command is sent as a newline-terminated ASCII line; the server replies with + ``ACK! `` and then streams text until it reports ``OK!`` + (success), ``ERROR!`` (failure, with a preceding error line), or ``ABORTED!``. + + Commands (```` is the 1-based nest number, which the server calls a hotel; + ``reset`` and ``status`` address every nest at once when ```` is omitted): + + :: + + reset home/reset a nest, or every nest + unlid lift the lid off the plate into the suction cup (delid) + lid lower the held lid back onto the plate + status report nest state (BUSY / OPEN / HAS_LID / ERROR) + home home the whole system + wave synchronously drop and raise the lifts times + vacon/vacoff turn the vacuum cycle on a nest on/off + purgeon/purgeoff turn the purge cycle on a nest on/off + clearabort clear the abort state + errors/history/settings/version/firmwareversion/detailedversion + change/save/revert edit, persist or discard in-memory settings + savecvm save the CVM program to the Copley controllers + list/info/help what the server accepts; "all" includes maintenance + reboot reboot the device + + The nest count is read from the device's ``ACTIVE_HOTELS`` setting at setup. + """ + + def __init__( + self, + host: str = "192.168.127.60", + port: int = 1000, + ack_timeout: float = 10.0, + command_timeout: float = 30.0, + reset_timeout: float = 60.0, + status_timeout: float = 3.0, + busy_timeout: float = 30.0, + status_poll_interval: float = 0.2, + lid_retries: int = 4, + lid_retry_delay: float = 1.5, + ) -> None: + # None until setup() reads ACTIVE_HOTELS off the device. + self._num_nests: Optional[int] = None + self.ack_timeout = ack_timeout + self.command_timeout = command_timeout + self.reset_timeout = reset_timeout + self.status_timeout = status_timeout + self.busy_timeout = busy_timeout + self.status_poll_interval = status_poll_interval + self.lid_retries = lid_retries + self.lid_retry_delay = lid_retry_delay + # One command owns the connection from its write until its completion reply. + self._command_lock = asyncio.Lock() + self.io = Socket( + human_readable_device_name="HighRes LidValet", + host=host, + port=port, + read_timeout=ack_timeout, + write_timeout=ack_timeout, + ) + + async def setup(self) -> None: + await self.io.setup() + self._num_nests = (await self.request_settings()).active_hotels + # Reset every nest, refusing to start with a lid stuck in a suction cup. + stuck = [ + nest for nest, state in (await self.request_all_states()).items() if state == "has_lid" + ] + if stuck: + raise HighResLidValetError( + title="Nest {} has lid in suction cup. Please remove".format( + ", ".join(str(nest) for nest in stuck) + ) + ) + await self.reset() + logger.info("[LidValet] connected: num_nests=%d", self.num_nests) + + @property + def num_nests(self) -> int: + """How many nests the device has, read from it at setup.""" + if self._num_nests is None: + raise RuntimeError("nest count unknown; call setup() first") + return self._num_nests + + async def stop(self) -> None: + """Ask the server to close this client's connection, then drop the socket.""" + try: + await self.io.write(b"disconnect\n") + except Exception: # the link may already be gone; closing locally still must happen + logger.debug("[LidValet] disconnect not delivered", exc_info=True) + await self.io.stop() + + # === Command layer === + + def _validate_nest(self, nest: int) -> None: + if not 1 <= nest <= self.num_nests: + raise ValueError(f"nest must be 1..{self.num_nests}") + + async def _read_chunk(self, timeout: float) -> str: + """Read whatever is available, returning "" if nothing arrives in time.""" + try: + data = await self.io.read(4096, timeout=timeout) + except TimeoutError: + return "" + return data.decode("ascii", errors="replace") + + def _parse_error(self, buffer: str) -> str: + """Extract the error message from a reply that reported ``ERROR!``.""" + before = buffer.split(ERROR, 1)[0] + for line in reversed(before.splitlines()): + if "Error" in line: + return line.strip() + return before.strip() + + async def send_command(self, command: str, timeout: float) -> str: + """Send a command and wait for the controller to complete it. + + Returns the accumulated reply text on ``OK!``; raises + :class:`HighResLidValetError` on ``ERROR!``, ``ABORTED!``, an invalid + acknowledgement, or a timeout. + + A command occupies the connection until the controller reports completion, + so concurrent callers are serialized rather than interleaving their replies. + """ + async with self._command_lock: + await self.io.write(f"{command}\n".encode("ascii")) + logger.debug("[LidValet] send: %s", command) + + buffer = await self._read_chunk(self.ack_timeout) + if f"{ACK}{command}" not in buffer and OK not in buffer: + if not buffer: + raise HighResLidValetError(title=MSG_COMMUNICATION_TIMEOUT, message=command) + raise HighResLidValetError(title=MSG_INVALID_RESPONSE, message=repr(buffer)) + + deadline = time.monotonic() + timeout + while True: + if ABORTED in buffer: + raise HighResLidValetError(title=MSG_ABORTED, message=command) + if ERROR in buffer: + raise HighResLidValetError(title=self._parse_error(buffer)) + if OK in buffer: + logger.debug("[LidValet] recv: %s", buffer.strip()) + return buffer + remaining = deadline - time.monotonic() + if remaining <= 0: + raise HighResLidValetError(title=MSG_OPERATION_TIMEOUT, message=command) + buffer += await self._read_chunk(min(remaining, self.ack_timeout)) + + def _parse_state(self, buffer: str) -> LidValetState: + if "BUSY" in buffer: + return "busy" + if "OPEN" in buffer: + return "open" + if "HAS_LID" in buffer: + return "has_lid" + # A nest whose last operation failed reports "ERROR (last failed)" until it is reset. + if "ERROR" in buffer: + return "error" + return "unknown" + + # === Status === + + async def request_state(self, nest: int) -> LidValetState: + """Query a nest: "busy", "open", "has_lid", "error", or "unknown".""" + self._validate_nest(nest) + buffer = await self.send_command(f"status {nest}", self.status_timeout) + return self._parse_state(buffer) + + async def request_all_states(self) -> Dict[int, LidValetState]: + """Query every nest in one round trip, keyed by 1-based nest number.""" + buffer = await self.send_command("status", self.status_timeout) + states: Dict[int, LidValetState] = {} + for line in buffer.splitlines(): + match = re.match(r"\s*hotel\s+(\d+)\s*:\s*(.+)", line) + if match: + states[int(match.group(1))] = self._parse_state(match.group(2)) + return states + + async def request_has_lid(self, nest: int) -> bool: + """Whether the suction cup at ``nest`` is holding a lid. Raises if busy.""" + state = await self.request_state(nest) + if state == "busy": + raise HighResLidValetError(title=MSG_BUSY, message=f"nest {nest}") + return state == "has_lid" + + async def request_is_busy(self, nest: int) -> bool: + """Whether ``nest`` is currently busy.""" + return await self.request_state(nest) == "busy" + + async def wait_until_ready(self, nest: int, timeout: Optional[float] = None) -> None: + """Poll ``nest`` until it is no longer busy.""" + self._validate_nest(nest) + timeout = self.busy_timeout if timeout is None else timeout + deadline = time.monotonic() + timeout + while await self.request_is_busy(nest): + if time.monotonic() > deadline: + raise HighResLidValetError(title=MSG_BUSY, message=f"nest {nest}") + await asyncio.sleep(self.status_poll_interval) + + # === Operations === + + async def reset(self, nest: Optional[int] = None) -> None: + """Home/reset a nest, or every nest when ``nest`` is omitted.""" + if nest is None: + await self.send_command("reset", self.reset_timeout) + logger.info("[LidValet] all nests reset") + return + self._validate_nest(nest) + await self.send_command(f"reset {nest}", self.reset_timeout) + logger.info("[LidValet] nest %d reset", nest) + + async def delid(self, nest: int) -> None: + """Lift the lid off the plate at ``nest`` into the suction cup. + + The suction cup must be empty; raises if it is already holding a lid or if + the nest is busy. + """ + self._validate_nest(nest) + state = await self.request_state(nest) + if state == "busy": + raise HighResLidValetError(title=MSG_BUSY, message=f"nest {nest}") + if state == "has_lid": + raise HighResLidValetError(title=MSG_HAS_LID, message=f"nest {nest}") + await self.send_command(f"unlid {nest}", self.command_timeout) + logger.info("[LidValet] nest %d delidded", nest) + + async def lid(self, nest: int) -> None: + """Lower the held lid back onto the plate at ``nest``. + + The suction cup must be holding a lid. Retries a few times to ride out a + transient busy/no-lid state, matching the device server's own behaviour. + """ + self._validate_nest(nest) + last_error: Optional[HighResLidValetError] = None + for attempt in range(self.lid_retries): + try: + state = await self.request_state(nest) + if state == "busy": + raise HighResLidValetError(title=MSG_BUSY, message=f"nest {nest}") + if state != "has_lid": + raise HighResLidValetError(title=MSG_NO_LID, message=f"nest {nest}") + await self.send_command(f"lid {nest}", self.command_timeout) + logger.info("[LidValet] nest %d lidded", nest) + return + except HighResLidValetError as error: + last_error = error + if attempt < self.lid_retries - 1: + logger.warning("[LidValet] retry lidding nest %d: %s", nest, error) + await asyncio.sleep(self.lid_retry_delay) + assert last_error is not None + raise last_error + + async def home(self) -> None: + """Home the whole system.""" + await self.send_command("home", self.reset_timeout) + logger.info("[LidValet] homed") + + async def wave(self, cycles: int = 1) -> None: + """Drop and raise the lifts ``cycles`` times, synchronously.""" + if cycles < 1: + raise ValueError("cycles must be at least 1") + await self.send_command(f"wave {cycles}", self.command_timeout * cycles) + logger.info("[LidValet] waved %d cycle(s)", cycles) + + # === Pneumatics === + + async def set_vacuum(self, nest: int, on: bool) -> None: + """Turn the vacuum cycle on ``nest`` on or off.""" + self._validate_nest(nest) + await self.send_command(f"{'vacon' if on else 'vacoff'} {nest}", self.command_timeout) + logger.info("[LidValet] nest %d vacuum %s", nest, "on" if on else "off") + + async def set_purge(self, nest: int, on: bool) -> None: + """Turn the purge cycle on ``nest`` on or off.""" + self._validate_nest(nest) + await self.send_command(f"{'purgeon' if on else 'purgeoff'} {nest}", self.command_timeout) + logger.info("[LidValet] nest %d purge %s", nest, "on" if on else "off") + + # === Diagnostics === + + def _payload(self, buffer: str) -> str: + """Strip the ``ACK!``/``OK!`` envelope, leaving the reply body.""" + body = [ + line + for line in buffer.splitlines() + if not line.startswith(ACK.strip()) and not line.startswith(OK) + ] + return "\n".join(body).strip() + + async def clear_abort(self) -> None: + """Clear the abort state.""" + await self.send_command("clearabort", self.command_timeout) + + async def request_version(self) -> str: + """Return the software version report.""" + return self._payload(await self.send_command("version", self.status_timeout)) + + async def request_firmware_version(self) -> str: + """Return the firmware version.""" + return self._payload(await self.send_command("firmwareversion", self.status_timeout)) + + async def request_detailed_version(self) -> str: + """Return version details about all hardware.""" + return self._payload(await self.send_command("detailedversion", self.command_timeout)) + + async def request_errors(self, count: Optional[int] = None) -> str: + """Return the top errors on the device's error stack.""" + command = "errors" if count is None else f"errors {count}" + return self._payload(await self.send_command(command, self.status_timeout)) + + async def request_history(self, count: Optional[int] = None) -> str: + """Return the last commands sent to the server.""" + command = "history" if count is None else f"history {count}" + return self._payload(await self.send_command(command, self.status_timeout)) + + async def request_command_status(self, command_id: int) -> str: + """Return the status of a previously issued command.""" + return self._payload(await self.send_command(f"commandstat {command_id}", self.status_timeout)) + + async def request_home_offset(self, address: int) -> str: + """Return the home offset for the motor at ``address`` (a Copley address). + + Unknown addresses are reported by the controller as an error, but address 0 + hangs its command queue outright and takes the device server down with it. + """ + if address < 1: + raise ValueError("address must be at least 1; address 0 hangs the controller") + return self._payload(await self.send_command(f"queryhomeoffset {address}", self.status_timeout)) + + # === Settings === + + async def request_raw_settings(self) -> str: + """Return the settings file as the device reports it.""" + return self._payload(await self.send_command("settings", self.command_timeout)) + + async def request_settings(self) -> HighResLidValetSettings: + """Return the device's settings as a typed, frozen object.""" + return HighResLidValetSettings.from_lines((await self.request_raw_settings()).splitlines()) + + async def change_setting(self, setting: str, value: str) -> None: + """Change a setting in memory. Lost on restart unless :meth:`save_settings` follows.""" + if " " in value: + raise ValueError("values with embedded spaces cannot be assigned") + await self.send_command(f"change {setting} {value}", self.command_timeout) + + async def save_settings(self) -> None: + """Persist the in-memory settings to permanent memory.""" + await self.send_command("save", self.command_timeout) + + async def revert_settings(self) -> None: + """Discard in-memory settings changes, restoring the stored values.""" + await self.send_command("revert", self.command_timeout) + + async def save_cvm(self) -> None: + """Save the CVM program to every Copley motor controller.""" + await self.send_command("savecvm", self.reset_timeout) + + async def reboot(self) -> None: + """Reboot the device. The connection drops; call :meth:`setup` again afterwards.""" + await self.io.write(b"reboot\n") + logger.info("[LidValet] reboot requested") + + # === Server introspection === + + async def list_commands(self, include_maintenance: bool = False) -> str: + """List the commands the server recognizes.""" + command = "list all" if include_maintenance else "list" + return self._payload(await self.send_command(command, self.command_timeout)) + + async def request_command_info(self, include_maintenance: bool = False) -> str: + """List the server's commands with their parameter information.""" + command = "info all" if include_maintenance else "info" + return self._payload(await self.send_command(command, self.command_timeout)) + + async def request_command_help(self, command: str) -> str: + """Return the parameter information for a single command.""" + return self._payload(await self.send_command(f"help {command}", self.status_timeout)) diff --git a/pylabrobot/high_res/lid_valet_tests.py b/pylabrobot/high_res/lid_valet_tests.py new file mode 100644 index 00000000000..906d34cb6f6 --- /dev/null +++ b/pylabrobot/high_res/lid_valet_tests.py @@ -0,0 +1,261 @@ +import asyncio +import unittest +from types import SimpleNamespace +from typing import Dict, List, Optional +from unittest.mock import AsyncMock, patch + +from pylabrobot.high_res import HighResLidValet, HighResLidValetError + +# Held before the tests patch asyncio.sleep, so the fake socket can still yield +# to the event loop while the driver's own sleeps are stubbed out. +_real_sleep = asyncio.sleep + + +class FakeLidValetSocket: + """In-memory TCP stand-in mimicking the LidValet device server. + + On ``write(command)`` it queues the controller's reply: ``ACK! `` + followed by the configured body (default ``OK!``), exactly like the server, + which acknowledges then streams progress until a completion sentinel. A + command listed in ``raw`` is answered verbatim instead, for malformed-reply + tests. + """ + + def __init__(self, responses: Dict[str, str], raw: Optional[Dict[str, str]] = None) -> None: + self.responses = responses + self.raw = raw or {} + self.port = 1000 + self.written: List[str] = [] + self._rx = bytearray() + + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def write(self, data: bytes) -> None: + # Yield like a real socket would, so concurrent callers can interleave here. + await _real_sleep(0) + self.written.append(data.decode("ascii")) + # the server is line-oriented: it acts on the command once the newline arrives + command = data.decode("ascii").rstrip("\r\n") + if command in self.raw: + self._rx += self.raw[command].encode("ascii") + else: + body = self.responses.get(command, "OK!\r\n") + self._rx += f"ACK! {command}\r\n{body}".encode("ascii") + + async def read(self, num_bytes: int = 4096, timeout: Optional[float] = None) -> bytes: + await _real_sleep(0) + if not self._rx: + raise TimeoutError("no data") + out = bytes(self._rx[:num_bytes]) + del self._rx[:num_bytes] + return out + + +class LidValetTestBase(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + # Skip the firmware's real retry sleeps so tests run instantly. + patcher = patch("pylabrobot.high_res.lid_valet.asyncio.sleep", new_callable=AsyncMock) + patcher.start() + self.addCleanup(patcher.stop) + + def _make( + self, responses: Dict[str, str], raw: Optional[Dict[str, str]] = None, num_nests: int = 2 + ) -> HighResLidValet: + valet = HighResLidValet() + # setup() normally reads this off the device; the fake has no settings file. + valet._num_nests = num_nests + valet.io = FakeLidValetSocket(responses, raw) # type: ignore[assignment] + return valet + + def _stub_nest_count(self, valet: HighResLidValet, count: int) -> None: + """setup() reads the count from the settings file, which the fake has no copy of.""" + settings = SimpleNamespace(active_hotels=count) + valet.request_settings = AsyncMock(return_value=settings) # type: ignore[method-assign] + + +class TestHighResLidValet(LidValetTestBase): + async def test_status_decode(self): + for body, expected in [ + ("OPEN\r\nOK!\r\n", "open"), + ("HAS_LID\r\nOK!\r\n", "has_lid"), + ("BUSY\r\nOK!\r\n", "busy"), + ("\r\nOK!\r\n", "unknown"), + ]: + valet = self._make({"status 1": body}) + self.assertEqual(await valet.request_state(1), expected) + + async def test_delid_wire_bytes(self): + valet = self._make({"status 1": "OPEN\r\nOK!\r\n", "unlid 1": "OK!\r\n"}) + await valet.delid(1) + self.assertEqual(valet.io.written, ["status 1\n", "unlid 1\n"]) # type: ignore[attr-defined] + + async def test_delid_refuses_when_cup_has_lid(self): + valet = self._make({"status 1": "HAS_LID\r\nOK!\r\n"}) + with self.assertRaises(HighResLidValetError): + await valet.delid(1) + self.assertNotIn("unlid 1\n", valet.io.written) # type: ignore[attr-defined] + + async def test_lid_wire_bytes(self): + valet = self._make({"status 1": "HAS_LID\r\nOK!\r\n", "lid 1": "OK!\r\n"}) + await valet.lid(1) + self.assertEqual(valet.io.written, ["status 1\n", "lid 1\n"]) # type: ignore[attr-defined] + + async def test_lid_retries_then_fails_without_lid(self): + valet = self._make({"status 1": "OPEN\r\nOK!\r\n"}) + with self.assertRaises(HighResLidValetError): + await valet.lid(1) + # One status poll per attempt, no "lid" command ever sent. + self.assertEqual(valet.io.written.count("status 1\n"), valet.lid_retries) # type: ignore[attr-defined] + self.assertNotIn("lid 1\n", valet.io.written) # type: ignore[attr-defined] + + async def test_reset_wire_bytes(self): + valet = self._make({"reset 2": "OK!\r\n"}) + await valet.reset(2) + self.assertEqual(valet.io.written, ["reset 2\n"]) # type: ignore[attr-defined] + + async def test_error_reply_is_parsed(self): + valet = self._make( + { + "status 1": "OPEN\r\nOK!\r\n", + "unlid 1": "Delid Error: no plate present\r\nERROR!\r\n", + } + ) + with self.assertRaises(HighResLidValetError) as ctx: + await valet.delid(1) + self.assertEqual(str(ctx.exception), "Delid Error: no plate present") + + async def test_aborted_reply_raises(self): + valet = self._make({"reset 1": "ABORTED!\r\n"}) + with self.assertRaises(HighResLidValetError): + await valet.reset(1) + + async def test_invalid_response_without_ack(self): + valet = self._make({}, raw={"status 1": "garbage\r\n"}) + with self.assertRaises(HighResLidValetError): + await valet.request_state(1) + + async def test_nest_validation(self): + valet = self._make({}, num_nests=2) + with self.assertRaises(ValueError): + await valet.reset(3) + with self.assertRaises(ValueError): + await valet.delid(0) + + async def test_setup_resets_every_nest_in_one_command(self): + valet = self._make({"status": "hotel 1: OPEN\r\nhotel 2: OPEN\r\nOK!\r\n", "reset": "OK!\r\n"}) + self._stub_nest_count(valet, 2) + await valet.setup() + # One status round trip for the whole machine, one reset for every nest. + self.assertEqual(valet.io.written, ["status\n", "reset\n"]) # type: ignore[attr-defined] + + async def test_setup_refuses_stuck_lid(self): + valet = self._make({"status": "hotel 1: HAS_LID\r\nhotel 2: OPEN\r\nOK!\r\n"}) + self._stub_nest_count(valet, 2) + with self.assertRaises(HighResLidValetError): + await valet.setup() + + async def test_request_all_states(self): + valet = self._make({"status": "hotel 1: OPEN\r\nhotel 2: ERROR (last failed)\r\nOK!\r\n"}) + self.assertEqual(await valet.request_all_states(), {1: "open", 2: "error"}) + + async def test_reset_all_omits_nest(self): + valet = self._make({"reset": "OK!\r\n"}) + await valet.reset() + self.assertEqual(valet.io.written, ["reset\n"]) # type: ignore[attr-defined] + + async def test_nest_count_unknown_before_setup(self): + valet = HighResLidValet() + with self.assertRaises(RuntimeError): + _ = valet.num_nests + with self.assertRaises(RuntimeError): + valet._validate_nest(1) + + async def test_save_cvm_wire_bytes(self): + valet = self._make({"savecvm": "OK!\r\n"}) + await valet.save_cvm() + self.assertEqual(valet.io.written, ["savecvm\n"]) # type: ignore[attr-defined] + + async def test_stop_tells_the_server_to_disconnect(self): + valet = self._make({}) + await valet.stop() + self.assertEqual(valet.io.written, ["disconnect\n"]) # type: ignore[attr-defined] + + async def test_introspection_wire_bytes(self): + valet = self._make({"list": "OK!\r\n", "info all": "OK!\r\n", "help wave": "OK!\r\n"}) + await valet.list_commands() + await valet.request_command_info(include_maintenance=True) + await valet.request_command_help("wave") + self.assertEqual( + valet.io.written, # type: ignore[attr-defined] + ["list\n", "info all\n", "help wave\n"], + ) + + async def test_concurrent_commands_do_not_cross_replies(self): + valet = self._make( + { + "status 1": "hotel 1: OPEN\r\nOK!\r\n", + "status 2": "hotel 2: HAS_LID\r\nOK!\r\n", + } + ) + # Each command holds the connection until its own reply, so neither + # coroutine can consume the other's. + first, second = await asyncio.gather(valet.request_state(1), valet.request_state(2)) + self.assertEqual((first, second), ("open", "has_lid")) + + async def test_failed_nest_reports_error_state(self): + valet = self._make({"status 1": "hotel 1: ERROR (last failed)\r\nOK!\r\n"}) + self.assertEqual(await valet.request_state(1), "error") + + async def test_vacuum_wire_bytes(self): + valet = self._make({"vacon 1": "OK!\r\n", "vacoff 1": "OK!\r\n"}) + await valet.set_vacuum(1, True) + await valet.set_vacuum(1, False) + self.assertEqual(valet.io.written, ["vacon 1\n", "vacoff 1\n"]) # type: ignore[attr-defined] + + async def test_purge_wire_bytes(self): + valet = self._make({"purgeon 2": "OK!\r\n", "purgeoff 2": "OK!\r\n"}) + await valet.set_purge(2, True) + await valet.set_purge(2, False) + self.assertEqual(valet.io.written, ["purgeon 2\n", "purgeoff 2\n"]) # type: ignore[attr-defined] + + async def test_pneumatics_validate_nest(self): + valet = self._make({}, num_nests=2) + with self.assertRaises(ValueError): + await valet.set_vacuum(3, True) + with self.assertRaises(ValueError): + await valet.set_purge(0, True) + + async def test_home_and_wave_wire_bytes(self): + valet = self._make({"home": "OK!\r\n", "wave 3": "OK!\r\n"}) + await valet.home() + await valet.wave(3) + self.assertEqual(valet.io.written, ["home\n", "wave 3\n"]) # type: ignore[attr-defined] + + async def test_home_offset_rejects_address_zero(self): + valet = self._make({}) + # Address 0 hangs the controller's command queue, so it never reaches the wire. + with self.assertRaises(ValueError): + await valet.request_home_offset(0) + self.assertEqual(valet.io.written, []) # type: ignore[attr-defined] + + async def test_wave_rejects_zero_cycles(self): + valet = self._make({}) + with self.assertRaises(ValueError): + await valet.wave(0) + + async def test_diagnostics_strip_envelope(self): + valet = self._make({"version": "Product Name: LidValet\r\nOK!\r\n"}) + self.assertEqual(await valet.request_version(), "Product Name: LidValet") + + async def test_change_setting_rejects_embedded_space(self): + valet = self._make({}) + with self.assertRaises(ValueError): + await valet.change_setting("SERVER_NAME", "two words") + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/high_res/settings.py b/pylabrobot/high_res/settings.py new file mode 100644 index 00000000000..180161c8a3b --- /dev/null +++ b/pylabrobot/high_res/settings.py @@ -0,0 +1,800 @@ +"""Typed, immutable view of a LidValet's on-device settings. + +The device exposes its full calibration/configuration via the ``settings`` +command as ``NAME = value`` text. Each key is surfaced here as one explicitly +typed attribute (the device ``NAME`` lower-cased); types are inferred from the +device's own values. A :class:`HighResLidValetSettings` is loaded whole from the +device (or a capture) and is frozen once built. +""" + +import warnings +from dataclasses import dataclass, fields +from typing import Any, Dict, Iterable, Tuple + +try: + from typing import Literal +except ImportError: # pragma: no cover + from typing_extensions import Literal # type: ignore + + +# Known LidValet models, as reported by the device's MACHINE_TYPE setting. +# Extend this (and MachineType) when a new model is encountered. +MachineType = Literal["LidValet"] +KNOWN_MACHINE_TYPES: Tuple[str, ...] = ("LidValet",) + + +@dataclass(frozen=True) +class HighResLidValetSettings: + """All on-device settings, one typed attribute per device key.""" + + product_name: str + product_description: str + serial_number: str + machine_type: str + rest_server_port: int + syslog_server: str + syslog_level: int + internal_log_level: int + carousel_home_speed_fast: float + carousel_home_speed_slow: float + carousel_home_acceleration: float + carousel_velocity: float + carousel_idle_velocity: float + carousel_acceleration: float + carousel_abort_deceleration: float + carousel_jerk: float + carousel_final_drive_jerk: float + carousel_stacker_zero_pos: float + carousel_stacker_0_pos: float + carousel_stacker_1_pos: float + carousel_stacker_2_pos: float + carousel_stacker_count: int + carousel_count: int + carousel_autohome_operations: int + carousel_calibration_offset: float + upper_carousel_offset: int + carousel_reverse: str + carousel_hold: int + carousel_run: int + carousel_boost: int + carousel_idle: int + spatula_home_speed_fast: float + spatula_home_speed_slow: float + spatula_home_acceleration: float + spatula_velocity: float + spatula_velocity_with_plate: float + spatula_acceleration: float + spatula_abort_deceleration: float + spatula_jerk: float + spatula_counts_per_mm: float + extra_plate_trap_cycle: str + spatula_rot_motorized: str + spatula_rot_home_speed_fast: float + spatula_rot_home_speed_slow: float + spatula_rot_home_acceleration: float + spatula_rot_velocity: float + spatula_rot_acceleration: float + spatula_rot_abort_deceleration: float + spatula_rot_jerk: float + spatula_rot_zero_pos: float + spatula_rot_stack_pos_0: float + spatula_rot_stack_pos_1: float + spatula_rot_stack_pos_2: float + spatula_rot_nest_1_pos: float + spatula_rot_nest_2_pos: float + spatula_rot_nest_3_pos: float + spatula_rot_nest_4_pos: float + spatula_rot_nest_5_pos: float + spatula_rot_nest_6_pos: float + spatula_rot_nest_7_pos: float + spatula_rot_nest_8_pos: float + spatula_rot_nest_9_pos: float + spatula_rot_nest_10_pos: float + spatula_rot_nest_21_pos: float + spatula_rot_nest_22_pos: float + spatula_rot_nest_23_pos: float + spatula_rot_nest_24_pos: float + spatula_rot_nest_51_pos: float + spatula_rot_nest_52_pos: float + spatula_rot_nest_61_pos: float + spatula_rot_nest_62_pos: float + spatula_rot_nest_63_pos: float + spatula_rot_nest_64_pos: float + spatula_rot_nest_65_pos: float + spatula_rot_nest_66_pos: float + spatula_rot_nest_67_pos: float + spatula_rot_nest_68_pos: float + spatula_rot_nest_69_pos: float + spatula_slide_motorized: str + spatula_slide_home_speed_fast: float + spatula_slide_home_speed_slow: float + spatula_slide_home_acceleration: float + spatula_slide_home_offset: float + spatula_slide_velocity: float + spatula_slide_acceleration: float + spatula_slide_abort_deceleration: float + spatula_slide_jerk: float + spatula_slide_in_pos_0: float + spatula_slide_in_pos_1: float + spatula_slide_in_pos_2: float + spatula_slide_nest_1_pos: float + spatula_slide_nest_2_pos: float + spatula_slide_nest_3_pos: float + spatula_slide_nest_4_pos: float + spatula_slide_nest_5_pos: float + spatula_slide_nest_6_pos: float + spatula_slide_nest_7_pos: float + spatula_slide_nest_8_pos: float + spatula_slide_nest_9_pos: float + spatula_slide_nest_10_pos: float + spatula_slide_nest_21_pos: float + spatula_slide_nest_22_pos: float + spatula_slide_nest_23_pos: float + spatula_slide_nest_24_pos: float + spatula_slide_nest_51_pos: float + spatula_slide_nest_52_pos: float + spatula_slide_nest_61_pos: float + spatula_slide_nest_62_pos: float + spatula_slide_nest_63_pos: float + spatula_slide_nest_64_pos: float + spatula_slide_nest_65_pos: float + spatula_slide_nest_66_pos: float + spatula_slide_nest_67_pos: float + spatula_slide_nest_68_pos: float + spatula_slide_nest_69_pos: float + spatula_valve_in: int + spatula_valve_out: int + spatula_valve_hold: int + spatula_valve_brake: int + spatula_plate_sensor: int + spatula_plate_release_sensor: int + spatula_in_limit_switch: int + spatula_out_limit_switch: int + inner_user_door_sensor: int + door_open_sensor_output: int + nest_count: int + nest_1_height: float + nest_2_height: float + nest_3_height: float + nest_4_height: float + nest_5_height: float + nest_6_height: float + nest_7_height: float + nest_8_height: float + nest_9_height: float + nest_10_height: float + nest_21_height: float + nest_22_height: float + nest_23_height: float + nest_24_height: float + nest_51_height: float + nest_52_height: float + nest_61_height: float + nest_62_height: float + nest_63_height: float + nest_64_height: float + nest_65_height: float + nest_66_height: float + nest_67_height: float + nest_68_height: float + nest_69_height: float + nest_1_style: str + nest_2_style: str + nest_3_style: str + nest_4_style: str + nest_5_style: str + nest_6_style: str + nest_7_style: str + nest_8_style: str + nest_9_style: str + nest_10_style: str + nest_clearance_above: float + nest_clearance_below: float + handover_nest_clearance_above: float + handover_nest_clearance_below: float + handover_y_rotation_position: float + conveyor_clearance_above: float + conveyor_clearance_below: float + static_nest_clearance_above: float + static_nest_clearance_below: float + io_nest_clearance_above: float + io_nest_clearance_below: float + nest_1_sense_input: int + nest_2_sense_input: int + nest_3_sense_input: int + nest_4_sense_input: int + nest_5_sense_input: int + nest_6_sense_input: int + nest_7_sense_input: int + nest_8_sense_input: int + nest_9_sense_input: int + nest_10_sense_input: int + stacker_base_0: float + stacker_base_1: float + stacker_base_2: float + stacker_top_1: float + barcode_base_0: float + barcode_base_1: float + stacker_default_zero_offset: float + stacker_default_slot_height: float + stacker_default_slot_count: int + stacker_clearance_above: float + stacker_clearance_below: float + stacker_laser_to_base: float + stacker_laser_ssd_offset: float + barcode_scanner: str + barcode_laser_start: int + barcode_laser_stop: int + barcode_gain: int + barcode_velocity: float + barcode_acceleration: float + barcode_config_itf_enable: str + barcode_config_itf_status: str + barcode_config_itf_length_1: int + barcode_config_itf_length_2: int + barcode_config_itf_range: str + plate_hold_settle_time: int + plate_default_height: float + plate_default_stack_height: float + plate_default_plate_thickness: float + plate_type_errors_fatal: str + manual_big_door: str + door_0_position: float + door_height: float + door_overlap_negative: float + door_overlap_positive: float + sterilization_temperature_c: int + sterilization_soak_time_sec: int + sterilization_cool_down_time_sec: int + sterilization_guaranteed_soak_band_c: float + spin_during_sterilization: str + environment_baud: int + environment_type: str + positive_humidity_control: str + idle_spin_cutoff_temp_c: int + door_gasket_valve: int + big_door_valve: int + door_1_valve: int + door_2_valve: int + door_3_valve: int + door_4_valve: int + door_5_valve: int + door_6_valve: int + door_7_valve: int + door_8_valve: int + door_1_open_sensor: int + door_2_open_sensor: int + door_3_open_sensor: int + door_4_open_sensor: int + door_5_open_sensor: int + door_6_open_sensor: int + door_7_open_sensor: int + door_8_open_sensor: int + door_1_close_sensor: int + door_2_close_sensor: int + door_3_close_sensor: int + door_4_close_sensor: int + door_5_close_sensor: int + door_6_close_sensor: int + door_7_close_sensor: int + door_8_close_sensor: int + door_ri_open_sensor: int + door_ri_close_sensor: int + gasket_deflate_delay_ms: int + gasket_inflate_delay_ms: int + big_door_open_delay_ms: int + big_door_close_delay_ms: int + door_open_delay_ms: int + door_close_delay_ms: int + door_open_signal_address: int + door_open_signal_active_level: int + idle_close_big_door_delay_ms: int + idle_spin_carousel_delay_ms: int + carousel_idle_spin_active: str + tubepicker_z_home_speed_fast: int + tubepicker_z_home_speed_slow: int + tubepicker_z_home_acceleration: int + tubepicker_z_velocity: int + tubepicker_z_acceleration: int + tubepicker_z_abort_deceleration: int + tubepicker_z_jerk: int + tubepicker_xy_home_speed_fast: int + tubepicker_xy_home_speed_slow: int + tubepicker_xy_home_acceleration: int + tubepicker_xy_home_current_limit: int + tubepicker_xy_home_current_delay: int + tubepicker_xy_velocity: int + tubepicker_xy_acceleration: int + tubepicker_xy_abort_deceleration: int + tubepicker_xy_jerk: int + vac_1_enable: int + vac_2_enable: int + vac_1_purge: int + vac_2_purge: int + lift_1_enable: int + lift_2_enable: int + vac_1_sense: int + vac_2_sense: int + nest_1_sense: int + nest_2_sense: int + vac_3_enable: int + vac_4_enable: int + vac_3_purge: int + vac_4_purge: int + lift_3_enable: int + lift_4_enable: int + vac_3_sense: int + vac_4_sense: int + nest_3_sense: int + nest_4_sense: int + vac_5_enable: int + vac_6_enable: int + vac_5_purge: int + vac_6_purge: int + lift_5_enable: int + lift_6_enable: int + vac_5_sense: int + vac_6_sense: int + nest_5_sense: int + nest_6_sense: int + vac_7_enable: int + vac_8_enable: int + vac_7_purge: int + vac_8_purge: int + lift_7_enable: int + lift_8_enable: int + vac_7_sense: int + vac_8_sense: int + nest_7_sense: int + nest_8_sense: int + active_hotels: int + nest_rot_home_speed_fast: int + nest_rot_home_speed_slow: int + nest_rot_home_acceleration: int + nest_rot_velocity: int + nest_rot_acceleration: int + nest_rot_abort_deceleration: int + nest_rot_jerk: int + nest_rot_zero_pos: float + microspin_door_closed: int + microspin_door_open: int + microspin_spindle_home_offset: int + microspin_bucket_radius_m: float + microspin_max_velocity_rpm: int + microspin_spindle_counts_per_rev: int + microspin_spindle_position_window: int + microspin_jiggle_size: int + microspin_jiggle_count: int + microspin_door_lock_hold_count: int + microspin_door_velocity: int + microspin_door_home_velocity: int + microspin_door_accel: int + microspin_door_abort_decel: int + microspin_door_jerk: int + microspin_spindle_home_velocity: int + microspin_spindle_velocity: int + microspin_spindle_accel: float + microspin_spindle_decel: float + microspin_spindle_slow_accel: float + microspin_spindle_slow_decel: float + microspin_decel_g_threshold: float + microspin_spindle_abort_decel: float + microspin_spindle_jerk: int + microspin_spindle_max_accel: float + microspin_spindle_max_decel: float + microspin_idle_spindle_threshold: float + microspin_bucket_rise_rpm: int + microspin_plate_lock: int + microspin_door_unlock: int + microspin_plate_lock_a: int + microspin_plate_lock_b: int + microspin_door_lock_a: int + microspin_door_lock_b: int + microspin_balance_sense_a: int + microspin_balance_sense_b: int + microspin_balance_sense_c: int + pico_rot_home_speed_fast: int + pico_rot_home_speed_slow: int + pico_rot_home_acceleration: int + pico_rot_velocity: int + pico_rot_acceleration: int + pico_rot_abort_deceleration: int + pico_rot_jerk: int + pico_rot_zero_pos: int + pico_rot_counts_per_rev: int + pico_stacker_count: int + sviott_chute_y_coord: float + sv_sbs_zero_x_mm: float + sv_sbs_zero_y_mm: float + sv_sbs_zero_x_prime_mm: float + sv_sbs_zero_y_prime_mm: float + sv_sbs_zero_vial_x_mm: float + sv_sbs_zero_vial_y_mm: float + sv_sbs_zero_vial_x_prime_mm: float + sv_sbs_zero_vial_y_prime_mm: float + sv_nest_to_nest_y_spacing_mm: float + sv_tray_to_tray_y_spacing_mm: float + sv_sbs_row_spacing_mm: float + sv_sbs_col_spacing_mm: float + sv_sbs_a1_row_offset_mm: float + sv_sbs_a1_col_offset_mm: float + sv_sbs_a1_col_offset2_mm: float + sv_sbs_rows: int + sv_sbs_cols: int + sv_barcode_reader_y_coord_mm: float + sv_high_density_col_spacing_mm: float + sv_pusher_1_to_gripper_offset_mm: float + sv_pusher_2_to_gripper_offset_mm: float + sv_high_density_even_offset_mm: float + sv_high_density_rows: int + sv_high_density_cols: int + sv_nest_lock_position_1_mm: float + sv_nest_lock_position_2_mm: float + sv_magnemotion_a1_row_offset_mm: float + sv_magnemotion_a1_col_offset_mm: float + sv_magnemotion_even_offset_mm: float + sv_check_for_pick_error: int + sv_check_for_place_error: int + sviott_x_home_fast: int + sviott_x_home_slow: int + sviott_x_home_accel: int + sviott_x_home_current_ma: int + sviott_x_home_cur_delay_ms: int + sviott_x_velocity: int + sviott_x_acceleration: int + sviott_x_abort_decel: int + sviott_x_jerk: int + sviott_x_counts_per_mm: int + sviott_y_home_fast: int + sviott_y_home_slow: int + sviott_y_home_accel: int + sviott_y_home_current_ma: int + sviott_y_home_cur_delay_ms: int + sviott_y_velocity: int + sviott_y_acceleration: int + sviott_y_abort_decel: int + sviott_y_jerk: int + sviott_y_counts_per_mm: int + sviott_z_home_fast: int + sviott_z_home_slow: int + sviott_z_home_accel: int + sviott_z_home_current_ma: int + sviott_z_home_cur_delay_ms: int + sviott_z_velocity: int + sviott_z_acceleration: int + sviott_z_abort_decel: int + sviott_z_jerk: int + sviott_z_counts_per_mm: int + sviott_z_barcode_height_mm: float + sviott_z_safe_height_mm: float + sviott_z_floor_height_mm: float + sviott_tube_height_mm: float + sviott_gripper_home_fast: int + sviott_gripper_home_slow: int + sviott_gripper_home_accel: int + sviott_gripper_home_current_ma: int + sviott_gripper_home_cur_delay_ms: int + sviott_gripper_velocity: int + sviott_gripper_acceleration: int + sviott_gripper_abort_decel: int + sviott_gripper_jerk: int + sviott_gripper_counts_per_mm: float + sviott_gripper_open_size_mm: float + sviott_gripper_close_pos_mm: float + sviott_gripper_tube_hold_pos: float + sviott_vial_x_home_fast: int + sviott_vial_x_home_slow: int + sviott_vial_x_home_accel: int + sviott_vial_x_home_current_ma: int + sviott_vial_x_home_cur_delay_ms: int + sviott_vial_x_velocity: int + sviott_vial_x_acceleration: int + sviott_vial_x_abort_decel: int + sviott_vial_x_jerk: int + sviott_vial_x_counts_per_mm: int + sviott_vial_y_home_fast: int + sviott_vial_y_home_slow: int + sviott_vial_y_home_accel: int + sviott_vial_y_home_current_ma: int + sviott_vial_y_home_cur_delay_ms: int + sviott_vial_y_velocity: int + sviott_vial_y_acceleration: int + sviott_vial_y_abort_decel: int + sviott_vial_y_jerk: int + sviott_vial_y_counts_per_mm: int + sviott_convey_home_fast: int + sviott_convey_home_slow: int + sviott_convey_home_accel: int + sviott_convey_home_current_ma: int + sviott_convey_home_cur_delay_ms: int + sviott_convey_velocity: int + sviott_convey_acceleration: int + sviott_convey_abort_decel: int + sviott_convey_jerk: int + sviott_convey_counts_per_mm: int + sviott_convey_unload_pos_mm: float + sviott_convey_tube_load_pos_mm: float + sviott_barcode_home_fast: int + sviott_barcode_home_slow: int + sviott_barcode_home_accel: int + sviott_barcode_velocity: int + sviott_barcode_acceleration: int + sviott_barcode_abort_decel: int + sviott_barcode_jerk: int + sviott_barcode_counts_per_deg: int + sviott_barcode_retry: str + sviott_wiggle_mm: int + sviott_pusher1_control: int + sviott_pusher2_control: int + sviott_vial_push_velocity: int + sviott_vial_push_acceleration: int + sviott_vial_push_counts_per_mm: float + sviott_vial_push_present_mm: float + def_plate_height: float + def_stack_height: float + def_plate_thickness: float + empty_policy: str + jiggle_count: int + jiggle_size: int + has_lock_sensor: str + lock_sensor_input: int + carousel_max_position: int + carousel_max_velocity: int + carousel_max_acceleration: int + carousel_max_deceleration: int + carousel_max_jerk: int + carousel_home_pos_offset: int + carousel_home_neg_offset: float + carousel_home_spin_offset: int + carousel_homing_speed: int + carousel_home_fast: int + carousel_home_slow: int + carousel_home_accel: int + carousel_stacker_width_default: float + carousel_small_flag_width: int + carousel_large_flag_width: float + carousel_move_precision: int + carousel_width_correction_max: int + carousel_max_moves_undershot: int + carousel_ring_numbering: str + carousel_home_stacker: int + effectuator_extended_position: int + effectuator_max_position: int + effectuator_lock_position: int + effectuator_unlock_position: int + effectuator_max_velocity: int + effectuator_max_acceleration: int + effectuator_abort_deceleration: int + effectuator_max_jerk: int + effectuator_home_offset: int + effectuator_home_fast: int + effectuator_home_slow: int + effectuator_home_accel: int + effectuator_counts_per_micron: float + spatula_max_position: int + spatula_max_velocity: int + spatula_measure_velocity: int + spatula_max_acceleration: int + spatula_max_jerk: int + spatula_home_offset: int + spatula_home_fast: int + spatula_home_slow: int + spatula_home_accel: int + spatula_nest_offset: float + spatula_measurement_tolerance: float + spatula_beam_break_height: float + spatula_permitted_shortfall: int + max_transparency_width_um: int + spatula_counts_per_micron: float + spatula_lock_position: int + spatula_base_position: int + barcode_max_position: int + barcode_max_velocity: int + barcode_max_move_velocity: int + barcode_max_move_acceleration: int + barcode_abort_deceleration: int + barcode_max_move_jerk: int + barcode_max_read_velocity: int + barcode_home_offset: int + barcode_home_fast: int + barcode_home_slow: int + barcode_home_accel: int + barcode_counts_per_micron: float + spatula_slide_safe_position: float + spatula_slide_barcode_position: float + nest_safe_rotation_clearance: float + limit_x_min: float + limit_x_max: float + limit_y_min: float + limit_y_max: float + limit_z_min: float + limit_z_max: float + limit_theta_min: float + limit_theta_max: float + limit_g_min: float + limit_g_max: float + limit_gy_min: float + limit_gy_max: float + limit_p_min: float + limit_p_max: float + limit_barcode_min: float + limit_barcode_max: float + z_keep_out_1_min: float + z_keep_out_1_max: float + z_keep_out_2_min: float + z_keep_out_2_max: float + z_keep_out_3_min: float + z_keep_out_3_max: float + tundra_door_cycle_active: str + tundra_door_cycle_time_sec: int + tundra_door_cycle_open_time_sec: int + autocal_z_offset: float + spatula_slide_adjustment: float + spatula_slide_conveyor_adjustment: float + theta_hardstop_to_parallel: float + carousel_calibration_factor: float + theta_calibration_factor: float + barcode_height_adjust: float + tundra_outer_door_cycle_active: str + tundra_outer_door_cycle_time_sec: int + tundra_outer_door_cycle_open_time_sec: int + spatula_door_clearance_below: float + spatula_door_clearance_above: float + plate_trap_calibration_mode_output: int + plate_trap_calibration_input: int + theta_ccw_positive: str + plate_trap_motorized: str + plate_trap_input_status_0: int + plate_trap_input_status_1: int + plate_trap_input_status_2: int + plate_trap_ouput_command_0: int + plate_trap_ouput_command_1: int + z_axis_gain_moving_cp: int + z_axis_gain_moving_ci: int + z_axis_gain_moving_vp: int + z_axis_gain_moving_vi: int + z_axis_gain_moving_pp: int + z_axis_gain_moving_vff: int + z_axis_gain_moving_aff: int + z_axis_gain_stationary_cp: int + z_axis_gain_stationary_ci: int + z_axis_gain_stationary_vp: int + z_axis_gain_stationary_vi: int + z_axis_gain_stationary_pp: int + z_axis_gain_stationary_vff: int + z_axis_gain_stationary_aff: int + sv_24v_to_tower_input: int + sv_48v_to_tower_input: int + weigh_cell_door_output: int + weigh_cell_door_input_status_0: int + weigh_cell_door_input_status_1: int + weigh_cell_door_input_status_2: int + weigh_cell_led_red_output: int + weigh_cell_led_green_output: int + weigh_cell_led_blue_output: int + axis_x_home_speed_fast: float + axis_x_home_speed_slow: float + axis_x_home_acceleration: float + axis_x_home_offset: float + axis_x2_home_offset: float + tray_to_gantry_0_distance: float + axis_x2_calibration_adjustment: float + axis_x_calibration_pos: float + axis_x_velocity: float + axis_x_acceleration: float + axis_x_abort_deceleration: float + axis_x_jerk: float + axis_y_home_speed_fast: float + axis_y_home_speed_slow: float + axis_y_home_acceleration: float + axis_y_velocity: float + axis_y_acceleration: float + axis_y_abort_deceleration: float + axis_y_jerk: float + axis_z_home_speed_fast: float + axis_z_home_speed_slow: float + axis_z_home_acceleration: float + axis_z_home_offset: float + axis_z_home_offset_hardstop: float + axis_z_home_hardstop_current_ma: int + axis_z_home_hardstop_current_time_ms: int + axis_z_velocity: float + axis_z_acceleration: float + axis_z_abort_deceleration: float + axis_z_jerk: float + axis_barcode_home_speed_fast: float + axis_barcode_home_speed_slow: float + axis_barcode_home_acceleration: float + axis_barcode_home_offset: float + axis_barcode_velocity: float + axis_barcode_acceleration: float + axis_barcode_abort_deceleration: float + axis_barcode_jerk: float + axis_gripper_home_speed_fast: float + axis_gripper_home_speed_slow: float + axis_gripper_home_acceleration: float + axis_gripper_home_offset: float + axis_gripper_home_offset_hardstop: float + axis_gripper_home_hardstop_current_ma: int + axis_gripper_home_hardstop_current_time_ms: int + axis_gripper_close_position: float + axis_gripper_velocity: float + axis_gripper_acceleration: float + axis_gripper_abort_deceleration: float + axis_gripper_jerk: float + plate_sense_door_open_pos: float + plate_sense_z_safe_hegiht: float + height_detect_base: float + height_detect_positive_adjustment: float + height_detect_negative_adjustment: float + height_detect_enable_address: int + barcode_input_number: int + height_detect_input_number: int + stacker_code_clearance_above: float + stacker_code_clearance_below: float + stacker_code_height: float + barcode_fixture_height: float + barcode_fixture_groove_height: float + barcode_ideal_stacker_tier_1: float + barcode_ideal_stacker_tier_2: float + muting_bank_input_1: int + muting_bank_input_2: int + muting_bank_input_3: int + muting_input_1: int + muting_input_2: int + tool_head_sel_0: int + tool_head_sel_1: int + tool_head_addr_0: int + tool_head_addr_1: int + tool_head_addr_2: int + tool_head_addr_3: int + ion_bar_air_output: int + ion_bar_power_output: int + busybox_mode: str + microserve_bus_voltage_threshold: float + microserve_recover_after_estop: str + psp_packet_delay: int + home_trays_to_hardstop: str + dc_out_1_default_on: str + dc_out_2_default_on: str + dc_out_3_default_on: str + lid_discard_drop_wait_time_ms: int + lidvalet_plate_dropped_threshold: int + lidvalet_hold_time_after_unlid: int + + @classmethod + def from_lines(cls, lines: Iterable[str]) -> "HighResLidValetSettings": + """Build from the device's ``settings`` output (``NAME = value`` lines).""" + data: Dict[str, str] = {} + for line in lines: + if "=" in line: + key, _, value = line.partition("=") + data[key.strip()] = value.strip() + + values: Dict[str, Any] = {} + missing = [] + for f in fields(cls): + key = f.name.upper() + if key not in data: + missing.append(key) + continue + if f.type is int: + values[f.name] = int(data[key]) + elif f.type is float: + values[f.name] = float(data[key]) + else: + values[f.name] = data[key] + if missing: + raise ValueError( + f"settings is missing {len(missing)} expected key(s): " + + ", ".join(missing[:8]) + + ("..." if len(missing) > 8 else "") + ) + machine_type = values["machine_type"] + if machine_type not in KNOWN_MACHINE_TYPES: + warnings.warn( + f"unknown LidValet model {machine_type!r}; please contribute it to " + "MachineType / KNOWN_MACHINE_TYPES", + stacklevel=2, + ) + return cls(**values) diff --git a/pylabrobot/inheco/__init__.py b/pylabrobot/inheco/__init__.py new file mode 100644 index 00000000000..95af4f51c0e --- /dev/null +++ b/pylabrobot/inheco/__init__.py @@ -0,0 +1,9 @@ +from .control_box import InhecoTECControlBox +from .cpac import InhecoCPAC, inheco_cpac_ultraflat +from .temperature_controller import InhecoTemperatureController +from .thermoshake import ( + InhecoThermoShake, + inheco_thermoshake, + inheco_thermoshake_ac, + inheco_thermoshake_rm, +) diff --git a/pylabrobot/inheco/control_box.py b/pylabrobot/inheco/control_box.py new file mode 100644 index 00000000000..3437ac864b6 --- /dev/null +++ b/pylabrobot/inheco/control_box.py @@ -0,0 +1,143 @@ +import time +import typing + +from pylabrobot.io.hid import HID + + +class InhecoTECControlBox: + def __init__( + self, + vid=0x03EB, + pid=0x2023, + serial_number=None, + ): + self.io = HID( + human_readable_device_name="Inheco TEC Control Box", + vid=vid, + pid=pid, + serial_number=serial_number, + ) + + async def setup(self): + """ + If io.setup() fails, ensure that libusb drivers were installed as per docs. + """ + await self.io.setup() + + async def stop(self): + await self.io.stop() + + @typing.no_type_check + def _generate_packets(self, msg): + """Generate packets for the given message. + + Splits the message into packets of 7 bytes each. The last packet contains the checksum. + """ + + ch_array1 = [None] * 128 + report_buffer = [0] * 9 + crc = 161 + for i, char in enumerate(msg): + ch_array1[i] = char if ord(char) <= ord("`") or char == "#" else chr(ord(char) - 32) + if ch_array1[i] != "#": + crc = self._crc8(ord(ch_array1[i]), crc=crc) + ch_array1[len(msg)] = "w" if crc in (35, 0) else chr(crc) + num1 = len(msg) + 1 + if num1 < 2: + report_buffer[0] = 0 + if num1 <= 8: + for i in range(num1): + report_buffer[i + 1] = ord(ch_array1[i]) + return [report_buffer] + else: + num2 = (num1 - 1) // 7 # number of packets + command = [] + for index1 in range(num2 + 1): + for index2 in range(7): + if index2 + index1 * 7 >= num1: + report_buffer[index2 + 1] = 0 + else: + report_buffer[index2 + 1] = ord(ch_array1[index2 + index1 * 7]) + if index1 != num2: + report_buffer[8] = ord("#") + else: + if (num2 + 1) * 7 + 1 == num1: + report_buffer[8] = ord(ch_array1[num1 - 1]) + if report_buffer[8] > 96: + report_buffer[8] -= 32 + else: + report_buffer[8] = 0 + command.append(report_buffer) + return command + + def _crc8(self, data, crc: int) -> int: + """Meme crc8 implementation""" + num = 8 + while num > 0: + if ((data ^ crc) & 1) == 1: + crc ^= 24 + crc >>= 1 + crc |= 128 + else: + crc >>= 1 + data >>= 1 + num -= 1 + return crc + + async def _read_until_end(self, timeout: int) -> str: + """Read until a packet ends with a \\x00 byte. May read multiple packets.""" + start = time.time() + response = b"" + while time.time() - start < timeout: + packet = await self.io.read(64, timeout=timeout) + if packet is not None and packet != b"": + if packet.endswith(b"\x00"): + response += packet.rstrip(b"\x00") # strip trailing \x00's + break + elif packet.endswith(b"#"): + response += packet[:-1] + continue + else: + # I have never seen this happen, commands always end with \x00 or '#' + print("weird packet, please report", packet) + response += packet + + return response.decode("unicode_escape") + + async def _read_response(self, command: str, timeout: int = 60) -> str: + """Read the response for a given command. + + "The MTC/STC replies to the first four characters of every command with a modified echo. The + modification changes the capitals of the commands to small letters. i.e. the reply to 5ASE1 + is 5ase0. Therefore it is easy to identify correct answers to the commands. This feature may + increase integrity of the communication." + """ + + start = time.time() + while time.time() - start < timeout: + response = await self._read_until_end(timeout=int(timeout - (time.time() - start))) + + if response[:4] == command[:4].lower(): + return response + + raise TimeoutError("Timeout while waiting for response from device.") + + async def send_command(self, command: str, timeout: int = 3): + """Send a command to the device and return the response""" + packets = self._generate_packets(command) + for packet in packets: + await self.io.write(bytes(packet[1:]), report_id=bytes(packet[0])) + + response = await self._read_response(command, timeout=timeout) + + if response[4] != "0": + raise RuntimeError(f"Error response from device: {response}") + + return response[5:-1] # cut off command, error status, and final checksum byte + + async def set_touchscreen(self, active: bool): + await self.send_command(f"0ADD{1 if active else 0}") + await self.reset_action_display() + + async def reset_action_display(self): + return await self.send_command("0ASD") diff --git a/pylabrobot/inheco/cpac.py b/pylabrobot/inheco/cpac.py new file mode 100644 index 00000000000..b2d9f3fe081 --- /dev/null +++ b/pylabrobot/inheco/cpac.py @@ -0,0 +1,61 @@ +from typing import Optional + +from pylabrobot.resources import Coordinate, ResourceHolder + +from .control_box import InhecoTECControlBox +from .temperature_controller import InhecoTemperatureController + + +class InhecoCPAC(ResourceHolder, InhecoTemperatureController): + """Inheco CPAC: a temperature-controlled plate holder addressed by index on a control box.""" + + def __init__( + self, + index: int, + name: str, + size_x: float, + size_y: float, + size_z: float, + control_box: InhecoTECControlBox, + child_location: Coordinate, + category: str = "temperature_controller", + model: Optional[str] = None, + ): + ResourceHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + child_location=child_location, + category=category, + model=model, + ) + InhecoTemperatureController.__init__(self, index=index, interface=control_box) + + +def inheco_cpac_ultraflat(name: str, control_box: InhecoTECControlBox, index: int) -> InhecoCPAC: + """Inheco CPAC Ultraflat + 7000166, 7000190, 7000165 + + https://www.inheco.com/data/pdf/cpac-brochure-1013-1032-34.pdf + + Example: + >>> from pylabrobot.inheco import inheco_cpac_ultraflat + >>> await box.setup() + >>> cpac = inheco_cpac_ultraflat("cpac", control_box=box, index=1) + >>> await cpac.set_temperature(37.0) + >>> await cpac.request_current_temperature() + 37.0 + """ + + return InhecoCPAC( + name=name, + control_box=control_box, + index=index, + size_x=113, # from spec + size_y=89, # from spec + size_z=129, # from spec + child_location=Coordinate(x=8, y=11, z=77), # x from spec, y and z measured + model=inheco_cpac_ultraflat.__name__, + ) diff --git a/pylabrobot/inheco/scila/__init__.py b/pylabrobot/inheco/scila/__init__.py new file mode 100644 index 00000000000..49b9f412f7c --- /dev/null +++ b/pylabrobot/inheco/scila/__init__.py @@ -0,0 +1 @@ +from .scila import SCILA, DrawerStatus, SCILADrawerLoadingTray diff --git a/pylabrobot/inheco/scila/scila.py b/pylabrobot/inheco/scila/scila.py new file mode 100644 index 00000000000..60ae990047d --- /dev/null +++ b/pylabrobot/inheco/scila/scila.py @@ -0,0 +1,214 @@ +import logging +from typing import Any, Dict, Literal, Optional + +from pylabrobot.inheco.transport.sila import InhecoSiLAInterface, get_params +from pylabrobot.resources import Coordinate, Resource +from pylabrobot.resources.resource_holder import ResourceHolder + +logger = logging.getLogger(__name__) + + +DrawerStatus = Literal["Opened", "Closed"] + + +class SCILADrawerLoadingTray(ResourceHolder): + """One of the SCILA's four drawers: a loading tray that opens and closes.""" + + def __init__( + self, + scila: "SCILA", + drawer_id: int, + name: str, + size_x: float, + size_y: float, + size_z: float, + child_location: Coordinate = Coordinate.zero(), + category: str = "loading_tray", + model: Optional[str] = None, + ): + if drawer_id not in {1, 2, 3, 4}: + raise ValueError(f"Invalid drawer ID: {drawer_id}. Must be 1, 2, 3, or 4.") + ResourceHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + child_location=child_location, + category=category, + model=model, + ) + self._scila = scila + self._drawer_id = drawer_id + + async def open(self): + await self._scila.send_command("PrepareForInput", position=self._drawer_id) + try: + await self._scila.send_command("OpenDoor") + except RuntimeError as e: + self._handle_warning("open", e) + + async def close(self): + await self._scila.send_command("PrepareForOutput", position=self._drawer_id) + try: + await self._scila.send_command("CloseDoor") + except RuntimeError as e: + self._handle_warning("close", e) + + def _handle_warning(self, action: str, e: RuntimeError) -> None: + if "warning" not in str(e).lower(): + raise e + # SCILA emits a non-fatal CO2-flow warning when the gas mixer is absent or unhealthy. + # When the user has declared no gas mixer is connected, silence it; otherwise surface + # the warning so a real CO2 problem isn't hidden. + if self._scila.gas_mixer_connected: + logger.warning("drawer %d %s: %s", self._drawer_id, action, e) + + +class SCILA(Resource): + """Inheco SCILA incubator: four drawers and temperature control, over SiLA. + + Owns the SiLA HTTP/SOAP connection and exposes the device's operations directly. + + Example: + >>> from pylabrobot.inheco.scila import SCILA + >>> scila = SCILA(name="scila", scila_ip="169.254.1.117") + >>> await scila.setup() + >>> await scila.set_temperature(37.0) + >>> await scila.drawers[2].open() + """ + + def __init__( + self, + name: str, + scila_ip: str, + client_ip: Optional[str] = None, + gas_mixer_connected: bool = True, + size_x: float = 0.0, # TODO: measure + size_y: float = 0.0, # TODO: measure + size_z: float = 0.0, # TODO: measure + ): + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Inheco SCILA", + ) + self._sila_interface = InhecoSiLAInterface(client_ip=client_ip, machine_ip=scila_ip) + self.gas_mixer_connected = gas_mixer_connected + + self.drawers: Dict[int, SCILADrawerLoadingTray] = {} + for drawer_id in range(1, 5): + tray = SCILADrawerLoadingTray( + scila=self, + drawer_id=drawer_id, + name=f"{name}_drawer_{drawer_id}", + size_x=0.0, # TODO: measure + size_y=0.0, # TODO: measure + size_z=0.0, # TODO: measure + child_location=Coordinate.zero(), # TODO: measure + ) + self.drawers[drawer_id] = tray + self.assign_child_resource(tray, location=Coordinate.zero()) # TODO: measure + + # -- lifecycle -- + + async def setup(self) -> None: + await self._sila_interface.setup() + await self._reset_and_initialize() + logger.info("[SCILA %s] connected", self._sila_interface.machine_ip) + + async def stop(self) -> None: + await self._sila_interface.close() + logger.info("[SCILA %s] connection closed", self._sila_interface.machine_ip) + + async def send_command(self, command: str, **kwargs) -> Any: + """Send a SiLA command and return the parsed response.""" + return await self._sila_interface.send_command(command, **kwargs) + + async def _reset_and_initialize(self) -> None: + event_uri = f"http://{self._sila_interface.client_ip}:{self._sila_interface.bound_port}/" + await self.send_command( + command="Reset", deviceId="MyController", eventReceiverURI=event_uri, simulationMode=False + ) + await self.send_command("Initialize") + + # -- status queries -- + + async def request_status(self) -> str: + resp = await self.send_command("GetStatus") + return resp.get("GetStatusResponse", {}).get("state", "Unknown") # type: ignore + + async def request_liquid_level(self) -> str: + root = await self.send_command("GetLiquidLevel") + return get_params(root, ["LiquidLevel"])["LiquidLevel"] # type: ignore + + # -- drawers -- + + async def request_drawer_statuses(self) -> Dict[int, DrawerStatus]: + root = await self.send_command("GetDoorStatus") + params = get_params(root, ["Drawer1", "Drawer2", "Drawer3", "Drawer4"]) + return {i: params[f"Drawer{i}"] for i in range(1, 5)} # type: ignore + + async def request_drawer_status(self, drawer_id: int) -> DrawerStatus: + if drawer_id not in {1, 2, 3, 4}: + raise ValueError(f"Invalid drawer ID: {drawer_id}. Must be 1, 2, 3, or 4.") + positions = await self.request_drawer_statuses() + return positions[drawer_id] + + # -- CO2 / valves -- + + async def request_co2_flow_status(self) -> str: + root = await self.send_command("GetCO2FlowStatus") + return get_params(root, ["CO2FlowStatus"])["CO2FlowStatus"] # type: ignore + + async def request_valve_status(self) -> dict[str, str]: + root = await self.send_command("GetValveStatus") + return get_params(root, ["H2O", "CO2 Normal", "CO2 Boost"]) # type: ignore + + # -- temperature control -- + + @property + def supports_active_cooling(self) -> bool: + return False + + async def request_temperature_information(self) -> dict[str, Any]: + root = await self.send_command("GetTemperature") + return get_params(root, ["CurrentTemperature", "TargetTemperature", "TemperatureControl"]) # type: ignore + + async def set_temperature(self, temperature: float) -> None: + logger.info( + "[SCILA %s] set temperature: target=%.1f C", + self._sila_interface.machine_ip, + temperature, + ) + await self.send_command( + "SetTemperature", targetTemperature=temperature, temperatureControl=True + ) + + async def request_current_temperature(self) -> float: + temp: float = (await self.request_temperature_information())["CurrentTemperature"] # type: ignore[index] + logger.info("[SCILA %s] read temperature: actual=%.1f C", self._sila_interface.machine_ip, temp) + return temp + + async def deactivate(self) -> None: + logger.info("[SCILA %s] deactivate temperature control", self._sila_interface.machine_ip) + await self.send_command("SetTemperature", temperatureControl=False) + + async def request_target_temperature(self) -> float: + return (await self.request_temperature_information())["TargetTemperature"] # type: ignore + + async def is_temperature_control_enabled(self) -> bool: + return (await self.request_temperature_information())["TemperatureControl"] # type: ignore + + # -- serialization -- + + def serialize(self) -> dict[str, Any]: + return { + **super().serialize(), + "scila_ip": self._sila_interface.machine_ip, + "client_ip": self._sila_interface.client_ip, + "gas_mixer_connected": self.gas_mixer_connected, + } diff --git a/pylabrobot/inheco/scila/scila_tests.py b/pylabrobot/inheco/scila/scila_tests.py new file mode 100644 index 00000000000..2956d43877a --- /dev/null +++ b/pylabrobot/inheco/scila/scila_tests.py @@ -0,0 +1,235 @@ +import logging +import unittest +import xml.etree.ElementTree as ET +from unittest.mock import AsyncMock, patch + +from pylabrobot.inheco.scila.scila import SCILA, SCILADrawerLoadingTray +from pylabrobot.inheco.transport.sila import InhecoSiLAInterface + +_TEMPERATURE_RESPONSE = ( + "" + " 25.0" + " 37.0" + " true" + "" +) + + +class _SCILATestCase(unittest.IsolatedAsyncioTestCase): + """Builds a SCILA whose SiLA transport is mocked.""" + + def setUp(self): + self.patcher = patch("pylabrobot.inheco.scila.scila.InhecoSiLAInterface") + self.MockInhecoSiLAInterface = self.patcher.start() + self.mock_sila_interface = AsyncMock(spec=InhecoSiLAInterface) + self.mock_sila_interface.bound_port = 80 + self.mock_sila_interface.client_ip = "127.0.0.1" + self.MockInhecoSiLAInterface.return_value = self.mock_sila_interface + self.scila = SCILA(name="scila", scila_ip="127.0.0.1") + + def tearDown(self): + self.patcher.stop() + + +class TestSCILA(_SCILATestCase): + async def test_setup(self): + await self.scila.setup() + self.mock_sila_interface.setup.assert_called_once() + + async def test_stop(self): + await self.scila.stop() + self.mock_sila_interface.close.assert_called_once() + + async def test_request_status(self): + self.mock_sila_interface.send_command.return_value = {"GetStatusResponse": {"state": "Standby"}} + self.assertEqual(await self.scila.request_status(), "Standby") + self.mock_sila_interface.send_command.assert_called_with("GetStatus") + + async def test_request_liquid_level(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "Ok" + ) + self.assertEqual(await self.scila.request_liquid_level(), "Ok") + self.mock_sila_interface.send_command.assert_called_with("GetLiquidLevel") + + async def test_request_drawer_status(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " Closed" + " Opened" + " Closed" + " Closed" + "" + ) + self.assertEqual( + await self.scila.request_drawer_statuses(), + {1: "Closed", 2: "Opened", 3: "Closed", 4: "Closed"}, + ) + self.mock_sila_interface.send_command.assert_called_with("GetDoorStatus") + + async def test_request_drawer_status_single(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " Closed" + " Opened" + " Closed" + " Closed" + "" + ) + self.assertEqual(await self.scila.request_drawer_status(2), "Opened") + + async def test_request_drawer_status_invalid_id(self): + with self.assertRaises(ValueError): + await self.scila.request_drawer_status(5) + + async def test_request_co2_flow_status(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "Ok" + ) + self.assertEqual(await self.scila.request_co2_flow_status(), "Ok") + self.mock_sila_interface.send_command.assert_called_with("GetCO2FlowStatus") + + async def test_request_valve_status(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " Open" + " Closed" + " Closed" + "" + ) + self.assertEqual( + await self.scila.request_valve_status(), + {"H2O": "Open", "CO2 Normal": "Closed", "CO2 Boost": "Closed"}, + ) + self.mock_sila_interface.send_command.assert_called_with("GetValveStatus") + + def test_serialize(self): + self.mock_sila_interface.machine_ip = "169.254.1.117" + self.mock_sila_interface.client_ip = "192.168.1.10" + data = self.scila.serialize() + self.assertEqual(data["scila_ip"], "169.254.1.117") + self.assertEqual(data["client_ip"], "192.168.1.10") + self.assertIs(data["gas_mixer_connected"], True) + + def test_serialize_no_client_ip(self): + self.mock_sila_interface.machine_ip = "127.0.0.1" + self.mock_sila_interface.client_ip = None + data = self.scila.serialize() + self.assertEqual(data["scila_ip"], "127.0.0.1") + self.assertIsNone(data["client_ip"]) + + def test_serialize_no_gas_mixer(self): + scila = SCILA(name="scila2", scila_ip="127.0.0.1", gas_mixer_connected=False) + self.assertIs(scila.gas_mixer_connected, False) + self.assertIs(scila.serialize()["gas_mixer_connected"], False) + + +class TestSCILATemperature(_SCILATestCase): + async def test_request_temperature_information(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring(_TEMPERATURE_RESPONSE) + self.assertEqual( + await self.scila.request_temperature_information(), + {"CurrentTemperature": 25.0, "TargetTemperature": 37.0, "TemperatureControl": True}, + ) + self.mock_sila_interface.send_command.assert_called_with("GetTemperature") + + async def test_request_current_temperature(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring(_TEMPERATURE_RESPONSE) + self.assertEqual(await self.scila.request_current_temperature(), 25.0) + + async def test_request_target_temperature(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring(_TEMPERATURE_RESPONSE) + self.assertEqual(await self.scila.request_target_temperature(), 37.0) + + async def test_is_temperature_control_enabled(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring(_TEMPERATURE_RESPONSE) + self.assertIs(await self.scila.is_temperature_control_enabled(), True) + + async def test_set_temperature(self): + await self.scila.set_temperature(30.0) + self.mock_sila_interface.send_command.assert_called_with( + "SetTemperature", targetTemperature=30.0, temperatureControl=True + ) + + async def test_deactivate(self): + await self.scila.deactivate() + self.mock_sila_interface.send_command.assert_called_with( + "SetTemperature", temperatureControl=False + ) + + def test_supports_active_cooling(self): + self.assertFalse(self.scila.supports_active_cooling) + + +class TestSCILADrawerLoadingTray(_SCILATestCase): + async def test_open(self): + for drawer_id in [1, 2, 3, 4]: + with self.subTest(drawer_id=drawer_id): + self.mock_sila_interface.send_command.reset_mock() + await self.scila.drawers[drawer_id].open() + self.mock_sila_interface.send_command.assert_any_call("PrepareForInput", position=drawer_id) + self.mock_sila_interface.send_command.assert_any_call("OpenDoor") + + async def test_close(self): + for drawer_id in [1, 2, 3, 4]: + with self.subTest(drawer_id=drawer_id): + self.mock_sila_interface.send_command.reset_mock() + await self.scila.drawers[drawer_id].close() + self.mock_sila_interface.send_command.assert_any_call( + "PrepareForOutput", position=drawer_id + ) + self.mock_sila_interface.send_command.assert_any_call("CloseDoor") + + async def test_open_co2_warning_logged_when_gas_mixer_connected(self): + self.scila.gas_mixer_connected = True + + async def side_effect(cmd, **kw): + if cmd == "OpenDoor": + raise RuntimeError("command OpenDoor failed with code 2: 'Warning: CO2 flow NOK'") + + self.mock_sila_interface.send_command.side_effect = side_effect + with self.assertLogs("pylabrobot.inheco.scila.scila", level="WARNING") as logs: + await self.scila.drawers[1].open() + self.assertTrue(any("drawer 1 open" in m for m in logs.output)) + + async def test_open_co2_warning_silenced_when_gas_mixer_not_connected(self): + self.scila.gas_mixer_connected = False + + async def side_effect(cmd, **kw): + if cmd == "OpenDoor": + raise RuntimeError("command OpenDoor failed with code 2: 'Warning: CO2 flow NOK'") + + self.mock_sila_interface.send_command.side_effect = side_effect + # assertNoLogs needs Python 3.10; emit a sentinel so assertLogs has something + # to capture and assert the drawer added nothing of its own. + logger_name = "pylabrobot.inheco.scila.scila" + with self.assertLogs(logger_name, level="WARNING") as captured: + logging.getLogger(logger_name).warning("sentinel") + await self.scila.drawers[1].open() + self.assertEqual(captured.output, [f"WARNING:{logger_name}:sentinel"]) + + async def test_open_non_warning_error_always_raises(self): + self.scila.gas_mixer_connected = False # most permissive setting + + async def side_effect(cmd, **kw): + if cmd == "OpenDoor": + raise RuntimeError("command OpenDoor failed with code 4: 'Door obstructed'") + + self.mock_sila_interface.send_command.side_effect = side_effect + with self.assertRaises(RuntimeError): + await self.scila.drawers[1].open() + + def test_invalid_drawer_id(self): + with self.assertRaises(ValueError): + SCILADrawerLoadingTray( + scila=self.scila, + drawer_id=5, + name="bad_drawer", + size_x=0.0, + size_y=0.0, + size_z=0.0, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/storage/inheco/scila/soap.py b/pylabrobot/inheco/scila/soap.py similarity index 100% rename from pylabrobot/storage/inheco/scila/soap.py rename to pylabrobot/inheco/scila/soap.py diff --git a/pylabrobot/inheco/temperature_controller.py b/pylabrobot/inheco/temperature_controller.py new file mode 100644 index 00000000000..b8632347e59 --- /dev/null +++ b/pylabrobot/inheco/temperature_controller.py @@ -0,0 +1,60 @@ +import logging + +from pylabrobot.inheco.control_box import InhecoTECControlBox + +logger = logging.getLogger(__name__) + + +class InhecoTemperatureController: + def __init__(self, index: int, interface: InhecoTECControlBox): + self.index = index + self.interface = interface + if not (1 <= index <= 6): + raise ValueError("Index must be between 1 and 6 (inclusive)") + + @property + def supports_active_cooling(self) -> bool: + return True + + # -- temperature control + + async def set_temperature(self, temperature: float): + logger.info("[Inheco idx=%d] setting temperature to %.1f C", self.index, temperature) + await self._set_target_temperature(temperature) + await self._start_temperature_control() + + async def request_current_temperature(self) -> float: + response = await self.interface.send_command(f"{self.index}RAT0") + temp = float(response) / 10 + logger.info("[Inheco idx=%d] read temperature: actual=%.1f C", self.index, temp) + return temp + + async def stop_temperature_control(self): + """Stop the temperature control""" + logger.info("[Inheco idx=%d] stopping temperature control", self.index) + return await self.interface.send_command(f"{self.index}ATE0") + + # --- firmware temp + + async def _set_target_temperature(self, temperature: float): + temperature = int(temperature * 10) + await self.interface.send_command(f"{self.index}STT{temperature}") + + async def _start_temperature_control(self): + return await self.interface.send_command(f"{self.index}ATE1") + + # --- firmware misc + + async def request_device_info(self, info_type: int): + """Get device information + + - 0 Bootstrap Version + - 1 Application Version + - 2 Serial number + - 3 Current hardware version + - 4 INHECO copyright + """ + + if info_type not in range(5): + raise ValueError("Info type must be in the range 0 to 4") + return await self.interface.send_command(f"{self.index}RFV{info_type}") diff --git a/pylabrobot/inheco/thermoshake.py b/pylabrobot/inheco/thermoshake.py new file mode 100644 index 00000000000..8536c0ac3bd --- /dev/null +++ b/pylabrobot/inheco/thermoshake.py @@ -0,0 +1,147 @@ +import asyncio +import logging +from typing import Optional + +from pylabrobot.resources import Coordinate, ResourceHolder + +from .control_box import InhecoTECControlBox +from .temperature_controller import InhecoTemperatureController + +logger = logging.getLogger(__name__) + + +class InhecoThermoShake(ResourceHolder, InhecoTemperatureController): + """Inheco ThermoShake: a temperature-controlled, shaking plate holder addressed by index on a + control box. + + https://www.inheco.com/thermoshake-ac.html + """ + + def __init__( + self, + index: int, + control_box: InhecoTECControlBox, + name: str, + size_x: float, + size_y: float, + size_z: float, + child_location: Coordinate, + category: str = "heating_shaking", + model: Optional[str] = None, + ): + ResourceHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + child_location=child_location, + category=category, + model=model, + ) + InhecoTemperatureController.__init__(self, index=index, interface=control_box) + + async def _start_shaking_command(self): + return await self.interface.send_command(f"{self.index}ASE1") + + async def stop_shaking(self): + logger.info("[Inheco ThermoShake idx=%d] stop shaking", self.index) + return await self.interface.send_command(f"{self.index}ASE0") + + async def set_shaker_speed(self, speed: float): + if not (60 <= speed <= 2000): + raise ValueError("Speed must be in the range 60 to 2000 RPM") + return await self.interface.send_command(f"{self.index}SSR{speed}") + + async def set_shaker_shape(self, shape: int): + """Set the shaking shape. + + Args: + shape: 0 = Circle anticlockwise, 1 = Circle clockwise, 2 = Up left down right, + 3 = Up right down left, 4 = Up-down, 5 = Left-right + """ + if shape not in range(6): + raise ValueError("Shape must be in the range 0 to 5") + return await self.interface.send_command(f"{self.index}SSS{shape}") + + async def start_shaking(self, speed: float, shape: int = 0): + logger.info( + "[Inheco ThermoShake idx=%d] start shaking: speed=%.0f, shape=%d", self.index, speed, shape + ) + await self.set_shaker_speed(speed=speed) + await self.set_shaker_shape(shape=shape) + await self._start_shaking_command() + + async def shake(self, speed: float, duration: float): + await self.start_shaking(speed=speed) + try: + await asyncio.sleep(duration) + finally: + await self.stop_shaking() + + +def inheco_thermoshake_ac( + name: str, control_box: InhecoTECControlBox, index: int +) -> InhecoThermoShake: + """Inheco Thermoshake AC + + 7100160, 7100161 + + https://www.inheco.com/thermoshake-ac.html + """ + + raise NotImplementedError("Inheco ThermoShake AC is missing child_location.") + + return InhecoThermoShake( + name=name, + control_box=control_box, + index=index, + size_x=147, # from spec + size_y=104, # from spec + size_z=115.9, # from spec + child_location=Coordinate(x=0, y=0, z=109.9), # TODO + model=inheco_thermoshake_ac.__name__, + ) + + +def inheco_thermoshake( + name: str, control_box: InhecoTECControlBox, index: int +) -> InhecoThermoShake: + """Inheco Thermoshake (7100146) + + https://www.inheco.com/thermoshake-classic.html + """ + + return InhecoThermoShake( + name=name, + control_box=control_box, + index=index, + size_x=147, # from spec + size_y=104, # from spec + size_z=118, # from spec + child_location=Coordinate(x=9.62, y=9.22, z=109.9), # measured + model=inheco_thermoshake.__name__, + # pedestal_size_z=-4.2, # measured + ) + + +def inheco_thermoshake_rm( + name: str, control_box: InhecoTECControlBox, index: int +) -> InhecoThermoShake: + """Inheco Thermoshake RM (7100144) + + https://www.inheco.com/thermoshake-classic.html + """ + + raise NotImplementedError("Inheco Thermoshake RM is missing child_location") + + return InhecoThermoShake( + name=name, + control_box=control_box, + index=index, + size_x=147, # from spec + size_y=104, # from spec + size_z=116, # from spec + child_location=Coordinate(x=0, y=0, z=0), # TODO + model=inheco_thermoshake_rm.__name__, + ) diff --git a/pylabrobot/inheco/transport/__init__.py b/pylabrobot/inheco/transport/__init__.py new file mode 100644 index 00000000000..d11059684e0 --- /dev/null +++ b/pylabrobot/inheco/transport/__init__.py @@ -0,0 +1 @@ +from .sila import InhecoSiLAInterface, SiLAError diff --git a/pylabrobot/inheco/transport/sila.py b/pylabrobot/inheco/transport/sila.py new file mode 100644 index 00000000000..f36cffd0fc6 --- /dev/null +++ b/pylabrobot/inheco/transport/sila.py @@ -0,0 +1,333 @@ +"""Inheco flavor of sila""" + +from __future__ import annotations + +import asyncio +import http.server +import logging +import random +import socket +import socketserver +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from typing import Any, Optional, Tuple + +from pylabrobot.inheco.scila.soap import XSI, soap_decode, soap_encode + +SOAP_RESPONSE_ResponseEventResponse = """ + + + + 1 + Success + PT0.0006262S + 0 + + + +""" + + +SOAP_RESPONSE_StatusEventResponse = """ + + + + 1 + Success + PT0.0005967S + 0 + + + +""" + + +def _get_local_ip(machine_ip: str) -> str: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + # Doesn't actually connect, just determines the route + s.connect((machine_ip, 1)) + local_ip: str = s.getsockname()[0] # type: ignore + if local_ip is None or local_ip.startswith("127."): + raise RuntimeError("Could not determine local IP address.") + finally: + s.close() + return local_ip + + +class SiLAError(RuntimeError): + def __init__(self, code: int, message: str, command: str, details: Optional[dict] = None): + self.code = code + self.message = message + self.command = command + self.details = details or {} + + +class InhecoSiLAInterface: + @dataclass(frozen=True) + class _HTTPRequest: + method: str + path: str + query: str + headers: dict[str, str] + body: bytes + + @dataclass(frozen=True) + class _SiLACommand: + name: str + request_id: int + fut: asyncio.Future[Any] + + def __init__( + self, + machine_ip: str, + client_ip: Optional[str] = None, + logger: Optional[logging.Logger] = None, + ) -> None: + self._client_ip = client_ip or _get_local_ip(machine_ip) + self._machine_ip = machine_ip + self._logger = logger or logging.getLogger(__name__) + + # single "in-flight token" + self._making_request = asyncio.Lock() + + # pending command information + self._pending: Optional[InhecoSiLAInterface._SiLACommand] = None + + # server plumbing + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._httpd: Optional[socketserver.TCPServer] = None + self._server_task: Optional[asyncio.Task[None]] = None + self._closed = False + + @property + def client_ip(self) -> str: + return self._client_ip + + @property + def machine_ip(self) -> str: + return self._machine_ip + + @property + def bound_port(self) -> int: + if self._httpd is None: + raise RuntimeError("Server not started yet") + return self._httpd.server_address[1] + + async def start(self) -> None: + if self._httpd is not None: + return + if self._closed: + raise RuntimeError("Bridge is closed") + + self._loop = asyncio.get_running_loop() + outer = self + + class _Handler(http.server.BaseHTTPRequestHandler): + server_version = "OneInFlightHTTPBridge/0.1" + + def log_message(self, fmt: str, *args) -> None: + return # silence + + def _read_body(self) -> bytes: + length = int(self.headers.get("Content-Length", "0") or "0") + return self.rfile.read(length) if length else b"" + + def _do(self) -> None: + assert outer._loop is not None + + parsed = urllib.parse.urlsplit(self.path) + req = InhecoSiLAInterface._HTTPRequest( + method=self.command, + path=parsed.path, + query=parsed.query, + headers={k.lower(): v for k, v in self.headers.items()}, + body=self._read_body(), + ) + + fut = asyncio.run_coroutine_threadsafe(outer._on_http(req), outer._loop) + try: + resp_body = fut.result() + status = 200 + except Exception as e: + resp_body = f"Internal Server Error: {type(e).__name__}: {e}\n".encode() + status = 500 + + self.send_response(status) + self.send_header("Content-Type", "text/xml; charset=utf-8") + self.send_header("Content-Length", str(len(resp_body))) + self.end_headers() + self.wfile.write(resp_body) + + def do_GET(self) -> None: + self._do() + + def do_POST(self) -> None: + self._do() + + def do_PUT(self) -> None: + self._do() + + def do_DELETE(self) -> None: + self._do() + + # port 0: choose random free port + self._httpd = http.server.ThreadingHTTPServer((self._client_ip, 0), _Handler) + + async def run_server() -> None: + assert self._httpd is not None + await asyncio.to_thread(self._httpd.serve_forever) + + self._server_task = asyncio.create_task(run_server(), name="http-server") + + async def close(self) -> None: + self._closed = True + if self._httpd is None: + return + + self._httpd.shutdown() + self._httpd.server_close() + + if self._server_task is not None: + await self._server_task + + self._httpd = None + self._server_task = None + + async def _on_http(self, req: _HTTPRequest) -> bytes: + """ + Called on the asyncio loop for every incoming HTTP request. + If there's a pending command, try to match and resolve it. + """ + + cmd = self._pending + + if cmd is not None and not cmd.fut.done(): + response_event = soap_decode(req.body.decode("utf-8")) + if "ResponseEvent" in response_event: + request_id = response_event["ResponseEvent"].get("requestId") + if request_id != cmd.request_id: + self._logger.warning("Request ID does not match pending command.") + else: + return_value = response_event["ResponseEvent"].get("returnValue", {}) + return_code = return_value.get("returnCode") + if return_code != 3: # error + err_msg = return_value.get("message", "Unknown error").replace("\n", " ") + cmd.fut.set_exception( + RuntimeError(f"Command {cmd.name} failed with code {return_code}: '{err_msg}'") + ) + else: + response_data = response_event["ResponseEvent"].get("responseData", "") + root = ET.fromstring(response_data) + cmd.fut.set_result(root) + else: + self._logger.warning("No pending command to match response to.") + + if "ResponseEvent" in req.body.decode("utf-8"): + return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") + if "StatusEvent" in req.body.decode("utf-8"): + return SOAP_RESPONSE_StatusEventResponse.encode("utf-8") + self._logger.warning("Unknown event type received.") + return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") + + def _get_return_code_and_message(self, command_name: str, response: Any) -> Tuple[int, str]: + resp_level = response.get(f"{command_name}Response", {}) # first level + result_level = resp_level.get(f"{command_name}Result", {}) # second level + return_code = result_level.get("returnCode") + if return_code is None: + raise ValueError(f"returnCode not found in response for {command_name}") + return return_code, result_level.get("message", "") + + async def setup(self) -> None: + await self.start() + + def _make_request_id(self): + return random.randint(1, 2**31 - 1) + + async def send_command( + self, + command: str, + **kwargs, + ) -> Any: + if self._closed: + raise RuntimeError("Bridge is closed") + + request_id = self._make_request_id() + cmd_xml = soap_encode( + command, + {"requestId": request_id, **kwargs}, + method_ns="http://sila.coop", + extra_method_xmlns={"i": XSI}, + ) + + # make POST request to machine + url = f"http://{self._machine_ip}:8080/" + req = urllib.request.Request( + url=url, + data=cmd_xml.encode("utf-8"), + method="POST", + headers={ + "Content-Type": "text/xml; charset=utf-8", + "Content-Length": str(len(cmd_xml)), + "SOAPAction": f"http://sila.coop/{command}", + "Expect": "100-continue", + "Accept-Encoding": "gzip, deflate", + }, + ) + + if self._making_request.locked(): + raise RuntimeError("can't send multiple commands at the same time") + + async with self._making_request: + try: + + def _do_request() -> bytes: + with urllib.request.urlopen(req) as resp: + return resp.read() # type: ignore + + body = await asyncio.to_thread(_do_request) + return_code, message = self._get_return_code_and_message( + command, soap_decode(body.decode("utf-8")) + ) + if return_code == 1: # success + return soap_decode(body.decode("utf-8")) + elif return_code == 2: # concurrent command + fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + self._pending = InhecoSiLAInterface._SiLACommand( + name=command, request_id=request_id, fut=fut + ) + return await fut # wait for response to be handled in _on_http + else: + raise RuntimeError(f"command {command} failed: {return_code} {message}") + finally: + self._pending = None + + +def _parse_scalar(text: Optional[str], tag: str) -> object: + if text is None: + return None + s = text.strip() + t = tag.lower() + if t in ("float64", "float32", "double", "single"): + return float(s) + if t in ("int32", "int64", "uint32", "uint64", "integer"): + return int(s) + if t in ("boolean", "bool"): + return s.lower() == "true" + return s + + +def _get_param(root: ET.Element, name: str): + p = root.find(f".//Parameter[@name='{name}']") + if p is None or len(p) == 0: + raise RuntimeError(f"Response missing parameter '{name}'") + child = next(iter(p)) + return _parse_scalar(child.text, child.tag) + + +def get_params(root: ET.Element, names: list[str]) -> dict[str, object]: + return {n: _get_param(root, n) for n in names} diff --git a/pylabrobot/io/ftdi.py b/pylabrobot/io/ftdi.py index 7d931aef8ed..17df02e7603 100644 --- a/pylabrobot/io/ftdi.py +++ b/pylabrobot/io/ftdi.py @@ -74,7 +74,7 @@ def __init__( f"Import error: {_PYUSB_ERROR}" ) - self._human_readable_device_name = human_readable_device_name + self.human_readable_device_name = human_readable_device_name self._device_id = device_id self._vid = vid self._pid = pid @@ -86,7 +86,7 @@ def __init__( if get_capture_or_validation_active(): raise RuntimeError( - f"Cannot create a new FTDI object for '{self._human_readable_device_name}' while capture or validation is active" + f"Cannot create a new FTDI object for '{self.human_readable_device_name}' while capture or validation is active" ) @property @@ -198,7 +198,7 @@ async def setup(self): logger.info(f"Successfully opened FTDI device: {self.device_id}") except FtdiError as e: raise RuntimeError( - f"Failed to open FTDI device for '{self._human_readable_device_name}': {e}. " + f"Failed to open FTDI device for '{self.human_readable_device_name}': {e}. " "Is the device connected? Is it in use by another process? " "Try restarting the kernel." ) from e @@ -293,7 +293,7 @@ async def poll_modem_status(self) -> int: ) return stat.value - async def get_serial(self) -> str: + async def request_serial(self) -> str: return self.device_id async def stop(self): @@ -331,7 +331,7 @@ async def readline(self) -> bytes: # type: ignore # very dumb it's reading from def serialize(self): return { - "human_readable_device_name": self._human_readable_device_name, + "human_readable_device_name": self.human_readable_device_name, "device_id": self._device_id, "vid": self._vid, "pid": self._pid, diff --git a/pylabrobot/io/hid.py b/pylabrobot/io/hid.py index f6b24ff3a51..1b624da6962 100644 --- a/pylabrobot/io/hid.py +++ b/pylabrobot/io/hid.py @@ -32,7 +32,7 @@ class HID(IOBase): def __init__( self, human_readable_device_name: str, vid: int, pid: int, serial_number: Optional[str] = None ): - self._human_readable_device_name = human_readable_device_name + self.human_readable_device_name = human_readable_device_name self.vid = vid self.pid = pid self.serial_number = serial_number @@ -144,7 +144,7 @@ async def write(self, data: bytes, report_id: bytes = b"\x00"): def _write(): if self.device is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") return self.device.write(write_data) if self._executor is None: @@ -161,7 +161,7 @@ async def read(self, size: int, timeout: int) -> bytes: def _read(): if self.device is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") try: return self.device.read(size, timeout=int(timeout)) except HIDException as e: @@ -179,7 +179,7 @@ def _read(): def serialize(self): return { - "human_readable_device_name": self._human_readable_device_name, + "human_readable_device_name": self.human_readable_device_name, "vid": self.vid, "pid": self.pid, "serial_number": self.serial_number, diff --git a/pylabrobot/io/serial.py b/pylabrobot/io/serial.py index 914d4567d19..26ddf801d96 100644 --- a/pylabrobot/io/serial.py +++ b/pylabrobot/io/serial.py @@ -1,9 +1,10 @@ import asyncio +import contextlib import logging from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from io import IOBase -from typing import Optional, cast +from typing import Iterator, Optional, cast from pylabrobot.io.errors import ValidationError @@ -48,8 +49,9 @@ def __init__( timeout=1, rtscts: bool = False, dsrdtr: bool = False, + xonxoff: bool = False, ): - self._human_readable_device_name = human_readable_device_name + self.human_readable_device_name = human_readable_device_name self._port = port self._vid = vid self._pid = pid @@ -63,6 +65,7 @@ def __init__( self.timeout = timeout self.rtscts = rtscts self.dsrdtr = dsrdtr + self.xonxoff = xonxoff # Instant parameter validation at init time if not self._port and not (self._vid and self._pid): @@ -76,6 +79,26 @@ def port(self) -> str: assert self._port is not None, "Port not set. Did you call setup()?" return self._port + def get_read_timeout(self) -> float: + """Get the current read timeout in seconds.""" + assert self._ser is not None, "Serial port not open. Did you call setup()?" + return float(self._ser.timeout) + + def set_read_timeout(self, timeout: float) -> None: + """Set the read timeout in seconds.""" + assert self._ser is not None, "Serial port not open. Did you call setup()?" + self._ser.timeout = timeout + + @contextlib.contextmanager + def temporary_timeout(self, timeout: float) -> Iterator[None]: + """Context manager that temporarily changes the read timeout, then restores it.""" + original = self.get_read_timeout() + self.set_read_timeout(timeout) + try: + yield + finally: + self.set_read_timeout(original) + async def setup(self): """ Initialize the serial connection to the device. @@ -171,6 +194,7 @@ def _open_serial() -> serial.Serial: timeout=self.timeout, rtscts=self.rtscts, dsrdtr=self.dsrdtr, + xonxoff=self.xonxoff, ) try: @@ -178,7 +202,7 @@ def _open_serial() -> serial.Serial: except serial.SerialException as e: logger.error( - f"Could not connect to device '{self._human_readable_device_name}', is it in use by a different notebook/process?" + f"Could not connect to device '{self.human_readable_device_name}', is it in use by a different notebook/process?" ) if self._executor is not None: self._executor.shutdown(wait=True) @@ -196,7 +220,7 @@ async def stop(self): loop = asyncio.get_running_loop() if self._executor is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") await loop.run_in_executor(self._executor, self._ser.close) if self._executor is not None: @@ -208,7 +232,7 @@ async def write(self, data: bytes): loop = asyncio.get_running_loop() if self._executor is None or self._ser is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") await loop.run_in_executor(self._executor, self._ser.write, data) @@ -222,7 +246,7 @@ async def read(self, num_bytes: int = 1) -> bytes: loop = asyncio.get_running_loop() if self._executor is None or self._ser is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") data = await loop.run_in_executor(self._executor, self._ser.read, num_bytes) @@ -239,7 +263,7 @@ async def readline(self) -> bytes: # type: ignore # very dumb it's reading from loop = asyncio.get_running_loop() if self._executor is None or self._ser is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") data = await loop.run_in_executor(self._executor, self._ser.readline) @@ -256,7 +280,7 @@ async def send_break(self, duration: float): loop = asyncio.get_running_loop() if self._executor is None or self._ser is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") def _send_break(ser, duration: float) -> None: """Send a break condition for the specified duration.""" @@ -270,7 +294,7 @@ def _send_break(ser, duration: float) -> None: async def reset_input_buffer(self): loop = asyncio.get_running_loop() if self._executor is None or self._ser is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") await loop.run_in_executor(self._executor, self._ser.reset_input_buffer) logger.log(LOG_LEVEL_IO, "[%s] reset_input_buffer", self._port) capturer.record(SerialCommand(device_id=self.port, action="reset_input_buffer", data="")) @@ -278,7 +302,7 @@ async def reset_input_buffer(self): async def reset_output_buffer(self): loop = asyncio.get_running_loop() if self._executor is None or self._ser is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") await loop.run_in_executor(self._executor, self._ser.reset_output_buffer) logger.log(LOG_LEVEL_IO, "[%s] reset_output_buffer", self._port) capturer.record(SerialCommand(device_id=self.port, action="reset_output_buffer", data="")) @@ -317,7 +341,7 @@ def rts(self, value: bool): def serialize(self): return { - "human_readable_device_name": self._human_readable_device_name, + "human_readable_device_name": self.human_readable_device_name, "port": self._port, "baudrate": self.baudrate, "bytesize": self.bytesize, @@ -329,21 +353,6 @@ def serialize(self): "dsrdtr": self.dsrdtr, } - @classmethod - def deserialize(cls, data: dict) -> "Serial": - return cls( - human_readable_device_name=data["human_readable_device_name"], - port=data["port"], - baudrate=data["baudrate"], - bytesize=data["bytesize"], - parity=data["parity"], - stopbits=data["stopbits"], - write_timeout=data["write_timeout"], - timeout=data["timeout"], - rtscts=data["rtscts"], - dsrdtr=data["dsrdtr"], - ) - class SerialValidator(Serial): def __init__( diff --git a/pylabrobot/io/sila/discovery.py b/pylabrobot/io/sila/discovery.py index a65e9c2d42b..28e0688b5dc 100644 --- a/pylabrobot/io/sila/discovery.py +++ b/pylabrobot/io/sila/discovery.py @@ -24,7 +24,11 @@ from typing import TYPE_CHECKING, AsyncGenerator, Optional try: - from zeroconf import ServiceBrowser, ServiceListener, Zeroconf + from zeroconf import ( # type: ignore[import-not-found] + ServiceBrowser, + ServiceListener, + Zeroconf, + ) HAS_ZEROCONF = True except ImportError: diff --git a/pylabrobot/io/socket.py b/pylabrobot/io/socket.py index 0fcae09a6bd..ae465fa3cfa 100644 --- a/pylabrobot/io/socket.py +++ b/pylabrobot/io/socket.py @@ -38,7 +38,7 @@ def __init__( ssl_context: Optional[ssl.SSLContext] = None, server_hostname: Optional[str] = None, ): - self._human_readable_device_name = human_readable_device_name + self.human_readable_device_name = human_readable_device_name self._host = host self._port = port self._reader: Optional[asyncio.StreamReader] = None @@ -91,7 +91,7 @@ async def reconnect(self): def serialize(self): return { - "human_readable_device_name": self._human_readable_device_name, + "human_readable_device_name": self.human_readable_device_name, "host": self._host, "port": self._port, "type": "Socket", @@ -99,27 +99,13 @@ def serialize(self): "write_timeout": self._write_timeout, } - @classmethod - def deserialize(cls, data: dict) -> "Socket": - kwargs = {} - if "read_timeout" in data: - kwargs["read_timeout"] = data["read_timeout"] - if "write_timeout" in data: - kwargs["write_timeout"] = data["write_timeout"] - return cls( - human_readable_device_name=data["human_readable_device_name"], - host=data["host"], - port=data["port"], - **kwargs, - ) - async def write(self, data: bytes, timeout: Optional[float] = None) -> None: """Wrapper around StreamWriter.write with lock and io logging. Does not retry on timeouts. """ if self._writer is None: raise RuntimeError( - f"Socket for '{self._human_readable_device_name}' not set up; call setup() first" + f"Socket for '{self.human_readable_device_name}' not set up; call setup() first" ) timeout = self._write_timeout if timeout is None else timeout async with self._write_lock: @@ -156,7 +142,7 @@ async def read(self, num_bytes: int = 128, timeout: Optional[float] = None) -> b """ if self._reader is None: raise RuntimeError( - f"Socket for '{self._human_readable_device_name}' not set up; call setup() first" + f"Socket for '{self.human_readable_device_name}' not set up; call setup() first" ) timeout = self._read_timeout if timeout is None else timeout async with self._read_lock: @@ -179,7 +165,7 @@ async def readline(self, timeout: Optional[float] = None) -> bytes: """Wrapper around StreamReader.readline with lock and io logging.""" if self._reader is None: raise RuntimeError( - f"Socket for '{self._human_readable_device_name}' not set up; call setup() first" + f"Socket for '{self.human_readable_device_name}' not set up; call setup() first" ) timeout = self._read_timeout if timeout is None else timeout async with self._read_lock: @@ -203,7 +189,7 @@ async def readuntil(self, separator: bytes = b"\n", timeout: Optional[float] = N Do not retry on timeouts.""" if self._reader is None: raise RuntimeError( - f"Socket for '{self._human_readable_device_name}' not set up; call setup() first" + f"Socket for '{self.human_readable_device_name}' not set up; call setup() first" ) timeout = self._read_timeout if timeout is None else timeout async with self._read_lock: @@ -241,7 +227,7 @@ async def read_exact(self, num_bytes: int, timeout: Optional[float] = None) -> b """ if self._reader is None: raise RuntimeError( - f"Socket for '{self._human_readable_device_name}' not set up; call setup() first" + f"Socket for '{self.human_readable_device_name}' not set up; call setup() first" ) timeout = self._read_timeout if timeout is None else timeout data = bytearray() @@ -279,7 +265,7 @@ async def read_until_eof(self, chunk_size: int = 1024, timeout: Optional[float] while True: if self._reader is None: raise RuntimeError( - f"Socket for '{self._human_readable_device_name}' not set up; call setup() first" + f"Socket for '{self.human_readable_device_name}' not set up; call setup() first" ) try: chunk = await asyncio.wait_for(self._reader.read(chunk_size), timeout=timeout) diff --git a/pylabrobot/io/usb.py b/pylabrobot/io/usb.py index 14d2802f986..8e4469f1577 100644 --- a/pylabrobot/io/usb.py +++ b/pylabrobot/io/usb.py @@ -11,7 +11,7 @@ from pylabrobot.io.validation_utils import LOG_LEVEL_IO, align_sequences try: - import libusb_package + import libusb_package # type: ignore[import-not-found] import usb.core import usb.util @@ -102,11 +102,21 @@ def __init__( self.read_endpoint: Optional[usb.core.Endpoint] = None self.write_endpoint: Optional[usb.core.Endpoint] = None - self._executor: Optional[ThreadPoolExecutor] = None + # Reads and writes use separate single-worker executors. The reader thread parks a long + # blocking read on its worker; sharing one worker with writes would let that read starve + # (and deadlock against) the write whose response the read is waiting for. + self._read_executor: Optional[ThreadPoolExecutor] = None + self._write_executor: Optional[ThreadPoolExecutor] = None # unique id in the logs self._unique_id = f"[{hex(self._id_vendor)}:{hex(self._id_product)}][{self._serial_number or ''}][{self._device_address or ''}]" - self._human_readable_device_name = human_readable_device_name + self.human_readable_device_name = human_readable_device_name + + @property + def read_executor(self) -> ThreadPoolExecutor: + if self._read_executor is None: + raise RuntimeError("Read executor not initialized. Call setup() first.") + return self._read_executor async def write(self, data: bytes, timeout: Optional[float] = None): """Write data to the device. @@ -118,7 +128,7 @@ async def write(self, data: bytes, timeout: Optional[float] = None): """ if self.dev is None or self.read_endpoint is None: - raise RuntimeError(f"USB device for '{self._human_readable_device_name}' is not connected.") + raise RuntimeError(f"USB device for '{self.human_readable_device_name}' is not connected.") if timeout is None: timeout = self.write_timeout @@ -127,10 +137,10 @@ async def write(self, data: bytes, timeout: Optional[float] = None): loop = asyncio.get_running_loop() write_endpoint = self.write_endpoint dev = self.dev - if self._executor is None or dev is None or write_endpoint is None: - raise RuntimeError(f"Call setup() first for USB device '{self._human_readable_device_name}'.") + if self._write_executor is None or dev is None or write_endpoint is None: + raise RuntimeError(f"Call setup() first for USB device '{self.human_readable_device_name}'.") await loop.run_in_executor( - self._executor, + self._write_executor, lambda: dev.write( write_endpoint, data, timeout=int(timeout * 1000) ), # PyUSB expects timeout in milliseconds @@ -138,7 +148,7 @@ async def write(self, data: bytes, timeout: Optional[float] = None): if len(data) % write_endpoint.wMaxPacketSize == 0: # send a zero-length packet to indicate the end of the transfer await loop.run_in_executor( - self._executor, + self._write_executor, lambda: dev.write(write_endpoint, b"", timeout=int(timeout * 1000)), ) logger.log(LOG_LEVEL_IO, "%s write: %s", self._unique_id, data) @@ -169,7 +179,7 @@ def _read_packet( """ if self.dev is None or self.read_endpoint is None: - raise RuntimeError(f"USB device for '{self._human_readable_device_name}' is not connected.") + raise RuntimeError(f"USB device for '{self.human_readable_device_name}' is not connected.") ep = endpoint if endpoint is not None else self.read_endpoint if ep is None: @@ -221,7 +231,7 @@ async def read(self, timeout: Optional[int] = None, size: Optional[int] = None) """ if self.dev is None or self.read_endpoint is None: - raise RuntimeError(f"USB device for '{self._human_readable_device_name}' is not connected.") + raise RuntimeError(f"USB device for '{self.human_readable_device_name}' is not connected.") if timeout is None: timeout = self.read_timeout @@ -261,13 +271,13 @@ def read_or_timeout(): return resp raise TimeoutError( - f"Timeout while reading from USB device '{self._human_readable_device_name}'." + f"Timeout while reading from USB device '{self.human_readable_device_name}'." ) loop = asyncio.get_running_loop() - if self._executor is None or self.dev is None: - raise RuntimeError(f"Call setup() first for USB device '{self._human_readable_device_name}'.") - return await loop.run_in_executor(self._executor, read_or_timeout) + if self._read_executor is None or self.dev is None: + raise RuntimeError(f"Call setup() first for USB device '{self.human_readable_device_name}'.") + return await loop.run_in_executor(self._read_executor, read_or_timeout) def get_available_devices(self) -> List["usb.core.Device"]: """Get a list of available devices that match the specified vendor and product IDs, and serial @@ -283,7 +293,7 @@ def get_available_devices(self) -> List["usb.core.Device"]: if self._device_address is not None: if dev.address is None: raise RuntimeError( - f"A device address was specified for '{self._human_readable_device_name}', but the backend used for PyUSB does " + f"A device address was specified for '{self.human_readable_device_name}', but the backend used for PyUSB does " "not support device addresses." ) @@ -293,7 +303,7 @@ def get_available_devices(self) -> List["usb.core.Device"]: if self._serial_number is not None: if dev._serial_number is None: raise RuntimeError( - f"A serial number was specified for '{self._human_readable_device_name}', but the device does not have a serial number." + f"A serial number was specified for '{self.human_readable_device_name}', but the device does not have a serial number." ) if dev.serial_number != self._serial_number: @@ -331,7 +341,7 @@ def ctrl_transfer( timeout: Optional[int] = None, ) -> bytearray: if self.dev is None: - raise RuntimeError(f"USB device for '{self._human_readable_device_name}' is not connected.") + raise RuntimeError(f"USB device for '{self.human_readable_device_name}' is not connected.") if timeout is None: timeout = self.read_timeout @@ -447,27 +457,29 @@ async def setup(self, empty_buffer=True): while self._read_packet() is not None: pass - self._executor = ThreadPoolExecutor(max_workers=self.max_workers) + self._read_executor = ThreadPoolExecutor(max_workers=self.max_workers) + self._write_executor = ThreadPoolExecutor(max_workers=self.max_workers) async def stop(self): - """Close the USB connection to the machine.""" + """Close the USB connection to the machine. Safe to call multiple times.""" if self.dev is None: - raise ValueError("USB device was not connected.") + return logger.warning("Closing connection to USB device.") usb.util.dispose_resources(self.dev) self.dev = None - if self._executor is not None: - self._executor.shutdown(wait=True) - self._executor = None + for executor in (self._read_executor, self._write_executor): + if executor is not None: + executor.shutdown(wait=True) + self._read_executor = self._write_executor = None def serialize(self) -> dict: """Serialize the backend to a dictionary.""" d = { **super().serialize(), - "human_readable_device_name": self._human_readable_device_name, + "human_readable_device_name": self.human_readable_device_name, "id_vendor": self._id_vendor, "id_product": self._id_product, "device_address": self._device_address, diff --git a/pylabrobot/io/validation.py b/pylabrobot/io/validation.py index 01d9d749e83..d89e629d612 100644 --- a/pylabrobot/io/validation.py +++ b/pylabrobot/io/validation.py @@ -5,7 +5,6 @@ from pylabrobot.io.hid import HID, HIDValidator from pylabrobot.io.serial import Serial, SerialValidator from pylabrobot.io.usb import USB, USBValidator -from pylabrobot.machines.backend import MachineBackend cr: Optional[CaptureReader] = None @@ -40,13 +39,6 @@ def _replace_io(obj): return False return True - for machine_backend in MachineBackend.get_all_instances(): - if not ( - (hasattr(machine_backend, "io") and _replace_io(machine_backend)) - or (hasattr(machine_backend, "interface") and _replace_io(machine_backend.interface)) - ): - raise RuntimeError(f"Backend {machine_backend} not supported for validation") - cr.start() diff --git a/pylabrobot/kbioscience/__init__.py b/pylabrobot/kbioscience/__init__.py new file mode 100644 index 00000000000..49ada6b6fb3 --- /dev/null +++ b/pylabrobot/kbioscience/__init__.py @@ -0,0 +1,6 @@ +from .kube import ( + KBioscienceError, + KBioscienceKUBE, + KUBEStatus, + SealPreset, +) diff --git a/pylabrobot/kbioscience/kube.py b/pylabrobot/kbioscience/kube.py new file mode 100644 index 00000000000..2ca882c5d71 --- /dev/null +++ b/pylabrobot/kbioscience/kube.py @@ -0,0 +1,445 @@ +import asyncio +import enum +import logging +import time +from typing import Optional + +from pylabrobot.io.serial import Serial + +logger = logging.getLogger(__name__) + + +class KUBEStatus(enum.IntFlag): + """Status byte returned by the ``?`` command (two hex digits). + + ``Ready`` (0x00) is the all-clear value; any other bit is a condition that + must be cleared, or masked when ignorable, before an operation runs. Note the + bit layout is specific to the KUBE firmware and differs from other sealers: + ``Busy`` and ``Sealing`` are distinct bits. + """ + + Ready = 0x00 + Busy = 0x01 + Error = 0x02 + Sealing = 0x04 + NotAtSealTemperature = 0x08 + SystemShutdown = 0x10 + FoilLow = 0x20 + DoorOpen = 0x40 + LowAirPressure = 0x80 + + +# Text for each status bit, used to describe a not-ready condition. +STATUS_MESSAGES = { + KUBEStatus.Busy: ( + "Device not ready. Make sure instrument is initialized, film loaded, door " + "closed, tray out, and control software is on main menu" + ), + KUBEStatus.Error: "Error", + KUBEStatus.Sealing: "Sealing", + KUBEStatus.NotAtSealTemperature: "Waiting for seal temperature", + KUBEStatus.SystemShutdown: "System shutting down", + KUBEStatus.FoilLow: "No foil detected or foil low.", + KUBEStatus.DoorOpen: "Door open", + KUBEStatus.LowAirPressure: "Low air pressure detected.", +} + +# Error codes returned by the ``E`` command (two decimal digits). The firmware +# leaves gaps in the numbering (there is no code 2). +DEVICE_ERRORS = { + 1: "Low air pressure detected. Check air pressure.", + 3: "Shuttle not out.", + 4: "No foil or low foil error. Check foil and foil sensor.", + 5: "Seal transfer error.", + 6: "Placement error (can be caused of low air pressure).", + 7: "Thermocouple error - ambient temperature may be too low.", +} + +MIN_SEALING_TEMPERATURE = 5 +MAX_SEALING_TEMPERATURE = 199 +MIN_SEALING_DURATION = 0.5 +MAX_SEALING_DURATION = 9.9 + + +class SealPreset(enum.IntEnum): + """Sealing configuration: user-defined (time + temperature) or one of six + presets stored on the instrument.""" + + USER_DEFINED = 0 + ONE = 1 + TWO = 2 + THREE = 3 + FOUR = 4 + FIVE = 5 + SIX = 6 + + +class KBioscienceError(Exception): + """Exceptions raised by a KBioscience KUBE thermal plate sealer.""" + + def __init__( + self, + title: str, + message: Optional[str] = None, + status: Optional[KUBEStatus] = None, + error_code: Optional[int] = None, + ) -> None: + self.title = title + self.message = message + self.status = status + self.error_code = error_code + + def __str__(self) -> str: + return f"{self.title}: {self.message}" if self.message else self.title + + +class KBioscienceKUBE: + """KBioscience KUBE thermal plate sealer. + + Serial settings: + 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake, "\\r" + terminator. + + Commands (ASCII, terminated with CR). The device echoes the command in front + of its reply, so the leading command characters are stripped before the reply + is read (e.g. ``A160`` -> ``A160ok`` -> ``ok``; ``?`` -> ``?3f`` -> ``3f``). A + reply containing ``syntax`` means the command was rejected. + + :: + + ? read status byte, two hex digits (see KUBEStatus) + E read error code, two decimal digits (see DEVICE_ERRORS) + S seal the plate; replies ok or err + A{t:03d} set sealing temperature, degrees C (5..199) + B{t:02d} set sealing time, deciseconds (05..99 = 0.5..9.9 s) + P{n} select seal preset 1..6 + X{1|2} set plate orientation: 1 portrait, 2 landscape; replies ok/err + H clear the error message window on the instrument display + C read sealing temperature setpoint, three digits + D read sealing time setpoint, two digits (deciseconds) + F read current heater temperature, three digits + V read firmware version + @ read product name / model description + protocol probe; a unit replying ``ALPS300`` speaks the older + ALPS protocol and is rejected at setup. Use + ``pylabrobot.thermo_fisher.alps.ThermoScientificALPS300`` for a + unit running that protocol. + + Not verified: has NOT been tested against hardware in PyLabRobot. A warning + is emitted at setup. + """ + + def __init__( + self, + port: str, + timeout: float = 5.0, + settle_time: float = 5.0, + preheating_temperature: int = 100, + offline_temperature: int = 20, + portrait: bool = False, + ) -> None: + self.timeout = timeout + self.settle_time = settle_time + self.preheating_temperature = preheating_temperature + self.offline_temperature = offline_temperature + self.portrait = portrait + self.firmware_version: Optional[str] = None + self.product_name: Optional[str] = None + self.io = Serial( + human_readable_device_name="KBioscience KUBE Thermal Plate Sealer", + port=port, + baudrate=9600, + bytesize=8, + parity="N", + stopbits=1, + timeout=0.2, + ) + + async def setup(self) -> None: + logger.warning( + "KBioscienceKUBE has NOT been tested against hardware in PyLabRobot. " + "Please make a PR to remove this message if you have verified it on your hardware." + ) + await self.io.setup() + # The device drops characters for a few seconds after the port opens. + await asyncio.sleep(self.settle_time) + await self.io.reset_input_buffer() + # A unit that answers the empty-command probe with "ALPS300" speaks the + # older ALPS protocol; pylabrobot.thermo_fisher.alps.ThermoScientificALPS300 + # handles that protocol. + if await self._is_alps300(): + raise KBioscienceError( + title="Incompatible communication protocol", + message=( + "This unit is running the ALPS protocol. Either use " + "ThermoScientificALPS300, or uncheck ALPS Compatibility from the " + "instrument software menu [Menu->Supervisor options->Remote control " + "settings->ALPS Compatibility]." + ), + ) + await self.wait_for_idle( + KUBEStatus.NotAtSealTemperature | KUBEStatus.FoilLow | KUBEStatus.LowAirPressure + ) + self.firmware_version = await self.request_firmware_version() + self.product_name = await self.request_product_name() + await self.set_plate_orientation(self.portrait) + await self.set_temperature(self.preheating_temperature) + logger.info( + "[KUBE %s] connected: model=%s firmware=%s", + self.io.port, + self.product_name, + self.firmware_version, + ) + + async def stop(self) -> None: + try: + await self.set_temperature(self.offline_temperature) + except (KBioscienceError, TimeoutError) as e: + logger.warning("[KUBE %s] could not set offline temperature: %s", self.io.port, e) + await self.io.stop() + + # === Command layer === + + async def _read_line(self) -> str: + """Read bytes until a CR terminator, skipping stray LF.""" + start = time.time() + buf = bytearray() + while True: + b = await self.io.read(1) + if b == b"": + if time.time() - start > self.timeout: + raise TimeoutError("Timeout while waiting for response from sealer.") + continue + if b == b"\r": + break + if b == b"\n": + continue + buf += b + return buf.decode("ascii", errors="replace") + + async def send_command(self, command: str) -> str: + """Send a command and return the reply with the echoed command stripped. + + Args: + command: command string without the CR terminator, e.g. "A160", "?". + """ + await self.io.reset_input_buffer() + await self.io.write((command + "\r").encode("ascii")) + await asyncio.sleep(0.2) + text = await self._read_line() + if "syntax" in text: + return text + # The device echoes the command; an empty command trims whitespace only. + return text.lstrip(command) if command else text.strip() + + def _check(self, reply: str, allowed: set, command: str) -> str: + if reply not in allowed or "syntax" in reply: + raise KBioscienceError( + title="Unexpected response", message=f"command {command!r} returned {reply!r}" + ) + return reply + + # === State === + + async def request_status(self) -> KUBEStatus: + """Read the status byte (``?``).""" + reply = await self.send_command("?") + try: + return KUBEStatus(int(reply, 16)) + except ValueError as e: + raise KBioscienceError(title="Unexpected status reply", message=repr(reply)) from e + + async def request_error_code(self) -> int: + """Read the current error code (``E``).""" + reply = await self.send_command("E") + try: + return int(reply) + except ValueError as e: + raise KBioscienceError(title="Unexpected error reply", message=repr(reply)) from e + + async def _is_alps300(self) -> bool: + """Probe the protocol with the empty command; True if the unit is ALPS300.""" + try: + return await self.send_command("") == "ALPS300" + except (KBioscienceError, TimeoutError): + return False + + async def wait_for_idle(self, ignore_mask: KUBEStatus = KUBEStatus.Ready) -> KUBEStatus: + """Wait until the device is Ready, tolerating the bits set in ``ignore_mask``. + + Raises KBioscienceError if any bit outside ``ignore_mask`` is set; if the + Error bit is set, the error code and text are attached. + """ + status = await self.request_status() + while status != KUBEStatus.Ready: + if status & KUBEStatus.Sealing: + await asyncio.sleep(0.5) + status = await self._wait_for_seal_cleared() + continue + + # While heating, the device also reports Busy; ignore it alongside + # NotAtSealTemperature so waiting for temperature does not raise. + if ignore_mask & KUBEStatus.NotAtSealTemperature and status & KUBEStatus.NotAtSealTemperature: + ignore_mask = ignore_mask | KUBEStatus.Busy + + remaining = status & ~ignore_mask + if remaining == KUBEStatus.Ready: + break + + description = ", ".join(m for bit, m in STATUS_MESSAGES.items() if remaining & bit) + if remaining & KUBEStatus.Error: + code = await self.request_error_code() + raise KBioscienceError( + title=f"Sealer error: {description}", + message=DEVICE_ERRORS.get(code), + status=remaining, + error_code=code, + ) + raise KBioscienceError(title=f"Sealer not ready: {description}", status=remaining) + return status + + async def _wait_for_seal_cleared(self, timeout: float = 60.0) -> KUBEStatus: + start = time.time() + status = await self.request_status() + while status & KUBEStatus.Sealing: + if time.time() - start > timeout: + raise KBioscienceError(title="Timeout while waiting for the seal to complete") + await asyncio.sleep(0.5) + status = await self.request_status() + return status + + async def wait_for_sealing_temperature(self, timeout: float = 300.0) -> None: + """Block until the heater reaches the setpoint (NotAtSealTemperature clears).""" + start = time.time() + while await self.request_status() & KUBEStatus.NotAtSealTemperature: + if time.time() - start > timeout: + raise TimeoutError("Timeout while waiting for sealing temperature.") + await asyncio.sleep(5.0) + + # === Parameters === + + async def set_temperature(self, temperature: int) -> None: + """Set the sealing temperature in degrees C (``A``, 5..199).""" + if not MIN_SEALING_TEMPERATURE <= temperature <= MAX_SEALING_TEMPERATURE: + raise ValueError( + f"temperature must be {MIN_SEALING_TEMPERATURE}..{MAX_SEALING_TEMPERATURE} C" + ) + command = f"A{temperature:03d}" + reply = await self.send_command(command) + if reply != "ok": + raise KBioscienceError( + title="Setting sealing temperature failed", + message=f"temperature {temperature} rejected with {reply!r}", + ) + # The firmware needs a moment to accept the new setpoint. + await asyncio.sleep(3.0) + + async def set_sealing_time(self, seconds: float) -> None: + """Set the sealing time in seconds (``B``, 0.5..9.9).""" + if not MIN_SEALING_DURATION <= seconds <= MAX_SEALING_DURATION: + raise ValueError(f"time must be {MIN_SEALING_DURATION}..{MAX_SEALING_DURATION} s") + command = f"B{int(seconds * 10):02d}" + self._check(await self.send_command(command), {"ok"}, command) + + async def set_seal_preset(self, preset: SealPreset) -> None: + """Select one of the instrument's stored presets (``P``, 1..6).""" + if preset == SealPreset.USER_DEFINED: + raise ValueError("USER_DEFINED is not a stored preset; set time and temperature instead") + command = f"P{int(preset)}" + reply = await self.send_command(command) + if reply != "ok": + raise KBioscienceError(title="Setting sealing preset failed", message=f"{command}:{reply}") + + async def set_plate_orientation(self, portrait: bool) -> None: + """Set the plate orientation (``X``): portrait (``X1``) or landscape (``X2``).""" + command = f"X{'1' if portrait else '2'}" + reply = await self.send_command(command) + if reply != "ok": + raise KBioscienceError(title="Setting plate orientation failed", message=f"{command}:{reply}") + + async def clear_error_window(self) -> None: + """Clear the error message window on the instrument display (``H``).""" + try: + await self.send_command("H") + except (KBioscienceError, TimeoutError): + pass + + async def request_sealing_temperature(self) -> int: + """Read the sealing temperature setpoint in degrees C (``C``).""" + reply = await self.send_command("C") + try: + return int(reply) + except ValueError as e: + raise KBioscienceError(title="Unexpected temperature reply", message=repr(reply)) from e + + async def request_sealing_time(self) -> float: + """Read the sealing time setpoint in seconds (``D``).""" + reply = await self.send_command("D") + try: + return int(reply) / 10.0 + except ValueError as e: + raise KBioscienceError(title="Unexpected time reply", message=repr(reply)) from e + + async def request_temperature(self) -> int: + """Read the current heater temperature in degrees C (``F``).""" + reply = await self.send_command("F") + try: + return int(reply) + except ValueError as e: + raise KBioscienceError(title="Unexpected temperature reply", message=repr(reply)) from e + + async def request_firmware_version(self) -> str: + """Read the firmware version (``V``).""" + return await self.send_command("V") + + async def request_product_name(self) -> str: + """Read the product name / model description (``@``).""" + return await self.send_command("@") + + # === Operations === + + async def seal( + self, + temperature: int = 160, + duration: float = 2.5, + preset: SealPreset = SealPreset.USER_DEFINED, + idle_temperature: int = 100, + ) -> None: + """Seal a plate. + + Waits for the device to be ready, applies either the given preset or the + user-defined time/temperature, waits for the heater to reach the setpoint, + seals, then returns to ``idle_temperature``. + + Args: + temperature: sealing temperature in degrees C (5..199); used when + ``preset`` is USER_DEFINED. + duration: sealing time in seconds (0.5..9.9); used when ``preset`` is + USER_DEFINED. + preset: a stored preset (ONE..SIX) or USER_DEFINED to use time/temperature. + idle_temperature: temperature to hold after sealing (5..199). + """ + logger.info( + "[KUBE %s] sealing (preset=%s) at %d C for %.1fs", + self.io.port, + preset.name, + temperature, + duration, + ) + ignore = KUBEStatus.NotAtSealTemperature | KUBEStatus.FoilLow | KUBEStatus.LowAirPressure + + await self.wait_for_idle(ignore) + + if preset == SealPreset.USER_DEFINED: + await self.set_sealing_time(duration) + await self.set_temperature(temperature) + else: + await self.set_seal_preset(preset) + await asyncio.sleep(3.0) + + await self.wait_for_sealing_temperature() + if self._check(await self.send_command("S"), {"ok", "err"}, "S") != "ok": + raise KBioscienceError(title="Seal command returned 'err'") + await self.wait_for_idle(KUBEStatus.NotAtSealTemperature | KUBEStatus.LowAirPressure) + + await self.set_temperature(idle_temperature) diff --git a/pylabrobot/kbiosystems/__init__.py b/pylabrobot/kbiosystems/__init__.py new file mode 100644 index 00000000000..7e30915214a --- /dev/null +++ b/pylabrobot/kbiosystems/__init__.py @@ -0,0 +1,4 @@ +from .sealer import KBiosystemsError, KBiosystemsSealer +from .ultraseal_epro import KBiosystemsUltrasealEPRO, UltrasealEPROStatus +from .ultraseal_pro import KBiosystemsUltrasealPRO, UltrasealPROStatus +from .ultraseal_xt_pro import KBiosystemsUltrasealXTPro, UltrasealXTProStatus diff --git a/pylabrobot/kbiosystems/sealer.py b/pylabrobot/kbiosystems/sealer.py new file mode 100644 index 00000000000..c95a3763581 --- /dev/null +++ b/pylabrobot/kbiosystems/sealer.py @@ -0,0 +1,276 @@ +import abc +import asyncio +import enum +import logging +import time +from typing import Dict, Optional, Type + +from pylabrobot.io.serial import Serial + +logger = logging.getLogger(__name__) + + +class KBiosystemsError(Exception): + """Error raised by a KBiosystems heat sealer.""" + + def __init__( + self, + title: str, + message: Optional[str] = None, + status: Optional[enum.IntFlag] = None, + error_code: Optional[int] = None, + ) -> None: + self.title = title + self.message = message + self.status = status + self.error_code = error_code + + def __str__(self) -> str: + return f"{self.title}: {self.message}" if self.message else self.title + + +class KBiosystemsSealer(abc.ABC): + """Shared base for KBiosystems serial heat sealers (Ultraseal ePRO / XT Pro). + + Every model speaks the same ASCII-over-RS-232 core: a command is written with + a CR terminator, the device echoes the command in front of its reply, and the + echo is stripped before the reply is parsed (e.g. ``A160`` -> ``A160ok`` -> + ``ok``; ``?`` -> ``?3f`` -> ``3f``). A reply containing ``syntax`` means the + command was rejected. This base implements the transport, the status/error + polling state machine, and the temperature and time commands shared by all + models. Subclasses supply the model-specific status flags, error table, + temperature limits, human-readable name, ``setup``, ``seal``, and any extra + commands. + + Serial settings: 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake, + CR (``\\r``) terminator. + """ + + # Human-readable device name, used for the serial handle. Overridden per model. + _HUMAN_READABLE_NAME: str = "KBiosystems Heat Sealer" + + # Status flag enum returned by ``?``. Overridden per model. + STATUS: Type[enum.IntFlag] + # Description for each status bit, used to explain a not-ready condition. + STATUS_MESSAGES: Dict[enum.IntFlag, str] + # Text for each numeric error code returned by ``E``. + ERRORS: Dict[int, str] + + # The low status bits are identical across every model, so the state machine + # below works against them directly rather than the per-model enum. + _READY = 0x00 + _ERROR = 0x02 + _BUSY = 0x04 + _NOT_AT_SEAL_TEMPERATURE = 0x08 + + MIN_SEALING_TEMPERATURE: int + MAX_SEALING_TEMPERATURE: int + MIN_SEALING_DURATION: float = 0.5 + MAX_SEALING_DURATION: float = 9.9 + + def __init__( + self, + port: str, + timeout: float = 5.0, + settle_time: float = 5.0, + preheating_temperature: int = 100, + offline_temperature: int = 20, + ) -> None: + self.timeout = timeout + self.settle_time = settle_time + self.preheating_temperature = preheating_temperature + self.offline_temperature = offline_temperature + self.io = Serial( + human_readable_device_name=self._HUMAN_READABLE_NAME, + port=port, + baudrate=9600, + bytesize=8, + parity="N", + stopbits=1, + timeout=0.2, + ) + + # === Subclass contract === + + @abc.abstractmethod + async def setup(self) -> None: + """Open the connection and bring the sealer to a ready, pre-heated state.""" + + @abc.abstractmethod + async def seal(self, temperature: int, duration: float) -> None: + """Seal a plate at the given sealing temperature (C) and duration (s).""" + + async def _open(self) -> None: + """Open the port, wait out the post-open settling, and clear the buffer.""" + await self.io.setup() + # The device drops characters for a few seconds after the port opens. + await asyncio.sleep(self.settle_time) + await self.io.reset_input_buffer() + + async def stop(self) -> None: + """Return the heater to the offline temperature and close the port.""" + try: + await self.set_temperature(self.offline_temperature) + except (KBiosystemsError, TimeoutError) as e: + logger.warning("[%s] could not set offline temperature: %s", self.io.port, e) + await self.io.stop() + + # === Command layer === + + async def _read_line(self) -> str: + """Read bytes until a CR terminator, skipping stray LF.""" + start = time.time() + buf = bytearray() + while True: + b = await self.io.read(1) + if b == b"": + if time.time() - start > self.timeout: + raise TimeoutError("Timeout while waiting for response from sealer.") + continue + if b == b"\r": + break + if b == b"\n": + continue + buf += b + return buf.decode("ascii", errors="replace") + + async def send_command(self, command: str, read_reply: bool = True) -> str: + """Send a command and return the reply with the echoed command stripped. + + Args: + command: command string without the CR terminator, e.g. "A160", "?". + read_reply: if False, do not read a reply (used for commands that send none). + """ + await self.io.reset_input_buffer() + await self.io.write((command + "\r").encode("ascii")) + await asyncio.sleep(0.2) + if not read_reply: + return "" + text = await self._read_line() + if "syntax" in text: + return text + # The device echoes the command; an empty command trims whitespace only. + return text.lstrip(command) if command else text.strip() + + def _check(self, reply: str, allowed: set, command: str) -> str: + if reply not in allowed or "syntax" in reply: + raise KBiosystemsError( + title="Unexpected response", message=f"command {command!r} returned {reply!r}" + ) + return reply + + # === State === + + async def request_status(self) -> enum.IntFlag: + """Read the status byte (``?``).""" + reply = await self.send_command("?") + try: + return self.STATUS(int(reply, 16)) + except ValueError as e: + raise KBiosystemsError(title="Unexpected status reply", message=repr(reply)) from e + + async def request_error_code(self) -> int: + """Read the current error code (``E``).""" + reply = await self.send_command("E") + try: + return int(reply) + except ValueError as e: + raise KBiosystemsError(title="Unexpected error reply", message=repr(reply)) from e + + async def wait_for_idle(self, ignore_mask: Optional[enum.IntFlag] = None) -> enum.IntFlag: + """Wait until the device is Ready, tolerating the bits set in ``ignore_mask``. + + Raises KBiosystemsError if any bit outside ``ignore_mask`` is set; if the + Error bit is set, the error code and text are attached. + """ + mask = self._READY if ignore_mask is None else int(ignore_mask) + + status = await self.request_status() + while status != self._READY: + if status & self._BUSY: + await asyncio.sleep(0.5) + status = await self._wait_for_busy_cleared() + continue + + # While heating, the device also reports Busy; ignore it alongside + # NotAtSealTemperature so waiting for temperature does not raise. + if mask & self._NOT_AT_SEAL_TEMPERATURE and status & self._NOT_AT_SEAL_TEMPERATURE: + mask = mask | self._BUSY + + remaining = status & ~mask + if remaining == self._READY: + break + + description = ", ".join(m for bit, m in self.STATUS_MESSAGES.items() if remaining & bit) + if remaining & self._ERROR: + code = await self.request_error_code() + raise KBiosystemsError( + title=f"Sealer error: {description}", + message=self.ERRORS.get(code), + status=remaining, + error_code=code, + ) + raise KBiosystemsError(title=f"Sealer not ready: {description}", status=remaining) + return status + + async def _wait_for_busy_cleared(self, timeout: float = 60.0) -> enum.IntFlag: + start = time.time() + status = await self.request_status() + while status & self._BUSY: + if time.time() - start > timeout: + raise KBiosystemsError(title="Timeout while waiting for busy flag to clear") + await asyncio.sleep(0.5) + status = await self.request_status() + return status + + async def wait_for_sealing_temperature(self, timeout: float = 300.0) -> None: + """Block until the heater reaches the setpoint (NotAtSealTemperature clears).""" + start = time.time() + while await self.request_status() & self._NOT_AT_SEAL_TEMPERATURE: + if time.time() - start > timeout: + raise TimeoutError("Timeout while waiting for sealing temperature.") + await asyncio.sleep(5.0) + + # === Parameters === + + async def set_temperature(self, temperature: int) -> None: + """Set the sealing temperature in degrees C (``A``).""" + if not self.MIN_SEALING_TEMPERATURE <= temperature <= self.MAX_SEALING_TEMPERATURE: + raise ValueError( + f"temperature must be {self.MIN_SEALING_TEMPERATURE}..{self.MAX_SEALING_TEMPERATURE} C" + ) + command = f"A{temperature:03d}" + self._check(await self.send_command(command), {"ok"}, command) + # The firmware needs a moment to accept the new setpoint. + await asyncio.sleep(3.0) + + async def set_sealing_time(self, seconds: float) -> None: + """Set the sealing time in seconds (``B``).""" + if not self.MIN_SEALING_DURATION <= seconds <= self.MAX_SEALING_DURATION: + raise ValueError(f"time must be {self.MIN_SEALING_DURATION}..{self.MAX_SEALING_DURATION} s") + command = f"B{int(seconds * 10):02d}" + self._check(await self.send_command(command), {"ok"}, command) + + async def request_sealing_temperature(self) -> int: + """Read the sealing temperature setpoint in degrees C (``C``).""" + reply = await self.send_command("C") + try: + return int(reply) + except ValueError as e: + raise KBiosystemsError(title="Unexpected temperature reply", message=repr(reply)) from e + + async def request_sealing_time(self) -> float: + """Read the sealing time setpoint in seconds (``D``).""" + reply = await self.send_command("D") + try: + return int(reply) / 10.0 + except ValueError as e: + raise KBiosystemsError(title="Unexpected time reply", message=repr(reply)) from e + + async def request_temperature(self) -> int: + """Read the current heater temperature in degrees C (``F``).""" + reply = await self.send_command("F") + try: + return int(reply) + except ValueError as e: + raise KBiosystemsError(title="Unexpected temperature reply", message=repr(reply)) from e diff --git a/pylabrobot/kbiosystems/sealer_tests.py b/pylabrobot/kbiosystems/sealer_tests.py new file mode 100644 index 00000000000..c100bf96201 --- /dev/null +++ b/pylabrobot/kbiosystems/sealer_tests.py @@ -0,0 +1,221 @@ +import unittest +from typing import Dict, List +from unittest.mock import AsyncMock, patch + +from pylabrobot.kbiosystems import ( + KBiosystemsError, + KBiosystemsUltrasealEPRO, + KBiosystemsUltrasealPRO, + KBiosystemsUltrasealXTPro, + UltrasealEPROStatus, + UltrasealPROStatus, + UltrasealXTProStatus, +) + + +class FakeSealerSerial: + """In-memory serial stand-in that mimics the sealer's echo protocol. + + On ``write(command + "\\r")`` it records the command and, if a reply body is + configured for it, queues ``command + body + "\\r"`` to be read back - exactly + like the device, which echoes the command in front of its reply. + """ + + def __init__(self, responses: Dict[str, str]) -> None: + self.responses = responses + self.port = "FAKE" + self.written: List[str] = [] + self._rx = bytearray() + + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def reset_input_buffer(self) -> None: + self._rx.clear() + + async def write(self, data: bytes) -> None: + command = data.decode("ascii").rstrip("\r") + self.written.append(command) + if command in self.responses: + self._rx += (command + self.responses[command] + "\r").encode("ascii") + + async def read(self, num_bytes: int = 1) -> bytes: + if not self._rx: + return b"" + out = bytes(self._rx[:num_bytes]) + del self._rx[:num_bytes] + return out + + +class KBiosystemsSealerTestBase(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + # Skip the firmware's real settle/setpoint sleeps so tests run instantly. + patcher = patch("pylabrobot.kbiosystems.sealer.asyncio.sleep", new_callable=AsyncMock) + patcher.start() + self.addCleanup(patcher.stop) + + +class TestUltrasealEPRO(KBiosystemsSealerTestBase): + def _make(self, responses: Dict[str, str]) -> KBiosystemsUltrasealEPRO: + sealer = KBiosystemsUltrasealEPRO(port="FAKE") + sealer.io = FakeSealerSerial(responses) # type: ignore[assignment] + return sealer + + async def test_setup_sequence(self): + sealer = self._make({"I": "ok", "?": "00", "V": "1.2\rboardA", "A100": "ok"}) + await sealer.setup() + self.assertEqual(sealer.firmware_version, "1.2 | boardA") + self.assertIn("I", sealer.io.written) # type: ignore[attr-defined] + self.assertIn("V", sealer.io.written) # type: ignore[attr-defined] + self.assertIn("A100", sealer.io.written) # type: ignore[attr-defined] + + async def test_seal_distance_mode_wire_bytes(self): + sealer = self._make( + { + "ECO_OFF": "ok", + "?": "00", + "B25": "ok", + "A170": "ok", + "A100": "ok", + "L=120": "ok", + "FS=0": "ok", + "DO=25": "ok", + "S": "ok", + } + ) + await sealer.seal(temperature=170, duration=2.5) + written = sealer.io.written # type: ignore[attr-defined] + for expected in ["ECO_OFF", "B25", "A170", "L=120", "FS=0", "DO=25", "S", "A100"]: + self.assertIn(expected, written) + # Distance mode must not touch the force commands. + self.assertFalse(any(w.startswith("PS=") or w == "FS=1" for w in written)) + + async def test_seal_force_mode_wire_bytes(self): + sealer = self._make( + { + "ECO_OFF": "ok", + "?": "00", + "B25": "ok", + "A170": "ok", + "A100": "ok", + "L=120": "ok", + "FS=1": "ok", + "S": "ok", + } + ) + await sealer.seal(temperature=170, duration=2.5, force_mode=True, sealing_force=30) + written = sealer.io.written # type: ignore[attr-defined] + self.assertIn("FS=1", written) + self.assertIn("PS=30", written) # sent with no reply + self.assertFalse(any(w.startswith("DO=") for w in written)) + + async def test_status_decode(self): + sealer = self._make({"?": "a4"}) + status = await sealer.request_status() + self.assertEqual( + status, + UltrasealEPROStatus.ParkMode | UltrasealEPROStatus.NotInitialised | UltrasealEPROStatus.Busy, + ) + + async def test_temperature_floor_is_5(self): + sealer = self._make({"A005": "ok"}) + await sealer.set_temperature(5) # allowed on the ePRO + with self.assertRaises(ValueError): + await sealer.set_temperature(4) + + +class TestUltrasealXTPro(KBiosystemsSealerTestBase): + def _make(self, responses: Dict[str, str]) -> KBiosystemsUltrasealXTPro: + sealer = KBiosystemsUltrasealXTPro(port="FAKE") + sealer.io = FakeSealerSerial(responses) # type: ignore[assignment] + return sealer + + async def test_setup_sequence(self): + sealer = self._make({"?": "00", "A100": "ok"}) + await sealer.setup() + self.assertEqual(sealer.io.written, ["?", "A100"]) # type: ignore[attr-defined] + + async def test_seal_wire_bytes(self): + sealer = self._make({"?": "00", "B30": "ok", "A180": "ok", "A100": "ok", "S": "ok"}) + await sealer.seal(temperature=180, duration=3.0) + written = sealer.io.written # type: ignore[attr-defined] + for expected in ["B30", "A180", "S", "A100"]: + self.assertIn(expected, written) + # The XT Pro has no foil/force/distance/eco commands. + self.assertFalse(any(w.startswith(("L=", "DO=", "PS=", "FS=", "ECO_")) for w in written)) + + async def test_shuttle_commands(self): + sealer = self._make({"P": "ok", "U": "ok", "R": "ok"}) + self.assertTrue(await sealer.park()) + self.assertTrue(await sealer.unpark()) + self.assertTrue(await sealer.reset()) + + async def test_status_decode_lowair(self): + sealer = self._make({"?": "24"}) + status = await sealer.request_status() + self.assertEqual(status, UltrasealXTProStatus.LowAir | UltrasealXTProStatus.Busy) + + async def test_temperature_floor_is_25(self): + sealer = self._make({"A025": "ok"}) + await sealer.set_temperature(25) + with self.assertRaises(ValueError): + await sealer.set_temperature(20) # allowed on the ePRO, not the XT Pro + + async def test_error_status_raises_with_code(self): + sealer = self._make({"?": "02", "E": "12"}) + with self.assertRaises(KBiosystemsError) as ctx: + await sealer.wait_for_idle() + self.assertEqual(ctx.exception.error_code, 12) + self.assertEqual(ctx.exception.message, "Low air pressure.") + + +class TestUltrasealPRO(KBiosystemsSealerTestBase): + def _make(self, responses: Dict[str, str]) -> KBiosystemsUltrasealPRO: + sealer = KBiosystemsUltrasealPRO(port="FAKE") + sealer.io = FakeSealerSerial(responses) # type: ignore[assignment] + return sealer + + async def test_setup_sequence(self): + sealer = self._make({"?": "00", "A100": "ok"}) + await sealer.setup() + self.assertEqual(sealer.io.written, ["?", "A100"]) # type: ignore[attr-defined] + + async def test_seal_wire_bytes(self): + sealer = self._make({"?": "00", "B30": "ok", "A180": "ok", "A100": "ok", "S": "ok"}) + await sealer.seal(temperature=180, duration=3.0) + written = sealer.io.written # type: ignore[attr-defined] + for expected in ["B30", "A180", "S", "A100"]: + self.assertIn(expected, written) + # The Ultraseal PRO has no foil/force/distance/eco or shuttle commands. + self.assertFalse( + any( + w.startswith(("L=", "DO=", "PS=", "FS=", "ECO_")) or w in ("P", "U", "R") for w in written + ) + ) + + async def test_park_mode_is_bit_7(self): + # The Ultraseal PRO reports Park Mode at 0x80 (bit 6 is spare) - unlike the + # XT Pro, where Park Mode is 0x40. Decoding either byte must not raise. + sealer = self._make({"?": "80"}) + self.assertEqual(await sealer.request_status(), UltrasealPROStatus.ParkMode) + sealer = self._make({"?": "40"}) + self.assertEqual(await sealer.request_status(), UltrasealPROStatus.Spare) + + async def test_status_decode_lowair(self): + sealer = self._make({"?": "24"}) + status = await sealer.request_status() + self.assertEqual(status, UltrasealPROStatus.LowAir | UltrasealPROStatus.Busy) + + async def test_error_status_raises_with_code(self): + sealer = self._make({"?": "02", "E": "09"}) + with self.assertRaises(KBiosystemsError) as ctx: + await sealer.wait_for_idle() + self.assertEqual(ctx.exception.error_code, 9) + self.assertEqual(ctx.exception.message, "The sealer is overheating.") + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/kbiosystems/ultraseal_epro.py b/pylabrobot/kbiosystems/ultraseal_epro.py new file mode 100644 index 00000000000..97f9bf27273 --- /dev/null +++ b/pylabrobot/kbiosystems/ultraseal_epro.py @@ -0,0 +1,240 @@ +import enum +import logging +from typing import Dict, Optional + +from pylabrobot.kbiosystems.sealer import KBiosystemsError, KBiosystemsSealer + +logger = logging.getLogger(__name__) + +__all__ = ["KBiosystemsUltrasealEPRO", "UltrasealEPROStatus", "KBiosystemsError"] + + +class UltrasealEPROStatus(enum.IntFlag): + """Status byte returned by the ``?`` command (two hex digits). + + ``Ready`` (0x00) is the all-clear value; any other bit is a condition that + must be cleared, or masked when ignorable, before an operation runs. + """ + + Ready = 0x00 + NoFoil = 0x01 + Error = 0x02 + Busy = 0x04 + NotAtSealTemperature = 0x08 + PlateNotPresent = 0x10 + NotInitialised = 0x20 + ForceSensorActivated = 0x40 + ParkMode = 0x80 + + +# Text for each status bit, used to describe a not-ready condition. +STATUS_MESSAGES: Dict[enum.IntFlag, str] = { + UltrasealEPROStatus.NoFoil: "No foil detected.", + UltrasealEPROStatus.Error: "Error", + UltrasealEPROStatus.Busy: ( + "Device not ready. Make sure instrument is initialized, film loaded, door " + "closed, tray out, and control software is on main menu" + ), + UltrasealEPROStatus.NotAtSealTemperature: "Waiting for seal temperature", + UltrasealEPROStatus.PlateNotPresent: "No plate detected.", + UltrasealEPROStatus.NotInitialised: "Sealer not initialized.", + UltrasealEPROStatus.ForceSensorActivated: "Force sensor activated.", + UltrasealEPROStatus.ParkMode: "Park mode.", +} + +# Error codes returned by the ``E`` command (two decimal digits). +DEVICE_ERRORS = { + 1: "Vertical shuttle down.", + 2: "Heater up.", + 3: "Shuttle in.", + 4: "Cutter error.", + 5: "Thermocouple error - ambient temperature may be too low.", + 6: "The sealer is overheating.", + 7: "No foil detected.", + 8: "No plate detected.", + 9: "Force sensor activated.", +} + +MIN_SEALING_TEMPERATURE = 5 +MAX_SEALING_TEMPERATURE = 199 +MIN_SEALING_DURATION = 0.5 +MAX_SEALING_DURATION = 9.9 +MIN_SEALING_FORCE = 10 +MAX_SEALING_FORCE = 50 +MIN_SEALING_DISTANCE = 10 +MAX_SEALING_DISTANCE = 50 +MIN_FOIL_LENGTH = 117 +MAX_FOIL_LENGTH = 128 + + +class KBiosystemsUltrasealEPRO(KBiosystemsSealer): + """KBiosystems Ultraseal ePRO heat sealer (formerly the eSeal). + + A benchtop heat sealer that presses a heated head onto foil over a plate. In + addition to the shared temperature/time commands it exposes foil length, + distance- vs force-controlled sealing, eco mode, and an initialize/home step. + + Commands (in addition to the shared ``?``/``E``/``S``/``A``/``B``/``C``/ + ``D``/``F``, see :class:`KBiosystemsSealer`): + + :: + + I initialize/home; replies ok or err + L={l:03d} set foil length (117..128) + DO={d:02d} set sealing distance (10..50), distance mode + PS={f:02d} set sealing force (10..50), force mode; no reply + FS={0|1} set force mode off/on + ECO_{ON|OFF} set eco mode + V read firmware version (two lines) + + Not verified: has NOT been tested against hardware in PyLabRobot. A warning is + emitted at setup. + """ + + _HUMAN_READABLE_NAME = "KBiosystems Ultraseal ePRO Heat Sealer" + + STATUS = UltrasealEPROStatus + STATUS_MESSAGES = STATUS_MESSAGES + ERRORS = DEVICE_ERRORS + MIN_SEALING_TEMPERATURE = MIN_SEALING_TEMPERATURE + MAX_SEALING_TEMPERATURE = MAX_SEALING_TEMPERATURE + + def __init__( + self, + port: str, + timeout: float = 5.0, + settle_time: float = 5.0, + preheating_temperature: int = 100, + offline_temperature: int = 20, + ) -> None: + super().__init__( + port, + timeout=timeout, + settle_time=settle_time, + preheating_temperature=preheating_temperature, + offline_temperature=offline_temperature, + ) + self.firmware_version: Optional[str] = None + + async def setup(self) -> None: + logger.warning( + "KBiosystemsUltrasealEPRO has NOT been tested against hardware in PyLabRobot. " + "Please make a PR to remove this message if you have verified it on your hardware." + ) + await self._open() + # Initialize/home the sealer (``I``). + if self._check(await self.send_command("I"), {"ok", "err"}, "I") != "ok": + raise KBiosystemsError(title="Initializing the Ultraseal ePRO failed") + await self.wait_for_idle( + UltrasealEPROStatus.NoFoil + | UltrasealEPROStatus.NotAtSealTemperature + | UltrasealEPROStatus.ForceSensorActivated + | UltrasealEPROStatus.ParkMode + ) + self.firmware_version = await self.request_firmware_version() + await self.set_temperature(self.preheating_temperature) + logger.info("[Ultraseal ePRO %s] connected: firmware=%s", self.io.port, self.firmware_version) + + # === Parameters === + + async def set_foil_length(self, length: int) -> None: + """Set the foil length (``L=``, 117..128).""" + if not MIN_FOIL_LENGTH <= length <= MAX_FOIL_LENGTH: + raise ValueError(f"foil length must be {MIN_FOIL_LENGTH}..{MAX_FOIL_LENGTH}") + command = f"L={length:03d}" + self._check(await self.send_command(command), {"ok"}, command) + + async def set_sealing_distance(self, distance: int) -> None: + """Set the sealing distance (``DO=``, 10..50). Selects distance mode.""" + if not MIN_SEALING_DISTANCE <= distance <= MAX_SEALING_DISTANCE: + raise ValueError(f"distance must be {MIN_SEALING_DISTANCE}..{MAX_SEALING_DISTANCE}") + command = f"DO={distance:02d}" + reply = await self.send_command(command) + # The firmware acknowledges with "ok" or an empty reply. + if reply not in ("ok", "") or "syntax" in reply: + raise KBiosystemsError(title="Setting distance failed", message=reply) + + async def set_sealing_force(self, force: int) -> None: + """Set the sealing force (``PS=``, 10..50). Selects force mode. Sends no reply.""" + if not MIN_SEALING_FORCE <= force <= MAX_SEALING_FORCE: + raise ValueError(f"force must be {MIN_SEALING_FORCE}..{MAX_SEALING_FORCE}") + await self.send_command(f"PS={force:02d}", read_reply=False) + + async def set_force_mode(self, on: bool) -> None: + """Select force mode (``FS=1``) or distance mode (``FS=0``).""" + command = f"FS={1 if on else 0}" + self._check(await self.send_command(command), {"ok"}, command) + + async def set_eco_mode(self, on: bool) -> None: + """Enable or disable eco mode (``ECO_ON`` / ``ECO_OFF``).""" + command = f"ECO_{'ON' if on else 'OFF'}" + reply = await self.send_command(command) + if reply == "err" or "syntax" in reply: + raise KBiosystemsError(title="Setting eco mode failed", message=reply) + + async def request_firmware_version(self) -> str: + """Read the firmware version (``V``). This device replies with two lines.""" + line1 = await self.send_command("V") + line2 = await self._read_line() + return f"{line1} | {line2}" + + # === Operations === + + async def seal( + self, + temperature: int, + duration: float, + foil_length: int = 120, + force_mode: bool = False, + sealing_force: int = 50, + sealing_distance: int = 25, + idle_temperature: int = 100, + eco_mode: bool = False, + ) -> None: + """Seal a plate. + + Waits for the device to be ready, applies the time/temperature/foil and + force-or-distance parameters, waits for the heater to reach the setpoint, + seals, then returns to ``idle_temperature``. + + Args: + temperature: sealing temperature in degrees C (5..199). + duration: sealing time in seconds (0.5..9.9). + foil_length: foil length (117..128). + force_mode: seal by force (True) or by distance (False). + sealing_force: force in force mode (10..50). + sealing_distance: distance in distance mode (10..50). + idle_temperature: temperature to hold after sealing (5..199). + eco_mode: enable eco mode after sealing. + """ + logger.info( + "[Ultraseal ePRO %s] sealing at %d C for %.1fs", self.io.port, temperature, duration + ) + ignore = ( + UltrasealEPROStatus.NotAtSealTemperature + | UltrasealEPROStatus.ForceSensorActivated + | UltrasealEPROStatus.ParkMode + ) + + # Eco mode is disabled while sealing and restored afterwards if requested. + await self.set_eco_mode(False) + await self.wait_for_idle(ignore) + + await self.set_sealing_time(duration) + await self.set_temperature(temperature) + await self.set_foil_length(foil_length) + if force_mode: + await self.set_force_mode(True) + await self.set_sealing_force(sealing_force) + else: + await self.set_force_mode(False) + await self.set_sealing_distance(sealing_distance) + + await self.wait_for_sealing_temperature() + if self._check(await self.send_command("S"), {"ok", "err"}, "S") != "ok": + raise KBiosystemsError(title="Seal command returned 'err'") + await self.wait_for_idle(ignore) + + await self.set_temperature(idle_temperature) + if eco_mode: + await self.set_eco_mode(True) diff --git a/pylabrobot/kbiosystems/ultraseal_pro.py b/pylabrobot/kbiosystems/ultraseal_pro.py new file mode 100644 index 00000000000..e21b9872c47 --- /dev/null +++ b/pylabrobot/kbiosystems/ultraseal_pro.py @@ -0,0 +1,149 @@ +import enum +import logging +from typing import Dict + +from pylabrobot.kbiosystems.sealer import KBiosystemsError, KBiosystemsSealer + +logger = logging.getLogger(__name__) + +__all__ = ["KBiosystemsUltrasealPRO", "UltrasealPROStatus", "KBiosystemsError"] + + +class UltrasealPROStatus(enum.IntFlag): + """Status byte returned by the ``?`` command (two hex digits). + + ``Ready`` (0x00) is the all-clear value; any other bit is a condition that + must be cleared, or masked when ignorable, before an operation runs. The low + bits (Ready/Error/Busy/NotAtSealTemperature) are the shared set + :class:`KBiosystemsSealer` acts on. + + The Ultraseal PRO differs from the Ultraseal XT Pro in the top of the byte: + ``ParkMode`` is bit 7 (0x80) with bit 6 (0x40) a documented spare, whereas the + XT Pro reports ``ParkMode`` at 0x40. ``Spare`` is kept as a member so decoding + a byte with that bit set does not raise. + """ + + Ready = 0x00 + NoFoil = 0x01 + Error = 0x02 + Busy = 0x04 + NotAtSealTemperature = 0x08 + PlateNotPresent = 0x10 + LowAir = 0x20 + Spare = 0x40 + ParkMode = 0x80 + + +# Text for each status bit, used to describe a not-ready condition. +STATUS_MESSAGES: Dict[enum.IntFlag, str] = { + UltrasealPROStatus.NoFoil: "No foil detected.", + UltrasealPROStatus.Error: "Error.", + UltrasealPROStatus.Busy: "Device busy.", + UltrasealPROStatus.NotAtSealTemperature: "Waiting for seal temperature.", + UltrasealPROStatus.PlateNotPresent: "No plate detected.", + UltrasealPROStatus.LowAir: "Low air pressure - check the compressed air supply.", + UltrasealPROStatus.ParkMode: "Shuttle parked (inside).", +} + +# Error codes returned by the ``E`` command (two decimal digits). +DEVICE_ERRORS = { + 1: "Vertical shuttle down.", + 2: "Heater up.", + 3: "Shuttle in.", + 4: "Shuttle out.", + 7: "Thermocouple error - ambient temperature may be too low.", + 9: "The sealer is overheating.", + 10: "No foil detected.", +} + +MIN_SEALING_TEMPERATURE = 25 +MAX_SEALING_TEMPERATURE = 200 +MIN_SEALING_DURATION = 0.5 +MAX_SEALING_DURATION = 9.9 + + +class KBiosystemsUltrasealPRO(KBiosystemsSealer): + """KBiosystems Ultraseal PRO heat sealer (formerly the WASP). + + An inline, pneumatically driven heat sealer that advances and cuts its own + film internally. A plate is placed on the shuttle while it is extended; the + seal command draws the shuttle in, seals, and returns it automatically, so - + unlike the Ultraseal XT Pro - the Ultraseal PRO exposes no shuttle + park/unpark/reset commands over serial. The system initializes on power up + (reporting ``Busy`` while it does), so there is no initialize command either. + + Commands (in addition to the shared ``?``/``E``/``S``/``A``/``B``/``C``/ + ``D``/``F``, see :class:`KBiosystemsSealer`): none. The Ultraseal PRO host + protocol is exactly the shared command set. + + Requires a compressed air supply (see the ``LowAir`` status bit). + + Verified against hardware. + """ + + _HUMAN_READABLE_NAME = "KBiosystems Ultraseal PRO Heat Sealer" + + STATUS = UltrasealPROStatus + STATUS_MESSAGES = STATUS_MESSAGES + ERRORS = DEVICE_ERRORS + MIN_SEALING_TEMPERATURE = MIN_SEALING_TEMPERATURE + MAX_SEALING_TEMPERATURE = MAX_SEALING_TEMPERATURE + + def __init__( + self, + port: str, + timeout: float = 5.0, + settle_time: float = 5.0, + preheating_temperature: int = 100, + offline_temperature: int = 25, + ) -> None: + super().__init__( + port, + timeout=timeout, + settle_time=settle_time, + preheating_temperature=preheating_temperature, + offline_temperature=offline_temperature, + ) + + async def setup(self) -> None: + await self._open() + await self.wait_for_idle( + UltrasealPROStatus.NoFoil + | UltrasealPROStatus.NotAtSealTemperature + | UltrasealPROStatus.ParkMode + ) + await self.set_temperature(self.preheating_temperature) + logger.info("[Ultraseal PRO %s] connected", self.io.port) + + # === Operations === + + async def seal( + self, + temperature: int, + duration: float, + idle_temperature: int = 100, + ) -> None: + """Seal the plate currently on the shuttle. + + Waits for the device to be ready, applies the time and temperature, waits + for the heater to reach the setpoint, seals, then returns to + ``idle_temperature``. The Ultraseal PRO draws the shuttle in, seals, and + returns it automatically; film advance and cutting are handled by the device. + + Args: + temperature: sealing temperature in degrees C (25..200). + duration: sealing time in seconds (0.5..9.9). + idle_temperature: temperature to hold after sealing (25..200). + """ + logger.info("[Ultraseal PRO %s] sealing at %d C for %.1fs", self.io.port, temperature, duration) + ignore = UltrasealPROStatus.NotAtSealTemperature | UltrasealPROStatus.ParkMode + + await self.wait_for_idle(ignore) + await self.set_sealing_time(duration) + await self.set_temperature(temperature) + await self.wait_for_sealing_temperature() + if self._check(await self.send_command("S"), {"ok", "err"}, "S") != "ok": + raise KBiosystemsError(title="Seal command returned 'err'") + await self.wait_for_idle(ignore) + + await self.set_temperature(idle_temperature) diff --git a/pylabrobot/kbiosystems/ultraseal_xt_pro.py b/pylabrobot/kbiosystems/ultraseal_xt_pro.py new file mode 100644 index 00000000000..87a1a1a8a28 --- /dev/null +++ b/pylabrobot/kbiosystems/ultraseal_xt_pro.py @@ -0,0 +1,196 @@ +import enum +import logging +from typing import Dict + +from pylabrobot.kbiosystems.sealer import KBiosystemsError, KBiosystemsSealer + +logger = logging.getLogger(__name__) + +__all__ = ["KBiosystemsUltrasealXTPro", "UltrasealXTProStatus", "KBiosystemsError"] + + +class UltrasealXTProStatus(enum.IntFlag): + """Status byte returned by the ``?`` command (two hex digits). + + ``Ready`` (0x00) is the all-clear value; any other bit is a condition that + must be cleared, or masked when ignorable, before an operation runs. The low + bits (Ready/Error/Busy/NotAtSealTemperature) are the shared set + :class:`KBiosystemsSealer` acts on; ``LowAir`` and ``ParkMode`` are specific to + this model. + """ + + Ready = 0x00 + NoFoil = 0x01 + Error = 0x02 + Busy = 0x04 + NotAtSealTemperature = 0x08 + PlateNotPresent = 0x10 + LowAir = 0x20 + ParkMode = 0x40 + + +# Text for each status bit, used to describe a not-ready condition. +STATUS_MESSAGES: Dict[enum.IntFlag, str] = { + UltrasealXTProStatus.NoFoil: "No foil detected.", + UltrasealXTProStatus.Error: "Error.", + UltrasealXTProStatus.Busy: "Device busy.", + UltrasealXTProStatus.NotAtSealTemperature: "Waiting for seal temperature.", + UltrasealXTProStatus.PlateNotPresent: "No plate detected.", + UltrasealXTProStatus.LowAir: "Low air pressure - check the compressed air supply.", + UltrasealXTProStatus.ParkMode: "Shuttle parked (inside).", +} + +# Error codes returned by the ``E`` command (two decimal digits). +DEVICE_ERRORS = { + 1: "Vertical shuttle down.", + 2: "Heater up.", + 3: "Shuttle in.", + 4: "Shuttle out.", + 7: "Thermocouple error.", + 8: "Heater failure.", + 9: "The sealer is overheating.", + 10: "No foil detected.", + 11: "No plate detected.", + 12: "Low air pressure.", + 13: "Door error.", +} + +MIN_SEALING_TEMPERATURE = 25 +MAX_SEALING_TEMPERATURE = 199 +MIN_SEALING_DURATION = 0.5 +MAX_SEALING_DURATION = 9.9 + + +class KBiosystemsUltrasealXTPro(KBiosystemsSealer): + """KBiosystems Ultraseal XT Pro heat sealer. + + An inline, pneumatically driven heat sealer that advances and cuts its own + film internally, so it exposes no foil-length/force/distance parameters. A + plate is presented by moving the shuttle out (unparking); after the plate is + loaded, the shuttle is moved in (parked) and the plate is sealed. + + Commands (in addition to the shared ``?``/``E``/``S``/``A``/``B``/``C``/ + ``D``/``F``, see :class:`KBiosystemsSealer`): + + :: + + P park the shuttle (move in); replies ok or err + U unpark the shuttle (move out); replies ok or err + R reset the sealer; replies ok + + Requires a compressed air supply (see the ``LowAir`` status bit). + + Not verified: has NOT been tested against hardware in PyLabRobot. A warning is + emitted at setup. + """ + + _HUMAN_READABLE_NAME = "KBiosystems Ultraseal XT Pro Heat Sealer" + + STATUS = UltrasealXTProStatus + STATUS_MESSAGES = STATUS_MESSAGES + ERRORS = DEVICE_ERRORS + MIN_SEALING_TEMPERATURE = MIN_SEALING_TEMPERATURE + MAX_SEALING_TEMPERATURE = MAX_SEALING_TEMPERATURE + + def __init__( + self, + port: str, + timeout: float = 5.0, + settle_time: float = 5.0, + preheating_temperature: int = 100, + offline_temperature: int = 25, + ) -> None: + super().__init__( + port, + timeout=timeout, + settle_time=settle_time, + preheating_temperature=preheating_temperature, + offline_temperature=offline_temperature, + ) + + async def setup(self) -> None: + logger.warning( + "KBiosystemsUltrasealXTPro has NOT been tested against hardware in PyLabRobot. " + "Please make a PR to remove this message if you have verified it on your hardware." + ) + await self._open() + await self.wait_for_idle( + UltrasealXTProStatus.NoFoil + | UltrasealXTProStatus.NotAtSealTemperature + | UltrasealXTProStatus.ParkMode + ) + await self.set_temperature(self.preheating_temperature) + logger.info("[Ultraseal XT Pro %s] connected", self.io.port) + + # === Shuttle === + + async def park(self) -> bool: + """Park the shuttle / move it in (``P``). Returns True on ``ok``.""" + return self._check(await self.send_command("P"), {"ok", "err"}, "P") == "ok" + + async def unpark(self) -> bool: + """Unpark the shuttle / move it out (``U``). Returns True on ``ok``.""" + return self._check(await self.send_command("U"), {"ok", "err"}, "U") == "ok" + + async def reset(self) -> bool: + """Reset the sealer (``R``). Returns True on ``ok``.""" + return await self.send_command("R") == "ok" + + async def move_shuttle_out(self) -> None: + """Present a plate: unpark the shuttle if needed, then wait until ready. + + ``NoFoil`` and ``NotAtSealTemperature`` are tolerated so a plate can be + loaded while the film is being advanced or the heater is warming. + """ + status = await self._wait_for_busy_cleared(timeout=30.0) + if status & UltrasealXTProStatus.ParkMode and not status & UltrasealXTProStatus.Busy: + await self.unpark() + await self.wait_for_idle( + UltrasealXTProStatus.NoFoil | UltrasealXTProStatus.NotAtSealTemperature + ) + + async def move_shuttle_in(self) -> None: + """Retract a plate for sealing: park the shuttle if needed, then wait.""" + status = await self._wait_for_busy_cleared(timeout=30.0) + if not status & UltrasealXTProStatus.ParkMode and not status & UltrasealXTProStatus.Busy: + await self.park() + await self.wait_for_idle( + UltrasealXTProStatus.NoFoil + | UltrasealXTProStatus.NotAtSealTemperature + | UltrasealXTProStatus.ParkMode + ) + + # === Operations === + + async def seal( + self, + temperature: int, + duration: float, + idle_temperature: int = 100, + ) -> None: + """Seal the plate currently in the sealer. + + Waits for the device to be ready, applies the time and temperature, waits + for the heater to reach the setpoint, seals, then returns to + ``idle_temperature``. The shuttle must already be in (see + :meth:`move_shuttle_in`); film advance and cutting are handled by the device. + + Args: + temperature: sealing temperature in degrees C (25..199). + duration: sealing time in seconds (0.5..9.9). + idle_temperature: temperature to hold after sealing (25..199). + """ + logger.info( + "[Ultraseal XT Pro %s] sealing at %d C for %.1fs", self.io.port, temperature, duration + ) + ignore = UltrasealXTProStatus.NotAtSealTemperature | UltrasealXTProStatus.ParkMode + + await self.wait_for_idle(ignore) + await self.set_sealing_time(duration) + await self.set_temperature(temperature) + await self.wait_for_sealing_temperature() + if self._check(await self.send_command("S"), {"ok", "err"}, "S") != "ok": + raise KBiosystemsError(title="Seal command returned 'err'") + await self.wait_for_idle(ignore) + + await self.set_temperature(idle_temperature) diff --git a/pylabrobot/keyence/__init__.py b/pylabrobot/keyence/__init__.py new file mode 100644 index 00000000000..7b542d88825 --- /dev/null +++ b/pylabrobot/keyence/__init__.py @@ -0,0 +1 @@ +from pylabrobot.keyence.barcode_scanner import KeyenceBarcodeScanner, KeyenceBarcodeScannerError diff --git a/pylabrobot/keyence/barcode_scanner.py b/pylabrobot/keyence/barcode_scanner.py new file mode 100644 index 00000000000..8482962fc16 --- /dev/null +++ b/pylabrobot/keyence/barcode_scanner.py @@ -0,0 +1,101 @@ +import asyncio +import logging +import time + +try: + import serial + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e + +from typing import Optional + +from pylabrobot.io.serial import Serial +from pylabrobot.resources.barcode import Barcode + +logger = logging.getLogger(__name__) + + +class KeyenceBarcodeScannerError(Exception): + """Base exception for Keyence barcode scanner errors.""" + + +class KeyenceBarcodeScanner: + """Keyence barcode scanner (BL-600HA, BL-1300).""" + + serial_messaging_encoding = "ascii" + default_baudrate = 9600 + init_timeout = 1.0 + poll_interval = 0.2 + + def __init__(self, port: str): + if not HAS_SERIAL: + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) + super().__init__() + + # BL-1300 Barcode reader factory default serial communication settings + # should be the same factory default for the BL-600HA and BL-1300 models + self.io = Serial( + human_readable_device_name="Keyence Barcode Scanner", + port=port, + baudrate=self.default_baudrate, + bytesize=serial.SEVENBITS, + parity=serial.PARITY_EVEN, + stopbits=serial.STOPBITS_ONE, + write_timeout=1, + timeout=1, + rtscts=False, + ) + + async def setup(self): + await self.io.setup() + logger.info("[Keyence %s] connected", self.io.port) + + deadline = time.time() + self.init_timeout + while time.time() < deadline: + response = await self.send_command("RMOTOR") + if response.strip() == "MOTORON": + logger.info("[Keyence %s] barcode scanner motor is ON", self.io.port) + break + elif response.strip() == "MOTOROFF": + raise KeyenceBarcodeScannerError( + "Failed to initialize Keyence barcode scanner: Motor is off." + ) + await asyncio.sleep(self.poll_interval) + else: + raise KeyenceBarcodeScannerError( + "Failed to initialize Keyence barcode scanner: Timeout waiting for motor to turn on." + ) + + async def stop(self): + await self.io.stop() + logger.info("[Keyence %s] disconnected", self.io.port) + + async def send_command(self, command: str) -> str: + """Send a command to the barcode scanner and return the response. + Keyence uses carriage return \\r as the line ending by default.""" + + await self.io.write((command + "\r").encode(self.serial_messaging_encoding)) + response = await self.io.read() + decoded = response.decode(self.serial_messaging_encoding).strip() + return decoded + + async def scan_barcode(self, read_time: Optional[float] = None) -> Optional[Barcode]: + # Keyence BL-series LON command doesn't take a read window — the scanner + # uses its own configured read mode/timeout. Accept and ignore for + # capability-API compatibility. + del read_time + data = await self.send_command("LON") + if data.startswith("NG"): + logger.error("[Keyence %s] barcode reader is off: cannot read barcode", self.io.port) + raise KeyenceBarcodeScannerError("Barcode reader is off: cannot read barcode") + if data.startswith("ERR99"): + logger.error("[Keyence %s] barcode reader error: %s", self.io.port, data) + raise KeyenceBarcodeScannerError(f"Error response from barcode reader: {data}") + logger.info("[Keyence %s] scanned barcode: %s", self.io.port, data) + return Barcode(data=data, symbology="unknown", position_on_resource="front") diff --git a/pylabrobot/legacy/__init__.py b/pylabrobot/legacy/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/arms/__init__.py b/pylabrobot/legacy/arms/__init__.py similarity index 100% rename from pylabrobot/arms/__init__.py rename to pylabrobot/legacy/arms/__init__.py diff --git a/pylabrobot/arms/backend.py b/pylabrobot/legacy/arms/backend.py similarity index 96% rename from pylabrobot/arms/backend.py rename to pylabrobot/legacy/arms/backend.py index 8a6ebabd6f1..39f0feea437 100644 --- a/pylabrobot/arms/backend.py +++ b/pylabrobot/legacy/arms/backend.py @@ -2,8 +2,8 @@ from dataclasses import dataclass from typing import Dict, List, Optional, Union -from pylabrobot.arms.precise_flex.coords import PreciseFlexCartesianCoords -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.arms.precise_flex.coords import PreciseFlexCartesianCoords +from pylabrobot.legacy.machines.backend import MachineBackend @dataclass diff --git a/pylabrobot/arms/precise_flex/__init__.py b/pylabrobot/legacy/arms/precise_flex/__init__.py similarity index 100% rename from pylabrobot/arms/precise_flex/__init__.py rename to pylabrobot/legacy/arms/precise_flex/__init__.py diff --git a/pylabrobot/arms/precise_flex/coords.py b/pylabrobot/legacy/arms/precise_flex/coords.py similarity index 81% rename from pylabrobot/arms/precise_flex/coords.py rename to pylabrobot/legacy/arms/precise_flex/coords.py index 6d039c0d31a..dd172c40177 100644 --- a/pylabrobot/arms/precise_flex/coords.py +++ b/pylabrobot/legacy/arms/precise_flex/coords.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Optional -from pylabrobot.arms.standard import CartesianCoords +from pylabrobot.legacy.arms.standard import CartesianCoords class ElbowOrientation(Enum): diff --git a/pylabrobot/arms/precise_flex/error_codes.py b/pylabrobot/legacy/arms/precise_flex/error_codes.py similarity index 100% rename from pylabrobot/arms/precise_flex/error_codes.py rename to pylabrobot/legacy/arms/precise_flex/error_codes.py diff --git a/pylabrobot/arms/precise_flex/joints.py b/pylabrobot/legacy/arms/precise_flex/joints.py similarity index 100% rename from pylabrobot/arms/precise_flex/joints.py rename to pylabrobot/legacy/arms/precise_flex/joints.py diff --git a/pylabrobot/arms/precise_flex/pf_3400.py b/pylabrobot/legacy/arms/precise_flex/pf_3400.py similarity index 76% rename from pylabrobot/arms/precise_flex/pf_3400.py rename to pylabrobot/legacy/arms/precise_flex/pf_3400.py index 152cc8a6b41..5b35474576b 100644 --- a/pylabrobot/arms/precise_flex/pf_3400.py +++ b/pylabrobot/legacy/arms/precise_flex/pf_3400.py @@ -1,4 +1,4 @@ -from pylabrobot.arms.precise_flex.precise_flex_backend import PreciseFlexBackend +from pylabrobot.legacy.arms.precise_flex.precise_flex_backend import PreciseFlexBackend class PreciseFlex3400Backend(PreciseFlexBackend): diff --git a/pylabrobot/arms/precise_flex/pf_400.py b/pylabrobot/legacy/arms/precise_flex/pf_400.py similarity index 76% rename from pylabrobot/arms/precise_flex/pf_400.py rename to pylabrobot/legacy/arms/precise_flex/pf_400.py index 524ab5020fe..b0a5ec0d837 100644 --- a/pylabrobot/arms/precise_flex/pf_400.py +++ b/pylabrobot/legacy/arms/precise_flex/pf_400.py @@ -1,4 +1,4 @@ -from pylabrobot.arms.precise_flex.precise_flex_backend import PreciseFlexBackend +from pylabrobot.legacy.arms.precise_flex.precise_flex_backend import PreciseFlexBackend class PreciseFlex400Backend(PreciseFlexBackend): diff --git a/pylabrobot/arms/precise_flex/precise_flex_backend.py b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py similarity index 99% rename from pylabrobot/arms/precise_flex/precise_flex_backend.py rename to pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py index b7acab98566..afba04dfe69 100644 --- a/pylabrobot/arms/precise_flex/precise_flex_backend.py +++ b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py @@ -3,16 +3,16 @@ from abc import ABC from typing import Dict, List, Literal, Optional, Union -from pylabrobot.arms.backend import ( +from pylabrobot.io.socket import Socket +from pylabrobot.legacy.arms.backend import ( AccessPattern, HorizontalAccess, SCARABackend, VerticalAccess, ) -from pylabrobot.arms.precise_flex.coords import ElbowOrientation, PreciseFlexCartesianCoords -from pylabrobot.arms.precise_flex.error_codes import ERROR_CODES -from pylabrobot.arms.precise_flex.joints import PFAxis -from pylabrobot.io.socket import Socket +from pylabrobot.legacy.arms.precise_flex.coords import ElbowOrientation, PreciseFlexCartesianCoords +from pylabrobot.legacy.arms.precise_flex.error_codes import ERROR_CODES +from pylabrobot.legacy.arms.precise_flex.joints import PFAxis from pylabrobot.resources import Coordinate, Rotation diff --git a/pylabrobot/arms/precise_flex/precise_flex_backend_tests.py b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend_tests.py similarity index 99% rename from pylabrobot/arms/precise_flex/precise_flex_backend_tests.py rename to pylabrobot/legacy/arms/precise_flex/precise_flex_backend_tests.py index 0742f7281e7..d61f5d0ba6e 100644 --- a/pylabrobot/arms/precise_flex/precise_flex_backend_tests.py +++ b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend_tests.py @@ -2,11 +2,14 @@ from typing import Dict from unittest.mock import AsyncMock, patch -from pylabrobot.arms.backend import HorizontalAccess, VerticalAccess -from pylabrobot.arms.precise_flex.coords import ElbowOrientation, PreciseFlexCartesianCoords -from pylabrobot.arms.precise_flex.joints import PFAxis -from pylabrobot.arms.precise_flex.precise_flex_backend import PreciseFlexBackend, PreciseFlexError from pylabrobot.io.socket import Socket # Import Socket for mocking +from pylabrobot.legacy.arms.backend import HorizontalAccess, VerticalAccess +from pylabrobot.legacy.arms.precise_flex.coords import ElbowOrientation, PreciseFlexCartesianCoords +from pylabrobot.legacy.arms.precise_flex.joints import PFAxis +from pylabrobot.legacy.arms.precise_flex.precise_flex_backend import ( + PreciseFlexBackend, + PreciseFlexError, +) from pylabrobot.resources import Coordinate, Rotation @@ -27,7 +30,7 @@ def setUp(self): # Patch the Socket class where it's used in PreciseFlexBackend patcher = patch( - "pylabrobot.arms.precise_flex.precise_flex_backend.Socket", + "pylabrobot.legacy.arms.precise_flex.precise_flex_backend.Socket", return_value=self.mock_socket_instance, ) self.MockSocketClass = patcher.start() # Store the mock of the class diff --git a/pylabrobot/arms/scara.py b/pylabrobot/legacy/arms/scara.py similarity index 95% rename from pylabrobot/arms/scara.py rename to pylabrobot/legacy/arms/scara.py index fed8345032d..72932f3db80 100644 --- a/pylabrobot/arms/scara.py +++ b/pylabrobot/legacy/arms/scara.py @@ -1,8 +1,8 @@ from typing import Dict, List, Optional, Union -from pylabrobot.arms.backend import AccessPattern, SCARABackend -from pylabrobot.arms.precise_flex.coords import PreciseFlexCartesianCoords -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.arms.backend import AccessPattern, SCARABackend +from pylabrobot.legacy.arms.precise_flex.coords import PreciseFlexCartesianCoords +from pylabrobot.legacy.machines.machine import Machine class ExperimentalSCARA(Machine): diff --git a/pylabrobot/arms/scara_tests.py b/pylabrobot/legacy/arms/scara_tests.py similarity index 94% rename from pylabrobot/arms/scara_tests.py rename to pylabrobot/legacy/arms/scara_tests.py index 4455abc104d..c3be1b27deb 100644 --- a/pylabrobot/arms/scara_tests.py +++ b/pylabrobot/legacy/arms/scara_tests.py @@ -1,9 +1,9 @@ import unittest from unittest.mock import AsyncMock, MagicMock -from pylabrobot.arms.backend import SCARABackend -from pylabrobot.arms.precise_flex.coords import PreciseFlexCartesianCoords -from pylabrobot.arms.scara import ExperimentalSCARA +from pylabrobot.legacy.arms.backend import SCARABackend +from pylabrobot.legacy.arms.precise_flex.coords import PreciseFlexCartesianCoords +from pylabrobot.legacy.arms.scara import ExperimentalSCARA from pylabrobot.resources import Coordinate, Rotation diff --git a/pylabrobot/arms/standard.py b/pylabrobot/legacy/arms/standard.py similarity index 100% rename from pylabrobot/arms/standard.py rename to pylabrobot/legacy/arms/standard.py diff --git a/pylabrobot/legacy/barcode_scanners/__init__.py b/pylabrobot/legacy/barcode_scanners/__init__.py new file mode 100644 index 00000000000..befd981f9a9 --- /dev/null +++ b/pylabrobot/legacy/barcode_scanners/__init__.py @@ -0,0 +1,3 @@ +from .backend import BarcodeScannerBackend, BarcodeScannerError +from .barcode_scanner import BarcodeScanner +from .keyence import KeyenceBarcodeScannerBackend diff --git a/pylabrobot/barcode_scanners/backend.py b/pylabrobot/legacy/barcode_scanners/backend.py similarity index 86% rename from pylabrobot/barcode_scanners/backend.py rename to pylabrobot/legacy/barcode_scanners/backend.py index 4a8b75fb9ae..62458fd6162 100644 --- a/pylabrobot/barcode_scanners/backend.py +++ b/pylabrobot/legacy/barcode_scanners/backend.py @@ -1,6 +1,6 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend from pylabrobot.resources.barcode import Barcode diff --git a/pylabrobot/barcode_scanners/barcode_scanner.py b/pylabrobot/legacy/barcode_scanners/barcode_scanner.py similarity index 74% rename from pylabrobot/barcode_scanners/barcode_scanner.py rename to pylabrobot/legacy/barcode_scanners/barcode_scanner.py index 821e5789ae2..f08c567f004 100644 --- a/pylabrobot/barcode_scanners/barcode_scanner.py +++ b/pylabrobot/legacy/barcode_scanners/barcode_scanner.py @@ -1,5 +1,5 @@ -from pylabrobot.barcode_scanners.backend import BarcodeScannerBackend -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.barcode_scanners.backend import BarcodeScannerBackend +from pylabrobot.legacy.machines.machine import Machine from pylabrobot.resources.barcode import Barcode diff --git a/pylabrobot/barcode_scanners/keyence/__init__.py b/pylabrobot/legacy/barcode_scanners/keyence/__init__.py similarity index 100% rename from pylabrobot/barcode_scanners/keyence/__init__.py rename to pylabrobot/legacy/barcode_scanners/keyence/__init__.py diff --git a/pylabrobot/barcode_scanners/keyence/keyence_backend.py b/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py similarity index 98% rename from pylabrobot/barcode_scanners/keyence/keyence_backend.py rename to pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py index 92dee9935c6..f95ef38fb39 100644 --- a/pylabrobot/barcode_scanners/keyence/keyence_backend.py +++ b/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py @@ -10,11 +10,11 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e -from pylabrobot.barcode_scanners.backend import ( +from pylabrobot.io.serial import Serial +from pylabrobot.legacy.barcode_scanners.backend import ( BarcodeScannerBackend, BarcodeScannerError, ) -from pylabrobot.io.serial import Serial from pylabrobot.resources.barcode import Barcode logger = logging.getLogger(__name__) diff --git a/pylabrobot/barcode_scanners/keyence/keyence_backend_tests.py b/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend_tests.py similarity index 96% rename from pylabrobot/barcode_scanners/keyence/keyence_backend_tests.py rename to pylabrobot/legacy/barcode_scanners/keyence/keyence_backend_tests.py index 69f7285f564..1dda64ee4fc 100644 --- a/pylabrobot/barcode_scanners/keyence/keyence_backend_tests.py +++ b/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend_tests.py @@ -1,8 +1,8 @@ import unittest from unittest.mock import AsyncMock, call -from pylabrobot.barcode_scanners.backend import BarcodeScannerError -from pylabrobot.barcode_scanners.keyence.keyence_backend import ( +from pylabrobot.legacy.barcode_scanners.backend import BarcodeScannerError +from pylabrobot.legacy.barcode_scanners.keyence.keyence_backend import ( HAS_SERIAL, KeyenceBarcodeScannerBackend, ) diff --git a/pylabrobot/legacy/centrifuge/__init__.py b/pylabrobot/legacy/centrifuge/__init__.py new file mode 100644 index 00000000000..f910c01a64e --- /dev/null +++ b/pylabrobot/legacy/centrifuge/__init__.py @@ -0,0 +1,17 @@ +from .access2 import Access2 +from .centrifuge import Centrifuge, Loader +from .highres import ( + MicroSpin, + MicroSpinAbortedError, + MicroSpinBackend, + MicroSpinError, + MicroSpinProtocolError, +) +from .standard import ( + BucketHasPlateError, + BucketNoPlateError, + CentrifugeDoorError, + LoaderNoPlateError, + NotAtBucketError, +) +from .vspin_backend import Access2Backend, VSpinBackend diff --git a/pylabrobot/centrifuge/access2.py b/pylabrobot/legacy/centrifuge/access2.py similarity index 80% rename from pylabrobot/centrifuge/access2.py rename to pylabrobot/legacy/centrifuge/access2.py index 8f773914ab7..a91eee917d9 100644 --- a/pylabrobot/centrifuge/access2.py +++ b/pylabrobot/legacy/centrifuge/access2.py @@ -1,7 +1,7 @@ from typing import Tuple -from pylabrobot.centrifuge.centrifuge import Centrifuge, Loader -from pylabrobot.centrifuge.vspin_backend import Access2Backend, VSpinBackend +from pylabrobot.legacy.centrifuge.centrifuge import Centrifuge, Loader +from pylabrobot.legacy.centrifuge.vspin_backend import Access2Backend, VSpinBackend from pylabrobot.resources import Coordinate diff --git a/pylabrobot/centrifuge/backend.py b/pylabrobot/legacy/centrifuge/backend.py similarity index 94% rename from pylabrobot/centrifuge/backend.py rename to pylabrobot/legacy/centrifuge/backend.py index b0429268204..0bd70c3b331 100644 --- a/pylabrobot/centrifuge/backend.py +++ b/pylabrobot/legacy/centrifuge/backend.py @@ -2,7 +2,7 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class CentrifugeBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/centrifuge/centrifuge.py b/pylabrobot/legacy/centrifuge/centrifuge.py similarity index 93% rename from pylabrobot/centrifuge/centrifuge.py rename to pylabrobot/legacy/centrifuge/centrifuge.py index 2476882e5ad..6323fde9327 100644 --- a/pylabrobot/centrifuge/centrifuge.py +++ b/pylabrobot/legacy/centrifuge/centrifuge.py @@ -1,15 +1,15 @@ import warnings -from typing import Optional, Tuple +from typing import Optional, Tuple, cast -from pylabrobot.centrifuge.backend import CentrifugeBackend, LoaderBackend -from pylabrobot.centrifuge.standard import ( +from pylabrobot.legacy.centrifuge.backend import CentrifugeBackend, LoaderBackend +from pylabrobot.legacy.centrifuge.standard import ( BucketHasPlateError, BucketNoPlateError, CentrifugeDoorError, LoaderNoPlateError, NotAtBucketError, ) -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.machines.machine import Machine from pylabrobot.resources import Coordinate, Resource, ResourceHolder from pylabrobot.resources.rotation import Rotation from pylabrobot.serializer import deserialize @@ -143,9 +143,9 @@ def deserialize(cls, data: dict, allow_marshal: bool = False): size_x=data["size_x"], size_y=data["size_y"], size_z=data["size_z"], - rotation=Rotation.deserialize(data["rotation"]), - category=data["category"], - model=data["model"], + rotation=cast(Optional[Rotation], deserialize(data.get("rotation"))), + category=data.get("category"), + model=data.get("model"), buckets=buckets, ) @@ -236,7 +236,7 @@ def deserialize(cls, data: dict, allow_marshal: bool = False): size_y=data["resource"]["size_y"], size_z=data["resource"]["size_z"], child_location=deserialize(data["resource"]["child_location"]), - rotation=deserialize(data["resource"]["rotation"]), - category=data["resource"]["category"], - model=data["resource"]["model"], + rotation=deserialize(data["resource"].get("rotation")), + category=data["resource"].get("category"), + model=data["resource"].get("model"), ) diff --git a/pylabrobot/centrifuge/centrifuge_tests.py b/pylabrobot/legacy/centrifuge/centrifuge_tests.py similarity index 95% rename from pylabrobot/centrifuge/centrifuge_tests.py rename to pylabrobot/legacy/centrifuge/centrifuge_tests.py index 0ed2fb30c09..88ef064593d 100644 --- a/pylabrobot/centrifuge/centrifuge_tests.py +++ b/pylabrobot/legacy/centrifuge/centrifuge_tests.py @@ -1,7 +1,7 @@ import unittest import unittest.mock -from pylabrobot.centrifuge import ( +from pylabrobot.legacy.centrifuge import ( BucketHasPlateError, BucketNoPlateError, Centrifuge, @@ -10,8 +10,11 @@ LoaderNoPlateError, NotAtBucketError, ) -from pylabrobot.centrifuge.backend import CentrifugeBackend, LoaderBackend -from pylabrobot.centrifuge.chatterbox import CentrifugeChatterboxBackend, LoaderChatterboxBackend +from pylabrobot.legacy.centrifuge.backend import CentrifugeBackend, LoaderBackend +from pylabrobot.legacy.centrifuge.chatterbox import ( + CentrifugeChatterboxBackend, + LoaderChatterboxBackend, +) from pylabrobot.resources import Coordinate, cor_96_wellplate_360uL_Fb diff --git a/pylabrobot/centrifuge/chatterbox.py b/pylabrobot/legacy/centrifuge/chatterbox.py similarity index 93% rename from pylabrobot/centrifuge/chatterbox.py rename to pylabrobot/legacy/centrifuge/chatterbox.py index 4f32d678473..6e74d270af2 100644 --- a/pylabrobot/centrifuge/chatterbox.py +++ b/pylabrobot/legacy/centrifuge/chatterbox.py @@ -1,4 +1,4 @@ -from pylabrobot.centrifuge.backend import CentrifugeBackend, LoaderBackend +from pylabrobot.legacy.centrifuge.backend import CentrifugeBackend, LoaderBackend class CentrifugeChatterboxBackend(CentrifugeBackend): diff --git a/pylabrobot/centrifuge/highres/__init__.py b/pylabrobot/legacy/centrifuge/highres/__init__.py similarity index 100% rename from pylabrobot/centrifuge/highres/__init__.py rename to pylabrobot/legacy/centrifuge/highres/__init__.py diff --git a/pylabrobot/centrifuge/highres/microspin.py b/pylabrobot/legacy/centrifuge/highres/microspin.py similarity index 85% rename from pylabrobot/centrifuge/highres/microspin.py rename to pylabrobot/legacy/centrifuge/highres/microspin.py index ffca7c7d2e0..0ac35cbd0b3 100644 --- a/pylabrobot/centrifuge/highres/microspin.py +++ b/pylabrobot/legacy/centrifuge/highres/microspin.py @@ -4,8 +4,8 @@ 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 +the :class:`~pylabrobot.legacy.centrifuge.Centrifuge` returned here is *not* +paired with a separate :class:`~pylabrobot.legacy.centrifuge.Loader` (unlike the Agilent VSpin + Access2 combo). """ @@ -13,8 +13,8 @@ from typing import Optional -from pylabrobot.centrifuge.centrifuge import Centrifuge -from pylabrobot.centrifuge.highres.microspin_backend import MicroSpinBackend +from pylabrobot.legacy.centrifuge.centrifuge import Centrifuge +from pylabrobot.legacy.centrifuge.highres.microspin_backend import MicroSpinBackend # Spec sheet (manual §11): # Dimensions (H x W x L): 15" x 15" x 16" @@ -52,7 +52,7 @@ def MicroSpin( ``host``/``port``/``timeout``. Returns: - A :class:`~pylabrobot.centrifuge.Centrifuge` wired to a + A :class:`~pylabrobot.legacy.centrifuge.Centrifuge` wired to a :class:`MicroSpinBackend`. """ if backend is None: diff --git a/pylabrobot/centrifuge/highres/microspin_backend.py b/pylabrobot/legacy/centrifuge/highres/microspin_backend.py similarity index 99% rename from pylabrobot/centrifuge/highres/microspin_backend.py rename to pylabrobot/legacy/centrifuge/highres/microspin_backend.py index 8f9d3917761..49943aa115e 100644 --- a/pylabrobot/centrifuge/highres/microspin_backend.py +++ b/pylabrobot/legacy/centrifuge/highres/microspin_backend.py @@ -14,7 +14,7 @@ This module also exposes a small surface of MicroSpin-specific helpers (``home``, ``abort``, ``request_status`` ...) in addition to the abstract -:class:`~pylabrobot.centrifuge.backend.CentrifugeBackend` interface. +:class:`~pylabrobot.legacy.centrifuge.backend.CentrifugeBackend` interface. Safety ------ @@ -118,7 +118,7 @@ class MicroSpinBackend(CentrifugeBackend): #: (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`. + #: :func:`~pylabrobot.legacy.centrifuge.highres.microspin.MicroSpin`. DEFAULT_PORT: int = 1000 #: Spec sheet maximum (manual §11). The firmware will reject larger values. MAX_G_FORCE = 3000 diff --git a/pylabrobot/centrifuge/highres/microspin_tests.py b/pylabrobot/legacy/centrifuge/highres/microspin_tests.py similarity index 99% rename from pylabrobot/centrifuge/highres/microspin_tests.py rename to pylabrobot/legacy/centrifuge/highres/microspin_tests.py index 4456b3eb6c0..f2486e97778 100644 --- a/pylabrobot/centrifuge/highres/microspin_tests.py +++ b/pylabrobot/legacy/centrifuge/highres/microspin_tests.py @@ -23,7 +23,7 @@ import warnings from typing import List, Tuple -from pylabrobot.centrifuge.highres.microspin_backend import ( +from pylabrobot.legacy.centrifuge.highres.microspin_backend import ( MicroSpinAbortedError, MicroSpinBackend, MicroSpinError, @@ -551,7 +551,7 @@ async def test_wait_for_spindle_stopped_rejects_non_positive_poll_interval(self) 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 + from pylabrobot.legacy.centrifuge import MicroSpin self.assertEqual(MicroSpinBackend.DEFAULT_PORT, 1000) backend = MicroSpinBackend(host="example.invalid") @@ -561,7 +561,7 @@ def test_default_port_is_1000(self): self.assertEqual(cf.backend.port, 1000) def test_port_can_be_customised(self): - from pylabrobot.centrifuge import MicroSpin + from pylabrobot.legacy.centrifuge import MicroSpin backend = MicroSpinBackend(host="example.invalid", port=9001) self.assertEqual(backend.port, 9001) diff --git a/pylabrobot/centrifuge/highres/mock_server.py b/pylabrobot/legacy/centrifuge/highres/mock_server.py similarity index 100% rename from pylabrobot/centrifuge/highres/mock_server.py rename to pylabrobot/legacy/centrifuge/highres/mock_server.py diff --git a/pylabrobot/centrifuge/highres/mock_server_tests.py b/pylabrobot/legacy/centrifuge/highres/mock_server_tests.py similarity index 99% rename from pylabrobot/centrifuge/highres/mock_server_tests.py rename to pylabrobot/legacy/centrifuge/highres/mock_server_tests.py index 84fff77307c..606f550e71b 100644 --- a/pylabrobot/centrifuge/highres/mock_server_tests.py +++ b/pylabrobot/legacy/centrifuge/highres/mock_server_tests.py @@ -13,12 +13,12 @@ import asyncio import unittest -from pylabrobot.centrifuge.highres.microspin_backend import ( +from pylabrobot.legacy.centrifuge.highres.microspin_backend import ( MicroSpinAbortedError, MicroSpinBackend, MicroSpinError, ) -from pylabrobot.centrifuge.highres.mock_server import MicroSpinMockServer +from pylabrobot.legacy.centrifuge.highres.mock_server import MicroSpinMockServer class _MockServerTestBase(unittest.IsolatedAsyncioTestCase): diff --git a/pylabrobot/centrifuge/standard.py b/pylabrobot/legacy/centrifuge/standard.py similarity index 100% rename from pylabrobot/centrifuge/standard.py rename to pylabrobot/legacy/centrifuge/standard.py diff --git a/pylabrobot/centrifuge/vspin_backend.py b/pylabrobot/legacy/centrifuge/vspin_backend.py similarity index 99% rename from pylabrobot/centrifuge/vspin_backend.py rename to pylabrobot/legacy/centrifuge/vspin_backend.py index 38f4102ad0e..a7583ee01cd 100644 --- a/pylabrobot/centrifuge/vspin_backend.py +++ b/pylabrobot/legacy/centrifuge/vspin_backend.py @@ -280,7 +280,7 @@ async def setup(self): # 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() + device_id = await self.io.request_serial() self._bucket_1_remainder = _load_vspin_calibrations(device_id) @property @@ -292,7 +292,7 @@ def bucket_1_remainder(self) -> int: 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() + device_id = await self.io.request_serial() remainder = await self.get_home_position() - current_position self._bucket_1_remainder = current_position % FULL_ROTATION _save_vspin_calibrations(device_id, remainder) diff --git a/pylabrobot/legacy/heating_shaking/__init__.py b/pylabrobot/legacy/heating_shaking/__init__.py new file mode 100644 index 00000000000..f4a0d610e17 --- /dev/null +++ b/pylabrobot/legacy/heating_shaking/__init__.py @@ -0,0 +1,16 @@ +"""A hybrid between pylabrobot.legacy.shaking and pylabrobot.legacy.temperature_controlling""" + +from pylabrobot.legacy.heating_shaking.backend import HeaterShakerBackend +from pylabrobot.legacy.heating_shaking.bioshake_backend import BioShake +from pylabrobot.legacy.heating_shaking.chatterbox import HeaterShakerChatterboxBackend +from pylabrobot.legacy.heating_shaking.hamilton_backend import ( + HamiltonHeaterShakerBackend, + HamiltonHeaterShakerBox, +) +from pylabrobot.legacy.heating_shaking.heater_shaker import HeaterShaker +from pylabrobot.legacy.heating_shaking.inheco.thermoshake import ( + inheco_thermoshake, + inheco_thermoshake_ac, + inheco_thermoshake_rm, +) +from pylabrobot.legacy.heating_shaking.inheco.thermoshake_backend import InhecoThermoshakeBackend diff --git a/pylabrobot/heating_shaking/backend.py b/pylabrobot/legacy/heating_shaking/backend.py similarity index 61% rename from pylabrobot/heating_shaking/backend.py rename to pylabrobot/legacy/heating_shaking/backend.py index 861af2a1314..3c0ee8c44a8 100644 --- a/pylabrobot/heating_shaking/backend.py +++ b/pylabrobot/legacy/heating_shaking/backend.py @@ -1,5 +1,5 @@ -from pylabrobot.shaking.backend import ShakerBackend -from pylabrobot.temperature_controlling.backend import ( +from pylabrobot.legacy.shaking.backend import ShakerBackend +from pylabrobot.legacy.temperature_controlling.backend import ( TemperatureControllerBackend, ) diff --git a/pylabrobot/heating_shaking/bioshake_backend.py b/pylabrobot/legacy/heating_shaking/bioshake_backend.py similarity index 98% rename from pylabrobot/heating_shaking/bioshake_backend.py rename to pylabrobot/legacy/heating_shaking/bioshake_backend.py index 2816363b5a2..eddd9b96a94 100644 --- a/pylabrobot/heating_shaking/bioshake_backend.py +++ b/pylabrobot/legacy/heating_shaking/bioshake_backend.py @@ -1,9 +1,9 @@ import asyncio import warnings -from pylabrobot.heating_shaking.backend import HeaterShakerBackend from pylabrobot.io.serial import Serial -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.heating_shaking.backend import HeaterShakerBackend +from pylabrobot.legacy.machines.backend import MachineBackend try: import serial diff --git a/pylabrobot/legacy/heating_shaking/chatterbox.py b/pylabrobot/legacy/heating_shaking/chatterbox.py new file mode 100644 index 00000000000..2a7975f9fe0 --- /dev/null +++ b/pylabrobot/legacy/heating_shaking/chatterbox.py @@ -0,0 +1,9 @@ +from pylabrobot.legacy.heating_shaking import HeaterShakerBackend +from pylabrobot.legacy.shaking import ShakerChatterboxBackend +from pylabrobot.legacy.temperature_controlling import TemperatureControllerChatterboxBackend + + +class HeaterShakerChatterboxBackend( + HeaterShakerBackend, ShakerChatterboxBackend, TemperatureControllerChatterboxBackend +): + pass diff --git a/pylabrobot/heating_shaking/hamilton_backend.py b/pylabrobot/legacy/heating_shaking/hamilton_backend.py similarity index 98% rename from pylabrobot/heating_shaking/hamilton_backend.py rename to pylabrobot/legacy/heating_shaking/hamilton_backend.py index f302502115a..fd609cbf607 100644 --- a/pylabrobot/heating_shaking/hamilton_backend.py +++ b/pylabrobot/legacy/heating_shaking/hamilton_backend.py @@ -4,8 +4,8 @@ from enum import Enum from typing import Dict, Literal, Optional -from pylabrobot.heating_shaking.backend import HeaterShakerBackend from pylabrobot.io.usb import USB +from pylabrobot.legacy.heating_shaking.backend import HeaterShakerBackend class PlateLockPosition(Enum): diff --git a/pylabrobot/heating_shaking/heater_shaker.py b/pylabrobot/legacy/heating_shaking/heater_shaker.py similarity index 82% rename from pylabrobot/heating_shaking/heater_shaker.py rename to pylabrobot/legacy/heating_shaking/heater_shaker.py index 39437de1ee7..de35bb94869 100644 --- a/pylabrobot/heating_shaking/heater_shaker.py +++ b/pylabrobot/legacy/heating_shaking/heater_shaker.py @@ -1,9 +1,9 @@ from typing import Optional -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.machines.machine import Machine +from pylabrobot.legacy.shaking import Shaker +from pylabrobot.legacy.temperature_controlling import TemperatureController from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.shaking import Shaker -from pylabrobot.temperature_controlling import TemperatureController from .backend import HeaterShakerBackend diff --git a/pylabrobot/heating_shaking/heater_shaker_tests.py b/pylabrobot/legacy/heating_shaking/heater_shaker_tests.py similarity index 83% rename from pylabrobot/heating_shaking/heater_shaker_tests.py rename to pylabrobot/legacy/heating_shaking/heater_shaker_tests.py index d796e1fc1eb..841ab451802 100644 --- a/pylabrobot/heating_shaking/heater_shaker_tests.py +++ b/pylabrobot/legacy/heating_shaking/heater_shaker_tests.py @@ -1,6 +1,6 @@ import unittest -from pylabrobot.heating_shaking import HeaterShaker, HeaterShakerChatterboxBackend +from pylabrobot.legacy.heating_shaking import HeaterShaker, HeaterShakerChatterboxBackend from pylabrobot.resources.coordinate import Coordinate diff --git a/pylabrobot/heating_shaking/inheco/__init__.py b/pylabrobot/legacy/heating_shaking/inheco/__init__.py similarity index 100% rename from pylabrobot/heating_shaking/inheco/__init__.py rename to pylabrobot/legacy/heating_shaking/inheco/__init__.py diff --git a/pylabrobot/heating_shaking/inheco/thermoshake.py b/pylabrobot/legacy/heating_shaking/inheco/thermoshake.py similarity index 86% rename from pylabrobot/heating_shaking/inheco/thermoshake.py rename to pylabrobot/legacy/heating_shaking/inheco/thermoshake.py index fbafa036d9d..52ffd22519d 100644 --- a/pylabrobot/heating_shaking/inheco/thermoshake.py +++ b/pylabrobot/legacy/heating_shaking/inheco/thermoshake.py @@ -1,7 +1,7 @@ -from pylabrobot.heating_shaking.heater_shaker import HeaterShaker -from pylabrobot.heating_shaking.inheco.thermoshake_backend import InhecoThermoshakeBackend +from pylabrobot.legacy.heating_shaking.heater_shaker import HeaterShaker +from pylabrobot.legacy.heating_shaking.inheco.thermoshake_backend import InhecoThermoshakeBackend +from pylabrobot.legacy.temperature_controlling.inheco.control_box import InhecoTECControlBox from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.temperature_controlling.inheco.control_box import InhecoTECControlBox def inheco_thermoshake_ac(name: str, control_box: InhecoTECControlBox, index: int) -> HeaterShaker: diff --git a/pylabrobot/heating_shaking/inheco/thermoshake_backend.py b/pylabrobot/legacy/heating_shaking/inheco/thermoshake_backend.py similarity index 94% rename from pylabrobot/heating_shaking/inheco/thermoshake_backend.py rename to pylabrobot/legacy/heating_shaking/inheco/thermoshake_backend.py index 76efc556b36..1289260e069 100644 --- a/pylabrobot/heating_shaking/inheco/thermoshake_backend.py +++ b/pylabrobot/legacy/heating_shaking/inheco/thermoshake_backend.py @@ -1,7 +1,7 @@ import warnings -from pylabrobot.heating_shaking.backend import HeaterShakerBackend -from pylabrobot.temperature_controlling.inheco.temperature_controller import ( +from pylabrobot.legacy.heating_shaking.backend import HeaterShakerBackend +from pylabrobot.legacy.temperature_controlling.inheco.temperature_controller import ( InhecoTemperatureControllerBackend, ) diff --git a/pylabrobot/legacy/liquid_handling/__init__.py b/pylabrobot/legacy/liquid_handling/__init__.py new file mode 100644 index 00000000000..62d99cf6d64 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/__init__.py @@ -0,0 +1,14 @@ +from .backends import * +from .liquid_handler import LiquidHandler +from .standard import ( + Drop, + DropTipRack, + MultiHeadAspirationPlate, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + ResourceMove, + SingleChannelAspiration, + SingleChannelDispense, +) +from .strictness import Strictness, get_strictness, set_strictness diff --git a/pylabrobot/legacy/liquid_handling/backends/__init__.py b/pylabrobot/legacy/liquid_handling/backends/__init__.py new file mode 100644 index 00000000000..a039cc1ed4b --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/__init__.py @@ -0,0 +1,10 @@ +from .backend import LiquidHandlerBackend +from .chatterbox import LiquidHandlerChatterboxBackend +from .chatterbox_backend import ChatterBoxBackend +from .hamilton.STAR_backend import STAR, STARBackend +from .hamilton.vantage_backend import Vantage, VantageBackend +from .opentrons_backend import OpentronsOT2Backend +from .opentrons_chatterbox import OpentronsOT2ChatterboxBackend +from .opentrons_simulator import OpentronsOT2Simulator +from .serializing_backend import SerializingBackend +from .tecan.EVO_backend import EVO, EVOBackend diff --git a/pylabrobot/liquid_handling/backends/backend.py b/pylabrobot/legacy/liquid_handling/backends/backend.py similarity index 96% rename from pylabrobot/liquid_handling/backends/backend.py rename to pylabrobot/legacy/liquid_handling/backends/backend.py index a16034577ea..36b63f15641 100644 --- a/pylabrobot/liquid_handling/backends/backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/backend.py @@ -3,8 +3,10 @@ from abc import ABCMeta, abstractmethod from typing import Dict, List, Optional, Union -from pylabrobot.liquid_handling.channel_positioning import GENERIC_LH_MIN_SPACING_BETWEEN_CHANNELS -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.channel_positioning import ( + GENERIC_LH_MIN_SPACING_BETWEEN_CHANNELS, +) +from pylabrobot.legacy.liquid_handling.standard import ( Drop, DropTipRack, MultiHeadAspirationContainer, @@ -19,7 +21,7 @@ SingleChannelAspiration, SingleChannelDispense, ) -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend from pylabrobot.resources import Deck, Tip from pylabrobot.resources.tip_tracker import TipTracker diff --git a/pylabrobot/legacy/liquid_handling/backends/chatterbox.py b/pylabrobot/legacy/liquid_handling/backends/chatterbox.py new file mode 100644 index 00000000000..e147601de21 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/chatterbox.py @@ -0,0 +1,242 @@ +from typing import List, Optional, Union + +from pylabrobot.legacy.liquid_handling.backends.backend import ( + LiquidHandlerBackend, +) +from pylabrobot.legacy.liquid_handling.standard import ( + Drop, + DropTipRack, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, +) +from pylabrobot.resources import Tip + + +class LiquidHandlerChatterboxBackend(LiquidHandlerBackend): + """Chatter box backend for device-free testing. Prints out all operations.""" + + _pip_length = 5 + _vol_length = 8 + _resource_length = 20 + _offset_length = 16 + _flow_rate_length = 10 + _blowout_length = 10 + _lld_z_length = 10 + _kwargs_length = 15 + _tip_type_length = 12 + _max_volume_length = 16 + _fitting_depth_length = 20 + _tip_length_length = 16 + # _pickup_method_length = 20 + _filter_length = 10 + + def __init__(self, num_channels: int = 8): + """Initialize a chatter box backend.""" + super().__init__() + self._num_channels = num_channels + self._num_arms = 1 + self._head96_installed = True + + async def setup(self): + await super().setup() + print("Setting up the liquid handler.") + + async def stop(self): + print("Stopping the liquid handler.") + + def serialize(self) -> dict: + return {**super().serialize(), "num_channels": self.num_channels} + + @property + def num_channels(self) -> int: + return self._num_channels + + async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs): + print("Picking up tips:") + header = ( + f"{'pip#':<{LiquidHandlerChatterboxBackend._pip_length}} " + f"{'resource':<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{'offset':<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{'tip type':<{LiquidHandlerChatterboxBackend._tip_type_length}} " + f"{'max volume (µL)':<{LiquidHandlerChatterboxBackend._max_volume_length}} " + f"{'fitting depth (mm)':<{LiquidHandlerChatterboxBackend._fitting_depth_length}} " + f"{'tip length (mm)':<{LiquidHandlerChatterboxBackend._tip_length_length}} " + # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} " + f"{'filter':<{LiquidHandlerChatterboxBackend._filter_length}}" + ) + print(header) + + for op, channel in zip(ops, use_channels): + offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}" + row = ( + f" p{channel}: " + f"{op.resource.name[-30:]:<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{offset:<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{op.tip.__class__.__name__:<{LiquidHandlerChatterboxBackend._tip_type_length}} " + f"{op.tip.maximal_volume:<{LiquidHandlerChatterboxBackend._max_volume_length}} " + f"{op.tip.fitting_depth:<{LiquidHandlerChatterboxBackend._fitting_depth_length}} " + f"{op.tip.total_tip_length:<{LiquidHandlerChatterboxBackend._tip_length_length}} " + # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} " + f"{'Yes' if op.tip.has_filter else 'No':<{LiquidHandlerChatterboxBackend._filter_length}}" + ) + print(row) + + async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs): + print("Dropping tips:") + header = ( + f"{'pip#':<{LiquidHandlerChatterboxBackend._pip_length}} " + f"{'resource':<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{'offset':<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{'tip type':<{LiquidHandlerChatterboxBackend._tip_type_length}} " + f"{'max volume (µL)':<{LiquidHandlerChatterboxBackend._max_volume_length}} " + f"{'fitting depth (mm)':<{LiquidHandlerChatterboxBackend._fitting_depth_length}} " + f"{'tip length (mm)':<{LiquidHandlerChatterboxBackend._tip_length_length}} " + # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} " + f"{'filter':<{LiquidHandlerChatterboxBackend._filter_length}}" + ) + print(header) + + for op, channel in zip(ops, use_channels): + offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}" + row = ( + f" p{channel}: " + f"{op.resource.name[-30:]:<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{offset:<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{op.tip.__class__.__name__:<{LiquidHandlerChatterboxBackend._tip_type_length}} " + f"{op.tip.maximal_volume:<{LiquidHandlerChatterboxBackend._max_volume_length}} " + f"{op.tip.fitting_depth:<{LiquidHandlerChatterboxBackend._fitting_depth_length}} " + f"{op.tip.total_tip_length:<{LiquidHandlerChatterboxBackend._tip_length_length}} " + # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} " + f"{'Yes' if op.tip.has_filter else 'No':<{LiquidHandlerChatterboxBackend._filter_length}}" + ) + print(row) + + async def aspirate( + self, + ops: List[SingleChannelAspiration], + use_channels: List[int], + **backend_kwargs, + ): + print("Aspirating:") + header = ( + f"{'pip#':<{LiquidHandlerChatterboxBackend._pip_length}} " + f"{'vol(ul)':<{LiquidHandlerChatterboxBackend._vol_length}} " + f"{'resource':<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{'offset':<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{'flow rate':<{LiquidHandlerChatterboxBackend._flow_rate_length}} " + f"{'blowout':<{LiquidHandlerChatterboxBackend._blowout_length}} " + f"{'lld_z':<{LiquidHandlerChatterboxBackend._lld_z_length}} " + ) + for key in backend_kwargs: + header += f"{key:<{LiquidHandlerChatterboxBackend._kwargs_length}} "[-16:] + print(header) + + for o, p in zip(ops, use_channels): + offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}" + row = ( + f" p{p}: " + f"{o.volume:<{LiquidHandlerChatterboxBackend._vol_length}} " + f"{o.resource.name[-20:]:<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{offset:<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{str(o.flow_rate):<{LiquidHandlerChatterboxBackend._flow_rate_length}} " + f"{str(o.blow_out_air_volume):<{LiquidHandlerChatterboxBackend._blowout_length}} " + f"{str(o.liquid_height):<{LiquidHandlerChatterboxBackend._lld_z_length}} " + ) + for key, value in backend_kwargs.items(): + if isinstance(value, list) and all(isinstance(v, bool) for v in value): + value = "".join("T" if v else "F" for v in value) + if isinstance(value, list): + value = "".join(map(str, value)) + row += f" {value:<15}" + print(row) + + async def dispense( + self, + ops: List[SingleChannelDispense], + use_channels: List[int], + **backend_kwargs, + ): + print("Dispensing:") + header = ( + f"{'pip#':<{LiquidHandlerChatterboxBackend._pip_length}} " + f"{'vol(ul)':<{LiquidHandlerChatterboxBackend._vol_length}} " + f"{'resource':<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{'offset':<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{'flow rate':<{LiquidHandlerChatterboxBackend._flow_rate_length}} " + f"{'blowout':<{LiquidHandlerChatterboxBackend._blowout_length}} " + f"{'lld_z':<{LiquidHandlerChatterboxBackend._lld_z_length}} " + ) + for key in backend_kwargs: + header += f"{key:<{LiquidHandlerChatterboxBackend._kwargs_length}} "[-16:] + print(header) + + for o, p in zip(ops, use_channels): + offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}" + row = ( + f" p{p}: " + f"{o.volume:<{LiquidHandlerChatterboxBackend._vol_length}} " + f"{o.resource.name[-20:]:<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{offset:<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{str(o.flow_rate):<{LiquidHandlerChatterboxBackend._flow_rate_length}} " + f"{str(o.blow_out_air_volume):<{LiquidHandlerChatterboxBackend._blowout_length}} " + f"{str(o.liquid_height):<{LiquidHandlerChatterboxBackend._lld_z_length}} " + ) + for key, value in backend_kwargs.items(): + if isinstance(value, list) and all(isinstance(v, bool) for v in value): + value = "".join("T" if v else "F" for v in value) + if isinstance(value, list): + value = "".join(map(str, value)) + row += f" {value:<{LiquidHandlerChatterboxBackend._kwargs_length}}" + print(row) + + async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs): + print(f"Picking up tips from {pickup.resource.name}.") + + async def drop_tips96(self, drop: DropTipRack, **backend_kwargs): + print(f"Dropping tips to {drop.resource.name}.") + + async def aspirate96( + self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] + ): + if isinstance(aspiration, MultiHeadAspirationPlate): + resource = aspiration.wells[0].parent + else: + resource = aspiration.container + print(f"Aspirating {aspiration.volume} from {resource}.") + + async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): + if isinstance(dispense, MultiHeadDispensePlate): + resource = dispense.wells[0].parent + else: + resource = dispense.container + print(f"Dispensing {dispense.volume} to {resource}.") + + async def pick_up_resource(self, pickup: ResourcePickup): + print(f"Picking up resource: {pickup}") + + async def move_picked_up_resource(self, move: ResourceMove): + print(f"Moving picked up resource: {move}") + + async def drop_resource(self, drop: ResourceDrop): + print(f"Dropping resource: {drop}") + + async def request_tip_presence(self) -> List[Optional[bool]]: + """Return tip presence based on the tip tracker state. + + Returns: + A list of length `num_channels` where each element is `True` if a tip is mounted, + `False` if not, or `None` if unknown. + """ + return [self.head[ch].has_tip for ch in range(self.num_channels)] + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + return True diff --git a/pylabrobot/liquid_handling/backends/chatterbox_backend.py b/pylabrobot/legacy/liquid_handling/backends/chatterbox_backend.py similarity index 100% rename from pylabrobot/liquid_handling/backends/chatterbox_backend.py rename to pylabrobot/legacy/liquid_handling/backends/chatterbox_backend.py diff --git a/pylabrobot/liquid_handling/backends/chatterbox_tests.py b/pylabrobot/legacy/liquid_handling/backends/chatterbox_tests.py similarity index 94% rename from pylabrobot/liquid_handling/backends/chatterbox_tests.py rename to pylabrobot/legacy/liquid_handling/backends/chatterbox_tests.py index 025019dfeee..efac36b4f8e 100644 --- a/pylabrobot/liquid_handling/backends/chatterbox_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/chatterbox_tests.py @@ -1,7 +1,7 @@ import unittest -from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.backends.chatterbox import ( +from pylabrobot.legacy.liquid_handling import LiquidHandler +from pylabrobot.legacy.liquid_handling.backends.chatterbox import ( LiquidHandlerChatterboxBackend, ) from pylabrobot.resources import ( diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py new file mode 100644 index 00000000000..f57ec4b3582 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py @@ -0,0 +1,15327 @@ +import asyncio +import datetime +import enum +import functools +import logging +import math +import re +import sys +import warnings +from abc import ABCMeta +from contextlib import asynccontextmanager, contextmanager +from dataclasses import dataclass, field +from typing import ( + Any, + Awaitable, + Callable, + Coroutine, + Dict, + List, + Literal, + Optional, + Sequence, + Tuple, + Type, + TypedDict, + TypeVar, + Union, + cast, +) + +if sys.version_info < (3, 10): + from typing_extensions import Concatenate, ParamSpec +else: + from typing import Concatenate, ParamSpec + +from pylabrobot import audio +from pylabrobot.legacy.arms.standard import CartesianCoords +from pylabrobot.legacy.heating_shaking.hamilton_backend import HamiltonHeaterShakerInterface +from pylabrobot.legacy.liquid_handling.backends.hamilton.base import ( + HamiltonLiquidHandler, +) +from pylabrobot.legacy.liquid_handling.backends.hamilton.common import fill_in_defaults +from pylabrobot.legacy.liquid_handling.channel_positioning import ( + get_tight_single_resource_liquid_op_offsets, + get_wide_single_resource_liquid_op_offsets, +) +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton import ( + HamiltonLiquidClass, + get_star_liquid_class, +) +from pylabrobot.legacy.liquid_handling.pipette_batch_scheduling import ( + ChannelBatch, + log_batches, + plan_batches, + validate_channel_selections, +) +from pylabrobot.legacy.liquid_handling.standard import ( + Drop, + DropTipRack, + GripDirection, + Mix, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + PipettingOp, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, +) +from pylabrobot.resources import ( + Carrier, + Container, + Coordinate, + Plate, + Resource, + Tip, + TipRack, + TipSpot, + Well, +) +from pylabrobot.resources.barcode import Barcode, Barcode1DSymbology +from pylabrobot.resources.errors import ( + HasTipError, + NoTipError, + TooLittleLiquidError, + TooLittleVolumeError, +) +from pylabrobot.resources.hamilton import ( + HamiltonTip, + TipDropMethod, + TipPickupMethod, + TipSize, +) +from pylabrobot.resources.hamilton.hamilton_decks import ( + HamiltonCoreGrippers, + rails_for_x_coordinate, +) +from pylabrobot.resources.liquid import Liquid +from pylabrobot.resources.rotation import Rotation +from pylabrobot.resources.tip_tracker import does_tip_tracking +from pylabrobot.resources.trash import Trash + +T = TypeVar("T") + + +logger = logging.getLogger("pylabrobot") + +_P = ParamSpec("_P") +_R = TypeVar("_R") + + +def need_iswap_parked( + method: Callable[Concatenate["STARBackend", _P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate["STARBackend", _P], Coroutine[Any, Any, _R]]: + """Ensure that the iSWAP is in parked position before running command. + + If the iSWAP is not parked, it get's parked before running the command. + """ + + @functools.wraps(method) + async def wrapper(self: "STARBackend", *args, **kwargs): + if self.extended_conf.left_x_drive.iswap_installed and not self.iswap_parked: + await self.park_iswap( + minimum_traverse_height_at_beginning_of_a_command=int(self._iswap_traversal_height * 10) + ) + + return await method(self, *args, **kwargs) + + return wrapper + + +def _requires_head96( + method: Callable[Concatenate["STARBackend", _P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate["STARBackend", _P], Coroutine[Any, Any, _R]]: + """Ensure that a 96-head is installed before running the command.""" + + @functools.wraps(method) + async def wrapper(self: "STARBackend", *args, **kwargs): + if not self.extended_conf.left_x_drive.core_96_head_installed: + raise RuntimeError( + "This command requires a 96-head, but none is installed. " + "Check your instrument configuration." + ) + return await method(self, *args, **kwargs) + + return wrapper + + +def parse_star_fw_string(resp: str, fmt: str = "") -> dict: + """Parse a machine command or response string according to a format string. + + The format contains names of parameters (always length 2), + followed by an arbitrary number of the following, but always + the same: + - '&': char + - '#': decimal + - '*': hex + + The order of parameters in the format and response string do not + have to (and often do not) match. + + The identifier parameter (id####) is added automatically. + + TODO: string parsing + The firmware docs mention strings in the following format: '...' + However, the length of these is always known (except when reading + barcodes), so it is easier to convert strings to the right number + of '&'. With barcode reading the length of the barcode is included + with the response string. We'll probably do a custom implementation + for that. + + TODO: spaces + We should also parse responses where integers are separated by spaces, + like this: `ua#### #### ###### ###### ###### ######` + + Args: + resp: The response string to parse. + fmt: The format string. + + Raises: + ValueError: if the format string is incompatible with the response. + + Returns: + A dictionary containing the parsed values. + + Examples: + Parsing a string containing decimals (`1111`), hex (`0xB0B`) and chars (`'rw'`): + + ``` + >>> parse_fw_string("aa1111bbrwccB0B", "aa####bb&&cc***") + {'aa': 1111, 'bb': 'rw', 'cc': 2827} + ``` + """ + + # Remove device and cmd identifier from response. + resp = resp[4:] + + # Parse the parameters in the fmt string. + info = {} + + def find_param(param): + name, data = param[0:2], param[2:] + type_ = {"#": "int", "*": "hex", "&": "str"}[data[0]] + + # Build a regex to match this parameter. + exp = { + "int": r"[-+]?[\d ]", + "hex": r"[\da-fA-F ]", + "str": ".", + }[type_] + len_ = len(data.split(" ")[0]) # Get length of first block. + regex = f"{name}((?:{exp}{ {len_} }" + + if param.endswith(" (n)"): + regex += " ?)+)" + is_list = True + else: + regex += "))" + is_list = False + + # Match response against regex, save results in right datatype. + r = re.search(regex, resp) + if r is None: + raise ValueError(f"could not find matches for parameter {name}") + + g = r.groups() + if len(g) == 0: + raise ValueError(f"could not find value for parameter {name}") + m = g[0] + + if is_list: + m = m.split(" ") + + if type_ == "str": + info[name] = m + elif type_ == "int": + info[name] = [int(m_) for m_ in m if m_ != ""] + elif type_ == "hex": + info[name] = [int(m_, base=16) for m_ in m if m_ != ""] + else: + if type_ == "str": + info[name] = m + elif type_ == "int": + info[name] = int(m) + elif type_ == "hex": + info[name] = int(m, base=16) + + # Find params in string. All params are identified by 2 lowercase chars. + param = "" + prevchar = None + for char in fmt: + if char.islower() and prevchar != "(": + if len(param) > 2: + find_param(param) + param = "" + param += char + prevchar = char + if param != "": + find_param(param) # last parameter is not closed by loop. + + # If id not in fmt, add it. + if "id" not in info: + find_param("id####") + + return info + + +class STARModuleError(Exception, metaclass=ABCMeta): + """Base class for all Hamilton backend errors, raised by a single module.""" + + def __init__( + self, + message: str, + trace_information: int, + raw_response: str, + raw_module: str, + ): + self.message = message + self.trace_information = trace_information + self.raw_response = raw_response + self.raw_module = raw_module + + def __repr__(self) -> str: + return f"{self.__class__.__name__}('{self.message}')" + + +class CommandSyntaxError(STARModuleError): + """Command syntax error + + Code: 01 + """ + + +class HardwareError(STARModuleError): + """Hardware error + + Possible cause(s): + drive blocked, low power etc. + + Code: 02 + """ + + +class CommandNotCompletedError(STARModuleError): + """Command not completed + + Possible cause(s): + error in previous sequence (not executed) + + Code: 03 + """ + + +class ClotDetectedError(STARModuleError): + """Clot detected + + Possible cause(s): + LLD not interrupted + + Code: 04 + """ + + +class BarcodeUnreadableError(STARModuleError): + """Barcode unreadable + + Possible cause(s): + bad or missing barcode + + Code: 05 + """ + + +class TipTooLittleVolumeError(STARModuleError): + """Too little liquid + + Possible cause(s): + 1. liquid surface is not detected, + 2. Aspirate / Dispense conditions could not be fulfilled. + + Code: 06 + """ + + +class TipAlreadyFittedError(STARModuleError): + """Tip already fitted + + Possible cause(s): + Repeated attempts to fit a tip or iSwap movement with tips + + Code: 07 + """ + + +class HamiltonNoTipError(STARModuleError): + """No tips + + Possible cause(s): + command was started without fitting tip (tip was not fitted or fell off again) + + Code: 08 + """ + + +class NoCarrierError(STARModuleError): + """No carrier + + Possible cause(s): + load command without carrier + + Code: 09 + """ + + +class NotCompletedError(STARModuleError): + """Not completed + + Possible cause(s): + Command in command buffer was aborted due to an error in a previous command, or command stack + was deleted. + + Code: 10 + """ + + +class DispenseWithPressureLLDError(STARModuleError): + """Dispense with pressure LLD + + Possible cause(s): + dispense with pressure LLD is not permitted + + Code: 11 + """ + + +class NoTeachInSignalError(STARModuleError): + """No Teach In Signal + + Possible cause(s): + X-Movement to LLD reached maximum allowable position with- out detecting Teach in signal + + Code: 12 + """ + + +class LoadingTrayError(STARModuleError): + """Loading Tray error + + Possible cause(s): + position already occupied + + Code: 13 + """ + + +class SequencedAspirationWithPressureLLDError(STARModuleError): + """Sequenced aspiration with pressure LLD + + Possible cause(s): + sequenced aspiration with pressure LLD is not permitted + + Code: 14 + """ + + +class NotAllowedParameterCombinationError(STARModuleError): + """Not allowed parameter combination + + Possible cause(s): + i.e. PLLD and dispense or wrong X-drive assignment + + Code: 15 + """ + + +class CoverCloseError(STARModuleError): + """Cover close error + + Possible cause(s): + cover is not closed and couldn't be locked + + Code: 16 + """ + + +class AspirationError(STARModuleError): + """Aspiration error + + Possible cause(s): + aspiration liquid stream error detected + + Code: 17 + """ + + +class WashFluidOrWasteError(STARModuleError): + """Wash fluid or trash error + + Possible cause(s): + 1. missing wash fluid + 2. trash of particular washer is full + + Code: 18 + """ + + +class IncubationError(STARModuleError): + """Incubation error + + Possible cause(s): + incubator temperature out of limit + + Code: 19 + """ + + +class TADMMeasurementError(STARModuleError): + """TADM measurement error + + Possible cause(s): + overshoot of limits during aspiration or dispensation + + Code: 20, 26 + """ + + +class NoElementError(STARModuleError): + """No element + + Possible cause(s): + expected element not detected + + Code: 21 + """ + + +class ElementStillHoldingError(STARModuleError): + """Element still holding + + Possible cause(s): + "Get command" is sent twice or element is not dropped expected element is missing (lost) + + Code: 22 + """ + + +class ElementLostError(STARModuleError): + """Element lost + + Possible cause(s): + expected element is missing (lost) + + Code: 23 + """ + + +class IllegalTargetPlatePositionError(STARModuleError): + """Illegal target plate position + + Possible cause(s): + 1. over or underflow of iSWAP positions + 2. iSWAP is not in park position during pipetting activities + + Code: 24 + """ + + +class IllegalUserAccessError(STARModuleError): + """Illegal user access + + Possible cause(s): + carrier was manually removed or cover is open (immediate stop is executed) + + Code: 25 + """ + + +class PositionNotReachableError(STARModuleError): + """Position not reachable + + Possible cause(s): + position out of mechanical limits using iSWAP, CoRe gripper or PIP-channels + + Code: 27 + """ + + +class UnexpectedLLDError(STARModuleError): + """unexpected LLD + + Possible cause(s): + liquid level is reached before LLD scanning is started (using PIP or XL channels) + + Code: 28 + """ + + +class AreaAlreadyOccupiedError(STARModuleError): + """area already occupied + + Possible cause(s): + Its impossible to occupy area because this area is already in use + + Code: 29 + """ + + +class ImpossibleToOccupyAreaError(STARModuleError): + """impossible to occupy area + + Possible cause(s): + Area cant be occupied because is no solution for arm prepositioning + + Code: 30 + """ + + +class AntiDropControlError(STARModuleError): + """ + Anti drop controlling out of tolerance. (VENUS only) + + Code: 31 + """ + + +class DecapperError(STARModuleError): + """ + Decapper lock error while screw / unscrew a cap by twister channels. (VENUS only) + + Code: 32 + """ + + +class DecapperHandlingError(STARModuleError): + """ + Decapper station error while lock / unlock a cap. (VENUS only) + + Code: 33 + """ + + +class StopError(STARModuleError): + """ + Hood is open (Not from documentation, but observed) + + Code: 36 + """ + + +class SlaveError(STARModuleError): + """Slave error + + Possible cause(s): + This error code indicates an error in one of slaves. (for error handling purpose using service + software macro code) + + Code: 99 + """ + + +class WrongCarrierError(STARModuleError): + """ + Wrong carrier barcode detected. (VENUS only) + + Code: 100 + """ + + +class NoCarrierBarcodeError(STARModuleError): + """ + Carrier barcode could not be read or is missing. (VENUS only) + + Code: 101 + """ + + +class LiquidLevelError(STARModuleError): + """ + Liquid surface not detected. (VENUS only) + + This error is created from main / slave error 06/70, 06/73 and 06/87. + + Code: 102 + """ + + +class NotDetectedError(STARModuleError): + """ + Carrier not detected at deck end position. (VENUS only) + + Code: 103 + """ + + +class NotAspiratedError(STARModuleError): + """ + Dispense volume exceeds the aspirated volume. (VENUS only) + + This error is created from main / slave error 02/54. + + Code: 104 + """ + + +class ImproperDispensationError(STARModuleError): + """ + The dispensed volume is out of tolerance (may only occur for Nano Pipettor Dispense steps). + (VENUS only) + + This error is created from main / slave error 02/52 and 02/54. + + Code: 105 + """ + + +class NoLabwareError(STARModuleError): + """ + The labware to be loaded was not detected by autoload module. (VENUS only) + + Note: + + May only occur on a Reload Carrier step if the labware property 'MlStarCarPosAreRecognizable' is + set to 1. + + Code: 106 + """ + + +class UnexpectedLabwareError(STARModuleError): + """ + The labware contains unexpected barcode ( may only occur on a Reload Carrier step ). (VENUS only) + + Code: 107 + """ + + +class WrongLabwareError(STARModuleError): + """ + The labware to be reloaded contains wrong barcode ( may only occur on a Reload Carrier step ). + (VENUS only) + + Code: 108 + """ + + +class BarcodeMaskError(STARModuleError): + """ + The barcode read doesn't match the barcode mask defined. (VENUS only) + + Code: 109 + """ + + +class BarcodeNotUniqueError(STARModuleError): + """ + The barcode read is not unique. Previously loaded labware with same barcode was loaded without + unique barcode check. (VENUS only) + + Code: 110 + """ + + +class BarcodeAlreadyUsedError(STARModuleError): + """ + The barcode read is already loaded as unique barcode ( it's not possible to load the same barcode + twice ). (VENUS only) + + Code: 111 + """ + + +class KitLotExpiredError(STARModuleError): + """ + Kit Lot expired. (VENUS only) + + Code: 112 + """ + + +class DelimiterError(STARModuleError): + """ + Barcode contains character which is used as delimiter in result string. (VENUS only) + + Code: 113 + """ + + +class UnknownHamiltonError(STARModuleError): + """Unknown error""" + + +def _module_id_to_module_name(id_): + """Convert a module ID to a module name.""" + return { + "C0": "Master", + "X0": "X-drives", + "I0": "Auto Load", + "W1": "Wash station 1-3", + "W2": "Wash station 4-6", + "T1": "Temperature carrier 1", + "T2": "Temperature carrier 2", + "R0": "ISWAP", + "P1": "Pipetting channel 1", + "P2": "Pipetting channel 2", + "P3": "Pipetting channel 3", + "P4": "Pipetting channel 4", + "P5": "Pipetting channel 5", + "P6": "Pipetting channel 6", + "P7": "Pipetting channel 7", + "P8": "Pipetting channel 8", + "P9": "Pipetting channel 9", + "PA": "Pipetting channel 10", + "PB": "Pipetting channel 11", + "PC": "Pipetting channel 12", + "PD": "Pipetting channel 13", + "PE": "Pipetting channel 14", + "PF": "Pipetting channel 15", + "PG": "Pipetting channel 16", + "H0": "CoRe 96 Head", + "HW": "Pump station 1 station", + "HU": "Pump station 2 station", + "HV": "Pump station 3 station", + "N0": "Nano dispenser", + "D0": "384 dispensing head", + "NP": "Nano disp. pressure controller", + "M1": "Reserved for module 1", + }.get(id_, "Unknown Module") + + +def error_code_to_exception(code: int) -> Type[STARModuleError]: + """Convert an error code to an exception.""" + codes = { + 1: CommandSyntaxError, + 2: HardwareError, + 3: CommandNotCompletedError, + 4: ClotDetectedError, + 5: BarcodeUnreadableError, + 6: TipTooLittleVolumeError, + 7: TipAlreadyFittedError, + 8: HamiltonNoTipError, + 9: NoCarrierError, + 10: NotCompletedError, + 11: DispenseWithPressureLLDError, + 12: NoTeachInSignalError, + 13: LoadingTrayError, + 14: SequencedAspirationWithPressureLLDError, + 15: NotAllowedParameterCombinationError, + 16: CoverCloseError, + 17: AspirationError, + 18: WashFluidOrWasteError, + 19: IncubationError, + 20: TADMMeasurementError, + 21: NoElementError, + 22: ElementStillHoldingError, + 23: ElementLostError, + 24: IllegalTargetPlatePositionError, + 25: IllegalUserAccessError, + 26: TADMMeasurementError, + 27: PositionNotReachableError, + 28: UnexpectedLLDError, + 29: AreaAlreadyOccupiedError, + 30: ImpossibleToOccupyAreaError, + 31: AntiDropControlError, + 32: DecapperError, + 33: DecapperHandlingError, + 99: SlaveError, + 100: WrongCarrierError, + 101: NoCarrierBarcodeError, + 102: LiquidLevelError, + 103: NotDetectedError, + 104: NotAspiratedError, + 105: ImproperDispensationError, + 106: NoLabwareError, + 107: UnexpectedLabwareError, + 108: WrongLabwareError, + 109: BarcodeMaskError, + 110: BarcodeNotUniqueError, + 111: BarcodeAlreadyUsedError, + 112: KitLotExpiredError, + 113: DelimiterError, + } + if code in codes: + return codes[code] + return UnknownHamiltonError + + +def trace_information_to_string(module_identifier: str, trace_information: int) -> str: + """Convert a trace identifier to an error message.""" + table = None + + if module_identifier == "C0": # master + table = { + 10: "CAN error", + 11: "Slave command time out", + 20: "E2PROM error", + 30: "Unknown command", + 31: "Unknown parameter", + 32: "Parameter out of range", + 33: "Parameter does not belong to command, or not all parameters were sent", + 34: "Node name unknown", + 35: "id parameter error", + 37: "node name defined twice", + 38: "faulty XL channel settings", + 39: "faulty robotic channel settings", + 40: "PIP task busy", + 41: "Auto load task busy", + 42: "Miscellaneous task busy", + 43: "Incubator task busy", + 44: "Washer task busy", + 45: "iSWAP task busy", + 46: "CoRe 96 head task busy", + 47: "Carrier sensor doesn't work properly", + 48: "CoRe 384 head task busy", + 49: "Nano pipettor task busy", + 50: "XL channel task busy", + 51: "Tube gripper task busy", + 52: "Imaging channel task busy", + 53: "Robotic channel task busy", + } + elif module_identifier == "I0": # autoload + table = { + 0: "No error", + 20: "No communication to EEPROM", + 30: "Unknown command", + 31: "Unknown parameter", + 32: "Parameter out of range", + 35: "Voltages outside permitted range", + # generic firmware meaning of 36 is "Stop during execution of command" + 36: "Hamilton will not run while the hood is open", + 40: "No parallel processes permitted", + 50: "Scanner X-drive: init position not found", + 51: "Scanner X-drive: stepper motor not initialized", + 52: "Scanner X-drive: movement error (step loss)", + 55: "Scanner rotation drive: drive blocked", + 60: "Carrier Y-drive: init position not found", + 61: "Carrier Y-drive: stepper motor not initialized", + 62: "Carrier Y-drive: movement error (step loss)", + 65: "Carrier Z-drive: init position not found", + 66: "Carrier Z-drive: stepper motor not initialized", + 67: "Carrier Z-drive: movement error (step loss)", + 70: "Barcode scanner: communication error", + 75: "Loading indicator (LED): communication error", + 80: "Identification barcode not readable", + 81: "No carrier present", + 82: "No carrier loaded", + 83: "Loading tray is occupied", + 84: "Data for free definable carrier not correct", + } + elif module_identifier in [ + "PX", + "P1", + "P2", + "P3", + "P4", + "P5", + "P6", + "P7", + "P8", + "P9", + "PA", + "PB", + "PC", + "PD", + "PE", + "PF", + "PG", + ]: + table = { + 0: "No error", + 20: "No communication to EEPROM", + 30: "Unknown command", + 31: "Unknown parameter", + 32: "Parameter out of range", + 35: "Voltages outside permitted range", + 36: "Stop during execution of command", + 37: "Stop during execution of command", + 40: "No parallel processes permitted (Two or more commands sent for the same controlprocess)", + 50: "Dispensing drive init. position not found", + 51: "Dispensing drive not initialized", + 52: "Dispensing drive movement error", + 53: "Maximum volume in tip reached", + 54: "Position outside of permitted area", + 55: "Y-drive blocked", + 56: "Y-drive not initialized", + 57: "Y-drive movement error", + 60: "Z-drive blocked", + 61: "Z-drive not initialized", + 62: "Z-drive movement error", + 63: "Z-drive limit stop not found", + 65: "Squeezer drive blocked. Can you manually unblock the squeezer drive by turning its screw?", + 66: "Squeezer drive not initialized", + 67: "Squeezer drive movement error: Step loss", + 68: "Init position adjustment error", + 70: "No liquid level found (possibly because no liquid was present, or too little liquid was present to trigger cLLD)", + 71: "Not enough liquid present (Immersion depth or surface following position possibly" + "below minimal access range)", + 72: "Auto calibration at pressure (Sensor not possible)", + 73: "No liquid level found with dual LLD", + 74: "Liquid at a not allowed position detected", + 75: "No tip picked up, possibly because no was present at specified position", + 76: "Tip already picked up", + 77: "Tip not dropped", + 78: "Wrong tip picked up", + 80: "Liquid not correctly aspirated", + 81: "Clot detected", + 82: "TADM measurement out of lower limit curve", + 83: "TADM measurement out of upper limit curve", + 84: "Not enough memory for TADM measurement", + 85: "No communication to digital potentiometer", + 86: "ADC algorithm error", + 87: "2nd phase of liquid nt found", + 88: "Not enough liquid present (Immersion depth or surface following position possibly" + "below minimal access range)", + 90: "Limit curve not resettable", + 91: "Limit curve not programmable", + 92: "Limit curve not found", + 93: "Limit curve data incorrect", + 94: "Not enough memory for limit curve", + 95: "Invalid limit curve index", + 96: "Limit curve already stored", + } + elif module_identifier == "H0": # Core 96 head + table = { + 0: "No error", + 20: "No communication to EEPROM", + # 21 is the current-firmware transfer-check error; older firmware reports 20 + 21: "No communication to digital potentiometer", + 25: "Flash EPROM data incorrect", + 26: "Flash EPROM cannot be programmed", + 27: "Flash EPROM cannot be erased", + 28: "Flash EPROM checksum error", + 30: "Unknown command", + 31: "Unknown parameter", + 32: "Parameter out of range", + 35: "Voltage outside permitted range", + 36: "Stop during execution of command", + 37: "The adjustment sensor did not switch", + 40: "No parallel processes permitted", + 50: "Dispensing drive initialization failed", + 51: "Dispensing drive not initialized", + 52: "Dispensing drive movement error", + 53: "Maximum volume in tip reached", + 54: "Position out of permitted area", + 55: "Y drive initialization failed", + 56: "Y drive not initialized", + 57: "Y drive movement error", + 58: "Y drive position outside of permitted area", + 60: "Z drive initialization failed", + 61: "Z drive not initialized", + 62: "Z drive movement error", + 63: "Z drive position outside of permitted area", + 65: "Squeezer drive initialization failed", + 66: "Squeezer drive not initialized", + 67: "Squeezer drive movement error: drive blocked or incremental sensor fault", + 68: "Squeezer drive position outside of permitted area", + 70: "No liquid level found", + 71: "Not enough liquid present", + 75: "No tip picked up", + 76: "Tip already picked up", + 81: "Clot detected", + 82: "TADM measurement out of lower limit curve", + 83: "TADM measurement out of upper limit curve", + 84: "Not enough memory for TADM measurement", + 90: "Limit curve not resettable", + 91: "Limit curve not programmable", + 92: "Limit curve not found", + 93: "Limit curve data incorrect", + 94: "Not enough memory for limit curve", + 95: "Invalid limit curve index", + 96: "Limit curve already stored", + } + elif module_identifier == "R0": # iswap + # These messages are iSWAP-specific. The internal plate gripper (IPG) also + # reports as module R0 but numbers its drives differently, so for an IPG the + # codes from 55 up map to different drives than the ones listed here. + table = { + 20: "No communication to EEPROM", + 30: "Unknown command", + 31: "Unknown parameter", + 32: "Parameter out of range", + 33: "FW doesn't match to HW", + 36: "Stop during execution of command", + 37: "The adjustment sensor did not switch", + 38: "The adjustment sensor cannot be searched", + 40: "No parallel processes permitted", + 41: "No parallel processes permitted", + 42: "No parallel processes permitted", + 50: "Y-drive Initialization failed", + 51: "Y-drive not initialized", + 52: "Y-drive movement error: drive locked or incremental sensor fault", + 53: "Y-drive movement error: position counter over/underflow", + 60: "Z-drive initialization failed", + 61: "Z-drive not initialized", + 62: "Z-drive movement error: drive locked or incremental sensor fault", + 63: "Z-drive movement error: position counter over/underflow", + 70: "Rotation-drive initialization failed", + 71: "Rotation-drive not initialized", + 72: "Rotation-drive movement error: drive locked or incremental sensor fault", + 73: "Rotation-drive movement error: position counter over/underflow", + 80: "Wrist twist drive initialization failed", + 81: "Wrist twist drive not initialized", + 82: "Wrist twist drive movement error: drive locked or incremental sensor fault", + 83: "Wrist twist drive movement error: position counter over/underflow", + 85: "Gripper drive: communication error to gripper DMS digital potentiometer", + 86: "Gripper drive: Auto adjustment of DMS digital potentiometer not possible", + 89: "Gripper drive movement error: drive locked or incremental sensor fault during gripping", + 90: "Gripper drive initialized failed", + 91: "iSWAP not initialized. Call STARBackend.initialize_iswap().", + 92: "Gripper drive movement error: drive locked or incremental sensor fault during release", + 93: "Gripper drive movement error: position counter over/underflow", + 94: "Plate not found", + 96: "Plate not available", + 97: "Unexpected object found", + } + elif module_identifier == "X0": # X-drives + table = { + 0: "No error", + 20: "Transmission error (I2C bus or EEPROM)", + 25: "Flash EPROM data incorrect", + 26: "Flash EPROM cannot be programmed", + 27: "Flash EPROM cannot be erased", + 28: "Flash EPROM checksum error", + 30: "Unknown command", + 31: "Unknown parameter", + 32: "Parameter out of range", + 35: "Voltages outside permitted range", + # older firmware reports 36 as an emergency-stop / cover-open event + 36: "Stop during execution of command", + 40: "No parallel processes permitted (X drive 1)", + 41: "No parallel processes permitted (X drive 2)", + 42: "No parallel processes permitted (reserve drive)", + 50: "X drive 1: initialization failed", + 51: "X drive 1: drive not initialized", + 52: "X drive 1: movement error (drive blocked or lag too high)", + 53: "X drive 1: position error (drive displaced)", + 54: "X drive 1: dispense-on-fly error", + 55: "X drive 1: positioning-to-dispense-on-fly error", + 70: "X drive 2: initialization failed", + 71: "X drive 2: drive not initialized", + 72: "X drive 2: movement error (drive blocked or lag too high)", + 73: "X drive 2: position error (drive displaced)", + 74: "X drive 2: dispense-on-fly error", + 75: "X drive 2: positioning-to-dispense-on-fly error", + 80: "Reserve drive: initialization failed", + 81: "Reserve drive: drive not initialized", + 82: "Reserve drive: movement error (drive blocked or lag too high)", + } + + if table is not None and trace_information in table: + return table[trace_information] + + return f"Unknown trace information code {trace_information:02}" + + +class STARFirmwareError(Exception): + def __init__(self, errors: Dict[str, STARModuleError], raw_response: str): + self.errors = errors + self.raw_response = raw_response + super().__init__(f"{errors}, {raw_response}") + + +def star_firmware_string_to_error( + error_code_dict: Dict[str, str], + raw_response: str, +) -> STARFirmwareError: + """Convert a firmware string to a STARFirmwareError.""" + + errors = {} + + for module_id, error in error_code_dict.items(): + module_name = _module_id_to_module_name(module_id) + if "/" in error: + # C0 module: error code / trace information + error_code_str, trace_information_str = error.split("/") + error_code, trace_information = ( + int(error_code_str), + int(trace_information_str), + ) + if error_code == 0 and trace_information == 0: + continue + error_class = error_code_to_exception(error_code) + elif module_id == "I0" and error == "36": + error_class = StopError + trace_information = int(error) + else: + # Slave modules: er## (just trace information) + error_class = UnknownHamiltonError + trace_information = int(error) + error_description = trace_information_to_string( + module_identifier=module_id, trace_information=trace_information + ) + errors[module_name] = error_class( + message=error_description, + trace_information=trace_information, + raw_response=error, + raw_module=module_id, + ) + + # If the master error is a SlaveError, remove it from the errors dict. + if isinstance(errors.get("Master"), SlaveError): + errors.pop("Master") + + return STARFirmwareError(errors=errors, raw_response=raw_response) + + +def convert_star_module_error_to_plr_error( + error: STARModuleError, +) -> Optional[Exception]: + """Convert an error returned by a specific STAR module to a Hamilton error.""" + # TipAlreadyFittedError -> HasTipError + if isinstance(error, TipAlreadyFittedError): + return HasTipError() + + # HamiltonNoTipError -> NoTipError + if isinstance(error, HamiltonNoTipError): + return NoTipError(error.message) + + if error.trace_information == 75: + return NoTipError(error.message) + + if error.trace_information in {70, 71}: + return TooLittleLiquidError(error.message) + + if error.trace_information in {54}: + return TooLittleVolumeError(error.message) + + return None + + +def convert_star_firmware_error_to_plr_error( + error: STARFirmwareError, +) -> Optional[Exception]: + """Check if a STARFirmwareError can be converted to a native PLR error. If so, return it, else + return `None`.""" + + # if all errors are channel errors, return a ChannelizedError + if all(e.startswith("Pipetting channel ") for e in error.errors): + + def _channel_to_int(channel: str) -> int: + return int(channel.split(" ")[-1]) - 1 # star is 1-indexed, plr is 0-indexed + + errors = { + _channel_to_int(module_name): convert_star_module_error_to_plr_error(error) or error + for module_name, error in error.errors.items() + } + return ChannelizedError(errors=errors, raw_response=error.raw_response) + + return None + + +def _dispensing_mode_for_op(empty: bool, jet: bool, blow_out: bool) -> int: + """from docs: + 0 = Partial volume in jet mode + 1 = Blow out in jet mode, called "empty" in the VENUS liquid editor + 2 = Partial volume at surface + 3 = Blow out at surface, called "empty" in the VENUS liquid editor + 4 = Empty tip at fix position + """ + + if empty: + return 4 + if jet: + return 1 if blow_out else 0 + else: + return 3 if blow_out else 2 + + +@dataclass +class DriveConfiguration: + """Configuration and geometry for an X drive (left or right). + + The installed-module bits combine byte 1 (xl/xr) and byte 2 (xn/xo). The arm + geometry - width, travel range, workspace range - comes from the X-drive range (RU) + and working-envelope (UA) queries, so it is None on a drive built from the module + bits alone (e.g. a simulated configuration) and populated when + `request_extended_configuration` builds the drive. `model` and `reference_point` + follow from `width`. + + Note: the installed modules on left and right drives must be different. + """ + + pip_installed: bool = False + iswap_installed: bool = False + core_96_head_installed: bool = False + nano_pipettor_installed: bool = False + dispensing_head_384_installed: bool = False + xl_channels_installed: bool = False + tube_gripper_installed: bool = False + imaging_channel_installed: bool = False + robotic_channel_installed: bool = False + + width: Optional[float] = None + """Arm width (mm), from the machine configuration.""" + x_range: Optional[Tuple[float, float]] = None + """Drive travel `(min, max)` in mm.""" + workspace_range: Optional[Tuple[float, float]] = None + """Reachable X workspace `(min, max)` in mm.""" + + @property + def model(self) -> str: + """Arm variant derived from `width`: wide arms span both rails, narrow arms one.""" + assert self.width is not None, "arm geometry not resolved" + if self.width > 300: + return "hamilton_legacy_star_dual_rail_arm" + return "hamilton_legacy_star_single_right_rail_arm" + + @property + def reference_point(self) -> Literal["center", "right"]: + """Where along the arm's width the tracked X refers to: the arm center for a + dual-rail arm, the right edge for a single-rail arm.""" + assert self.width is not None, "arm geometry not resolved" + return "center" if self.width > 300 else "right" + + +@dataclass +class MachineConfiguration: + """Response from RM (Request Machine Configuration) command [SFCO.0035].""" + + # kb byte (configuration data 1) + pip_type_1000ul: bool = False + """Bit 0: PIP Type. False = 300ul, True = 1000ul.""" + kb_iswap_installed: bool = False + """Bit 1: ISWAP. False = none, True = installed.""" + main_front_cover_monitoring_installed: bool = False + """Bit 2: Main front cover monitoring. False = none, True = installed.""" + auto_load_installed: bool = False + """Bit 3: Auto load. False = none, True = installed.""" + wash_station_1_installed: bool = False + """Bit 4: Wash station 1. False = none, True = installed.""" + wash_station_2_installed: bool = False + """Bit 5: Wash station 2. False = none, True = installed.""" + temp_controlled_carrier_1_installed: bool = False + """Bit 6: Temperature controlled carrier 1. False = none, True = installed.""" + temp_controlled_carrier_2_installed: bool = False + """Bit 7: Temperature controlled carrier 2. False = none, True = installed.""" + + num_pip_channels: int = 0 + """Number of PIP channels (kp). Range: 0..16.""" + + +@dataclass +class ExtendedConfiguration: + """Response from QM (Request Extended Configuration) command. + + This command returns the full instrument configuration matching the AK + (Set Instrument Configuration) [SFCO.0026] parameter set. + """ + + # ka (configuration data 2, 24-bit) + left_x_drive_large: bool = False + """Bit 0: Left X drive. False = small, True = large.""" + ka_core_96_head_installed: bool = False + """Bit 1: CoRe 96 Head. False = none, True = installed.""" + right_x_drive_large: bool = False + """Bit 2: Right X drive. False = small, True = large.""" + pump_station_1_installed: bool = False + """Bit 3: Pump station 1. False = none, True = installed.""" + pump_station_2_installed: bool = False + """Bit 4: Pump station 2. False = none, True = installed.""" + wash_station_1_type_cr: bool = False + """Bit 5: Type wash station 1. False = G3, True = CR.""" + wash_station_2_type_cr: bool = False + """Bit 6: Type wash station 2. False = G3, True = CR.""" + left_cover_installed: bool = False + """Bit 7: Left cover. False = none, True = installed.""" + right_cover_installed: bool = False + """Bit 8: Right cover. False = none, True = installed.""" + additional_front_cover_monitoring_installed: bool = False + """Bit 9: Additional front cover monitoring. False = none, True = installed.""" + pump_station_3_installed: bool = False + """Bit 10: Pump station 3. False = none, True = installed.""" + multi_channel_nano_pipettor_installed: bool = False + """Bit 11: Multi channel nano pipettor. False = none, True = installed.""" + dispensing_head_384_installed: bool = False + """Bit 12: 384 dispensing head. False = none, True = installed.""" + xl_channels_installed: bool = False + """Bit 13: XL channels. False = none, True = installed.""" + tube_gripper_installed: bool = False + """Bit 14: Tube gripper. False = none, True = installed.""" + waste_direction_left: bool = False + """Bit 15: Waste direction. False = right, True = left.""" + iswap_gripper_wide: bool = False + """Bit 16: iSWAP gripper size. False = small, True = wide.""" + additional_channel_nano_pipettor_installed: bool = False + """Bit 17: Additional channel nano pipettor. False = none, True = installed.""" + imaging_channel_installed: bool = False + """Bit 18: Imaging channel. False = none, True = installed.""" + robotic_channel_installed: bool = False + """Bit 19: Robotic channel. False = none, True = installed.""" + channel_order_ox_first: bool = False + """Bit 20: Channel order. False = XL first, True = OX first.""" + x0_interface_ham_can: bool = False + """Bit 21: X0 interface. False = other, True = Ham CAN.""" + park_heads_with_iswap_off: bool = False + """Bit 22: Park heads with iSWAP. False = on, True = off.""" + + # ke (configuration data 3, 32-bit) + configuration_data_3: int = 0 + """Raw configuration data 3 (ke, 32-bit). Bit definitions are undocumented.""" + + instrument_size_slots: int = 54 + """Instrument size in slots, X range (xt). Default: 54.""" + auto_load_size_slots: int = 54 + """Auto load size in slots (xa). Default: 54.""" + tip_waste_x_position: float = 1340.0 + """Tip waste X-position [mm] (xw). Default: 1340.0.""" + left_x_drive: DriveConfiguration = field(default_factory=DriveConfiguration) + """Left X drive configuration (xl + xn).""" + right_x_drive: Optional[DriveConfiguration] = None + """Right X drive configuration (xr + xo), or None when no right arm is installed.""" + min_iswap_collision_free_position: float = 350.0 + """Minimal iSWAP collision free position for direct X access [mm] (xm). Default: 350.0.""" + max_iswap_collision_free_position: float = 1140.0 + """Maximal iSWAP collision free position for direct X access [mm] (xx). Default: 1140.0.""" + left_x_arm_width: float = 370.0 + """Width of left X arm [mm] (xu). Default: 370.0.""" + right_x_arm_width: float = 370.0 + """Width of right X arm [mm] (xv). Default: 370.0.""" + num_xl_channels: int = 0 + """Number of XL channels (kc). Range: 0..8.""" + num_robotic_channels: int = 0 + """Number of Robotic channels (kr). Range: 0..8.""" + min_raster_pitch_pip_channels: float = 9.0 + """Minimal raster pitch of PIP channels [mm] (ys). Default: 9.0.""" + min_raster_pitch_xl_channels: float = 36.0 + """Minimal raster pitch of XL channels [mm] (kl). Default: 36.0.""" + min_raster_pitch_robotic_channels: float = 36.0 + """Minimal raster pitch of Robotic channels [mm] (km). Default: 36.0.""" + pip_maximal_y_position: float = 606.5 + """PIP maximal Y position [mm] (ym). Default: 606.5.""" + left_arm_min_y_position: float = 6.0 + """Left arm minimal Y position [mm] (yu). Default: 6.0.""" + right_arm_min_y_position: float = 6.0 + """Right arm minimal Y position [mm] (yx). Default: 6.0.""" + + +@dataclass +class PipChannelInformation: + """Installed hardware information for a single pipetting channel (VW command).""" + + ChannelType = Literal["ML_STAR", "ML_STAR_RPC"] + HeadType = Literal["ML_STAR", "ML_STAR_PLE", "ML_STAR_RPC"] + StopDiscType = Literal["core_i", "core_ii"] + PressureADC = Literal["Renesas_X9268", "Analog_Devices_AD5263"] + + channel_type: ChannelType + head_type: HeadType + stop_disc_type: StopDiscType + pressure_adc: PressureADC + + +@dataclass(frozen=True, eq=False) +class Head96Information: + """Information about the installed 96-head.""" + + StopDiscType = Literal["core_i", "core_ii"] + InstrumentType = Literal["legacy", "FM-STAR"] + HeadType = Literal["Low volume head", "High volume head", "96 head II", "96 head TADM", "unknown"] + + fw_version: datetime.date + x_offset: float + """Deck X distance from the X-arm carriage center to head channel A1 (mm), read from + master EEPROM at setup. Mirrors iSWAPInformation.rotation_drive_x_offset.""" + supports_clot_monitoring_clld: bool + stop_disc_type: StopDiscType + instrument_type: InstrumentType + head_type: HeadType + + # === Firmware/variant-derived limits. z_range is set at setup because its max is a hardware + # probe; the Y and dispensing-drive windows are pure functions of fw_version (and the encoder + # resolutions below), so they are exposed as properties rather than stored. === + z_range: Tuple[float, float] + """Z-drive position window (mm); FM-STAR extends it. Set at setup: the min is variant-derived, + the max is read from a hardware probe.""" + + z_speed_range: Tuple[float, float] = (0.25, 100.0) + """Z-drive speed window (mm/s); unchanged across the 2008/2013/2025 firmware, unlike the + version-resolved `y_speed_range`.""" + z_acceleration_range: Tuple[float, float] = (25.0, 500.0) + """Z-drive acceleration window (mm/s2); unchanged across the 2008/2013/2025 firmware (the + pre-2010 encoding differs, the physical range does not).""" + + # === Encoder resolutions (defaulted device facts). Y/Z are unchanged across firmware; the + # dispensing/squeezer resolutions are the 2013+ generation values (2008-era heads differ). === + z_drive_mm_per_increment: float = 0.005 + y_drive_mm_per_increment: float = 0.015625 + dispensing_drive_mm_per_increment: float = 0.001025641026 + dispensing_drive_uL_per_increment: float = 0.019340933 + squeezer_drive_mm_per_increment: float = 0.0002086672009 + + # === Firmware/variant-derived area-of-operation windows (standard units). Pure functions of + # fw_version and the encoder resolutions above, so they are computed on access. === + @property + def y_range(self) -> Tuple[float, float]: + """Y-drive position window (mm); 2013 firmware shifted it from the 2008 range.""" + min_inc, max_inc = (6000, 36000) if self.fw_version.year >= 2010 else (7000, 36200) + return ( + round(min_inc * self.y_drive_mm_per_increment, 2), + round(max_inc * self.y_drive_mm_per_increment, 2), + ) + + @property + def y_speed_range(self) -> Tuple[float, float]: + """Y-drive speed window (mm/s). The pre-2021 max (390.625 = the firmware default, 25000 inc) is + an empirical, deck-tested cap; per firmware version the maxima are 312.5 (2008) and 625 (2013+). + Verify on a pre-2021 head before raising it.""" + return (0.78125, 390.625 if self.fw_version.year <= 2021 else 625.0) + + @property + def y_acceleration_range(self) -> Tuple[float, float]: + """Y-drive acceleration window (mm/s2). The min (5000 inc) is constant; the max rose from 32000 + inc (2008) to 50000 inc (2013+), so it tracks firmware like the Y range / speed.""" + max_inc = 50000 if self.fw_version.year >= 2010 else 32000 + return ( + round(5000 * self.y_drive_mm_per_increment, 2), + round(max_inc * self.y_drive_mm_per_increment, 2), + ) + + @property + def dispensing_drive_range(self) -> Tuple[float, float]: + """Aspirate/dispense piston volume window (uL); applies to both aspirate and dispense. 2013 + firmware widened the max from 62130 inc.""" + max_inc = 64350 if self.fw_version.year >= 2010 else 62130 + return (0.0, round(max_inc * self.dispensing_drive_uL_per_increment, 2)) + + @property + def dispensing_drive_speed_range(self) -> Tuple[float, float]: + """Dispensing-drive speed window (uL/s); 2013 firmware widened the max from 52000 inc.""" + min_inc = 5 # firmware dv minimum (00005 increments/second) + max_inc = 55000 if self.fw_version.year >= 2010 else 52000 + return ( + round(min_inc * self.dispensing_drive_uL_per_increment, 2), + round(max_inc * self.dispensing_drive_uL_per_increment, 2), + ) + + # === Per-drive factory default speed / acceleration (standard units). The Y/Z-drive defaults are + # deliberately not kept here: STARBackend reads them from the machine at setup into mutable + # attributes (head96_{y,z}_drive_{speed,acceleration}_default) so a run can override them. === + @property + def dispensing_drive_speed_default(self) -> float: + """Dispensing-drive default speed (uL/s); constant across firmware.""" + return 261.1 + + @property + def dispensing_drive_acceleration_default(self) -> float: + """Dispensing-drive default acceleration (uL/s2); 2013 firmware raised it.""" + increments = 900000 if self.fw_version.year >= 2010 else 150000 + return round(increments * self.dispensing_drive_uL_per_increment, 2) + + @property + def squeezer_drive_speed_default(self) -> float: + """Squeezer-drive default speed (mm/s); 2013 firmware raised it.""" + increments = 76000 if self.fw_version.year >= 2010 else 16000 + return round(increments * self.squeezer_drive_mm_per_increment, 2) + + @property + def squeezer_drive_acceleration_default(self) -> float: + """Squeezer-drive default acceleration (mm/s2); 2013 firmware raised it.""" + increments = 300000 if self.fw_version.year >= 2010 else 100000 + return round(increments * self.squeezer_drive_mm_per_increment, 2) + + +@dataclass(frozen=True, eq=False) +class iSWAPInformation: + """Device parameters for the installed iSWAP, loaded or resolved at setup. + + Populated once by `STARBackend._set_up_iswap` when the iSWAP is installed + (`extended_conf.left_x_drive.iswap_installed`). Holds two kinds of data: + per-machine calibration read from EEPROM (link lengths, calibrated stops, + offsets), and firmware/hardware-version-dependent device facts (per-drive + area-of-operation ranges and encoder resolutions). Neither changes at + runtime, so the record is treated as immutable post-setup. + """ + + # Two tiers (dataclasses require non-default fields before defaulted fields): + # per-machine calibration read from EEPROM, then defaulted device facts. + # Each tier is ordered by axis/drive (X, Y, Z, rotation, wrist, gripper). + + # === Per-machine calibration (read from EEPROM at setup; no defaults) ====== + fw_version: str + """iSWAP firmware version string (R0 RF response).""" + + # -- X -- + rotation_drive_x_offset: float + """Deck X distance from the X-arm carriage center to the rotation drive + (mm). Stored in master EEPROM as parameter `kg`. Hamilton factory default + is 34.0 mm.""" + + # -- Y -- + rotation_drive_y_max: float + """Upper Y-axis bound of the iSWAP carriage (mm). Parking sits at this Y; + anything past it is in the mechanical-stop region.""" + + # -- rotation drive -- + rotation_drive_predefined_increments: Dict["STARBackend.RotationDriveOrientation", int] + """Calibrated motor-increment positions for the rotation drive's named + stops (LEFT / FRONT / RIGHT / PARKED_RIGHT), read from EEPROM `pw[0..4]`. + Used as anchor points for angle <-> increment conversion in + `_iswap_rotation_drive_increments_to_angle`.""" + + link_1_length: float + """Distance from the rotation joint (joint 1) to the wrist joint (joint 2), + in mm. Hamilton factory default is 138.0 mm; queried from EEPROM via + `iswap_request_link_1_length` (R0 RA ra=pw, slot 9).""" + + # -- wrist drive -- + wrist_drive_predefined_increments: Dict["STARBackend.WristDriveOrientation", int] + """Calibrated motor-increment positions for the wrist drive's named stops + (RIGHT / STRAIGHT / LEFT / REVERSE), read from EEPROM `pt[1..4]`. Used to + determine the per-machine STRAIGHT angle, which anchors forward kinematics + (link-2 angle is measured relative to STRAIGHT).""" + + link_2_length: float + """Distance from the wrist joint (joint 2) to the gripper finger center, + in mm. Hamilton factory default is 138.0 mm; queried from EEPROM via + `iswap_request_link_2_length` (R0 RA ra=pt, slot 9).""" + + # === Firmware/hardware-version-dependent device facts (4th-generation iSWAP, + # the only generation currently supported): per-drive area-of-operation + # ranges and encoder resolutions. Defaulted (same across units of a + # generation), so setup construction is unchanged. Defaults mirror the + # STARBackend class constants tagged `# TODO: remove in v1`. =============== + + # -- Y -- + y_increment_range: Tuple[int, int] = (0, 14_000) + """Y-carriage position range accepted by the YA command, in motor increments + (the mechanical area of operation ends earlier; the per-machine parking bound + is `rotation_drive_y_max`).""" + + y_mm_per_increment: float = 0.046302083 + + y_speed_increment_range: Tuple[int, int] = (50, 8_000) # unit: increments/sec + + # -- Z -- + z_increment_range: Tuple[int, int] = (-187, 26_661) + + z_mm_per_increment: float = 0.01072765 + + z_speed_increment_range: Tuple[int, int] = (50, 15_000) # unit: increments/sec + + z_acceleration_increment_range: Tuple[int, int] = (5, 999) # unit: 1000 increments/sec^2 + + # -- rotation drive (joint 1, W) -- + rotation_increment_range: Tuple[int, int] = (-30_032, 30_032) + + rotation_deg_per_increment: float = 0.00309619077 + + # -- wrist drive (joint 2, T) -- + wrist_increment_range: Tuple[int, int] = (-30_000, 30_000) + + wrist_deg_per_increment: float = 0.00507968798 + + # -- gripper (G) -- + gripper_increment_range: Tuple[int, int] = (12_780, 24_120) # jaw width + + gripper_mm_per_increment: float = 0.00554337 + + +class STARBackend(HamiltonLiquidHandler, HamiltonHeaterShakerInterface): + """Interface for the Hamilton STARBackend.""" + + class iSWAPAxis(enum.IntEnum): + """Axis index for `iswap_request_joint_state` dicts. + + Units are axis-implicit (matches PF400's `Dict[int, float]` keyed by `PFAxis`): + prismatic axes (X/Y/Z, GRIPPER) are in mm; revolute axes (ROTATION, WRIST) + are in degrees. Z is the rotation-drive-bottom Z (sits 13 mm above the + gripper finger plane). + """ + + X = 1 # X-arm carriage (rotation-drive X in deck coords) + Y = 2 # Y carriage at rotation drive + Z = 3 # Z carriage at rotation drive (rotation-drive-bottom Z, deck coords). + # NOT the grip-center Z - that sits ~13 mm below this plane and only + # appears in iswap_request_pose().location.z. + ROTATION = 4 # W joint 1, signed from calibrated FRONT (deg) + WRIST = 5 # T joint 2, signed from motor zero (deg) + GRIPPER = 6 # gripper jaw opening width + + @property + def is_in_kinematic_chain(self) -> bool: + """Whether this axis enters forward kinematics. The gripper is an + addressable actuator but not a chain member - it changes what is held, + not where the gripper frame is.""" + return self is not STARBackend.iSWAPAxis.GRIPPER + + @property + def is_revolute(self) -> bool: + """True for revolute (rotary, deg) axes; False for prismatic (linear, mm).""" + return self in (STARBackend.iSWAPAxis.ROTATION, STARBackend.iSWAPAxis.WRIST) + + PIP_X_MIN_WITH_LEFT_SIDE_PANEL: float = 320.0 + HEAD96_X_MIN_WITH_LEFT_SIDE_PANEL: float = 0.0 + + def __init__( + self, + device_address: Optional[int] = None, + serial_number: Optional[str] = None, + packet_read_timeout: int = 3, + read_timeout: int = 30, + write_timeout: int = 30, + left_side_panel_installed: bool = False, + ): + """Create a new STAR interface. + + Args: + device_address: the USB device address of the Hamilton STARBackend. Only useful if using more than + one Hamilton machine over USB. + serial_number: the serial number of the Hamilton STARBackend. Only useful if using more than one + Hamilton machine over USB. + packet_read_timeout: timeout in seconds for reading a single packet. + read_timeout: timeout in seconds for reading a full response. + write_timeout: timeout in seconds for writing a command. + left_side_panel_installed: if True, restrict PIP channels to x >= 320mm and + the 96-head to x >= 0mm to prevent collisions with the left side panel. + """ + + super().__init__( + device_address=device_address, + packet_read_timeout=packet_read_timeout, + read_timeout=read_timeout, + write_timeout=write_timeout, + id_product=0x8000, + serial_number=serial_number, + ) + + self.left_side_panel_installed = left_side_panel_installed + self._machine_conf: Optional[MachineConfiguration] = None + + self._iswap_parked: Optional[bool] = None + self._num_channels: Optional[int] = None + self._channels_minimum_y_spacing: List[float] = [9.0] * 8 + self._core_parked: Optional[bool] = None + self._extended_conf: Optional[ExtendedConfiguration] = None + self._channel_traversal_height: float = 245.0 + self._iswap_traversal_height: float = 280.0 + # All iSWAP setup state lives in a single dataclass populated by + # `set_up_iswap` from firmware/EEPROM. See `iswap_information` property + # for guarded access. None pre-setup; immutable post-setup. + self._iswap_information: Optional[iSWAPInformation] = None + # Mutable 96-head Y/Z drive speed/acceleration defaults, seeded from the machine's registers at + # setup and overridable via the @property setters (range-checked). None until setup() loads them; + # a move uses the current default when no explicit value is passed. + self._head96_y_drive_speed_default: Optional[float] = None + self._head96_y_drive_acceleration_default: Optional[float] = None + self._head96_z_drive_speed_default: Optional[float] = None + self._head96_z_drive_acceleration_default: Optional[float] = None + self.core_adjustment = Coordinate.zero() + self._unsafe = UnSafe(self) + + self._pip_channel_information: Optional[List[PipChannelInformation]] = None + + self._default_1d_symbology: Barcode1DSymbology = "Code 128 (Subset B and C)" + self._x_grouping_tolerance_mm: float = 0.1 + + self._setup_done = False + + def _min_spacing_between(self, i: int, j: int) -> float: + """Return the firmware-safe minimum Y spacing between channels *i* and *j*. + + For each adjacent pair, takes max() of both channels' spacings and ceiling-rounds + to 0.1mm. For non-adjacent channels, sums these per-pair spacings. + + TODO: migrate to radii model (spacing[i]/2 + spacing[j]/2) to match + compute_channel_offsets. Current max() model is conservative but inconsistent + with channel_positioning.py's diameter-based abstraction. + """ + lo, hi = min(i, j), max(i, j) + if hi - lo == 1: + import math + + spacing = max(self._channels_minimum_y_spacing[lo], self._channels_minimum_y_spacing[hi]) + return math.ceil(spacing * 10) / 10 + return sum(self._min_spacing_between(k, k + 1) for k in range(lo, hi)) + + def _ops_to_fw_positions( + self, ops: Sequence[PipettingOp], use_channels: List[int] + ) -> Tuple[List[int], List[int], List[bool]]: + x_positions, y_positions, channels_involved = super()._ops_to_fw_positions(ops, use_channels) + # TODO: also bound each involved channel's x against the arm's resolved x_range here, + # raising once with every non-compliant (channel, x) pair. Extends the primitive guard + # in `_check_x_arm_reachable` to the high-level pipetting ops that route through here. + if self.left_side_panel_installed: + min_x = round(self.PIP_X_MIN_WITH_LEFT_SIDE_PANEL * 10) + for x, involved in zip(x_positions, channels_involved): + if involved and x < min_x: + raise ValueError( + f"PIP channel x={x / 10}mm is below the minimum " + f"{self.PIP_X_MIN_WITH_LEFT_SIDE_PANEL}mm (left side panel is installed)" + ) + return x_positions, y_positions, channels_involved + + @property + def machine_conf(self) -> MachineConfiguration: + """Machine configuration.""" + if self._machine_conf is None: + raise RuntimeError("has not loaded machine_conf, forgot to call `setup`?") + return self._machine_conf + + @property + def autoload_installed(self) -> bool: + """Deprecated. Use `machine_conf.auto_load_installed`.""" + warnings.warn( + "autoload_installed is deprecated. Use `machine_conf.auto_load_installed` instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.machine_conf.auto_load_installed + + @property + def iswap_installed(self) -> bool: + """Deprecated. Use `extended_conf.left_x_drive.iswap_installed`.""" + warnings.warn( + "iswap_installed is deprecated. Use `extended_conf.left_x_drive.iswap_installed` instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.extended_conf.left_x_drive.iswap_installed + + @property + def core96_head_installed(self) -> bool: + """Deprecated. Use `extended_conf.left_x_drive.core_96_head_installed`.""" + warnings.warn( + "core96_head_installed is deprecated. Use " + "`extended_conf.left_x_drive.core_96_head_installed` instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.extended_conf.left_x_drive.core_96_head_installed + + @property + def num_arms(self) -> int: + return 1 if self.extended_conf.left_x_drive.iswap_installed else 0 + + @property + def head96_installed(self) -> Optional[bool]: + return self.extended_conf.left_x_drive.core_96_head_installed + + @property + def unsafe(self) -> "UnSafe": + """Actions that have a higher risk of damaging the robot. Use with care!""" + return self._unsafe + + @property + def num_channels(self) -> int: + """The number of pipette channels present on the robot.""" + if self._num_channels is None: + raise RuntimeError("has not loaded num_channels, forgot to call `setup`?") + return self._num_channels + + def set_minimum_traversal_height(self, traversal_height: float): + raise NotImplementedError( + "set_minimum_traversal_height is deprecated. use set_minimum_channel_traversal_height or " + "set_minimum_iswap_traversal_height instead." + ) + + def set_minimum_channel_traversal_height(self, traversal_height: float): + """Set the minimum traversal height for the pip channels. + + This refers to the bottom of the pipetting channel when no tip is present, or the bottom of the + tip when a tip is present. This value will be used as the default value for the + `minimal_traverse_height_at_begin_of_command` and `minimal_height_at_command_end` parameters + unless they are explicitly set. + """ + + assert 0 < traversal_height < 285, "Traversal height must be between 0 and 285 mm" + + self._channel_traversal_height = traversal_height + + def set_minimum_iswap_traversal_height(self, traversal_height: float): + """Set the minimum traversal height for the iswap.""" + + assert 0 < traversal_height < 285, "Traversal height must be between 0 and 285 mm" + + self._iswap_traversal_height = traversal_height + + @contextmanager + def iswap_minimum_traversal_height(self, traversal_height: float): + orig = self._iswap_traversal_height + self._iswap_traversal_height = traversal_height + try: + yield + except Exception as e: + self._iswap_traversal_height = orig + raise e + + @property + def iswap_traversal_height(self) -> float: + return self._iswap_traversal_height + + @property + def module_id_length(self): + return 2 + + @property + def extended_conf(self) -> ExtendedConfiguration: + """Extended configuration.""" + if self._extended_conf is None: + raise RuntimeError("has not loaded extended_conf, forgot to call `setup`?") + return self._extended_conf + + @property + def iswap_parked(self) -> bool: + return self._iswap_parked is True + + @property + def core_parked(self) -> bool: + return self._core_parked is True + + @property + def iswap_information(self) -> iSWAPInformation: + """Cached iSWAP setup state (link lengths, EEPROM-calibrated stops, fw version). + + Populated by `setup()` when the iSWAP is installed. Raises if `setup()` + has not run with the iSWAP active. + """ + if self._iswap_information is None: + raise RuntimeError( + "iSWAP information not loaded; ensure the iSWAP is installed and `setup()` has run." + ) + return self._iswap_information + + async def get_iswap_version(self) -> str: + """The iSWAP firmware version, loaded into `iswap_information` during setup.""" + return self.iswap_information.fw_version + + async def request_pip_channel_version(self, channel: int) -> str: + return cast( + str, + (await self.send_command(STARBackend.channel_id(channel), "RF", fmt="rf" + "&" * 17))["rf"], + ) + + async def _pip_channel_request_configuration(self, channel: int) -> PipChannelInformation: + """Request installed hardware for a pipetting channel using the VW command. + + Args: + channel: 0-indexed channel number. + """ + pip_fw = self._parse_firmware_version_datetime(await self.request_pip_channel_version(channel)) + if pip_fw.year <= 2016: + raise RuntimeError( + f"VW (pip channel configuration) is not supported on firmware from 2016 or older " + f"(channel {channel} firmware date: {pip_fw.isoformat()})." + ) + resp: str = await self.send_command(STARBackend.channel_id(channel), "VW") + return self._parse_pip_channel_information(resp) + + @staticmethod + def _parse_pip_channel_information(resp: str) -> PipChannelInformation: + """Parse a VW (pip channel hardware-configuration) firmware response. + + The number of fields in a VW reply varies by firmware. The full form is + 4 fields (channel_type, head_type, stop_disc_type, pressure_adc), but some + firmwares -- including post-2016 ones that pass the year gate in + `_pip_channel_request_configuration` -- return a 2-field short form such as + ``vw0 0``. Missing trailing fields fall back to their baseline ("code 0") + value rather than raising, since the cached information is descriptive + metadata and is not consulted by pipetting logic. This is distinct from the + firmware-year gate in `_pip_channel_request_configuration`, which decides + whether VW is queried at all. + + Behavior for fields that ARE present is identical to the historical parser; + only absent fields are newly defaulted. A reply with zero fields is treated + as a malformed/communication failure and raises, so it is distinguishable + from a known short layout. + + Args: + resp: Raw VW firmware response (e.g. ``"P1VWid0001vw0 0"``). + + Returns: + Parsed `PipChannelInformation`. + + Raises: + ValueError: If the response contains no hardware-configuration fields. + """ + hw_tokens = resp.split("vw")[-1].strip().split() + if not hw_tokens: + raise ValueError(f"Unparsable VW (pip channel configuration) response: {resp!r}") + + def tok(i: int) -> Optional[str]: + return hw_tokens[i] if i < len(hw_tokens) else None + + return PipChannelInformation( + channel_type="ML_STAR_RPC" if tok(0) == "1" else "ML_STAR", + head_type="ML_STAR_PLE" if tok(1) == "1" else "ML_STAR_RPC" if tok(1) == "2" else "ML_STAR", + stop_disc_type="core_i" if tok(2) in ("0", None) else "core_ii", + pressure_adc="Analog_Devices_AD5263" if tok(3) == "1" else "Renesas_X9268", + ) + + def get_id_from_fw_response(self, resp: str) -> Optional[int]: + """Get the id from a firmware response.""" + parsed = parse_star_fw_string(resp, "id####") + if "id" in parsed and parsed["id"] is not None: + return int(parsed["id"]) + return None + + def check_fw_string_error(self, resp: str): + """Raise an error if the firmware response is an error response. + + Raises: + ValueError: if the format string is incompatible with the response. + HamiltonException: if the response contains an error. + """ + + # Parse errors. + module = resp[:2] + if module == "C0": + # C0 sends errors as er##/##. P1 raises errors as er## where the first group is the error + # code, and the second group is the trace information. + # Beyond that, specific errors may be added for individual channels and modules. These + # are formatted as P1##/## H0##/##, etc. These items are added programmatically as + # named capturing groups to the regex. + + exp = r"er(?P[0-9]{2}/[0-9]{2})" + for module in [ + "X0", + "I0", + "W1", + "W2", + "T1", + "T2", + "R0", + "P1", + "P2", + "P3", + "P4", + "P5", + "P6", + "P7", + "P8", + "P9", + "PA", + "PB", + "PC", + "PD", + "PE", + "PF", + "PG", + "H0", + "HW", + "HU", + "HV", + "N0", + "D0", + "NP", + "M1", + ]: + exp += f" ?(?:{module}(?P<{module}>[0-9]{{2}}/[0-9]{{2}}))?" + errors = re.search(exp, resp) + else: + # Other modules send errors as er##, and do not contain slave errors. + exp = f"er(?P<{module}>[0-9]{{2}})" + errors = re.search(exp, resp) + + if errors is not None: + # filter None elements + errors_dict = {k: v for k, v in errors.groupdict().items() if v is not None} + # filter 00 and 00/00 elements, which mean no error. + errors_dict = {k: v for k, v in errors_dict.items() if v not in ["00", "00/00"]} + + has_error = not (errors is None or len(errors_dict) == 0) + if has_error: + he = star_firmware_string_to_error(error_code_dict=errors_dict, raw_response=resp) + + # If there is a faulty parameter error, request which parameter that is. + for module_name, error in he.errors.items(): + if error.message == "Unknown parameter": + # temp. disabled until we figure out how to handle async in parse response (the + # background thread does not have an event loop, and I'm not sure if it should.) + # vp = await self.send_command(module=error.raw_module, command="VP", fmt="vp&&")["vp"] + # he[module_name].message += f" ({vp})" + + he.errors[ + module_name + ].message += " (call lh.backend.request_name_of_last_faulty_parameter)" + + raise he + + def _parse_response(self, resp: str, fmt: str) -> dict: + """Parse a response from the machine.""" + return parse_star_fw_string(resp, fmt) + + def _parse_firmware_version_datetime(self, fw_version: str) -> datetime.date: + """Extract datetime from firmware version string. + + Args: + fw_version: Firmware version string (e.g., "v2021.03.15" or "2023_Q2_v1.4") + + Returns: + A datetime object representing the extracted date + """ + + # Prefer full date patterns like YYYY.MM.DD / YYYY_MM_DD / YYYY-MM-DD + date_match = re.search(r"\b(20\d{2})[._-](\d{2})[._-](\d{2})\b", fw_version) + if date_match: + y, m, d = map(int, date_match.groups()) + return datetime.date(y, m, d) + + # Handle quarter formats like 2023_Q2 -> first day of the quarter + q_match = re.search(r"\b(20\d{2})_Q([1-4])\b", fw_version, flags=re.IGNORECASE) + if q_match: + y = int(q_match.group(1)) + q = int(q_match.group(2)) + month = (q - 1) * 3 + 1 + return datetime.date(y, month, 1) + + # Fall back to year only -> Jan 1st of that year, or None + year_match = re.search(r"\b(20\d{2})\b", fw_version) + if year_match is None: + raise ValueError(f"Could not parse year from firmware version string: '{fw_version}'") + return datetime.date(int(year_match.group(1)), 1, 1) + + async def setup( + self, + skip_instrument_initialization=False, + skip_pip=False, + skip_autoload=False, + skip_iswap=False, + skip_core96_head=False, + ): + """Creates a USB connection and finds read/write interfaces. + + Args: + skip_autoload: if True, skip initializing the autoload module, if applicable. + skip_iswap: if True, skip initializing the iSWAP module, if applicable. + skip_core96_head: if True, skip initializing the CoRe 96 head module, if applicable. + """ + + await super().setup() + + self.id_ = 0 + + # Request machine information + self._machine_conf = await self.request_machine_configuration() + self._extended_conf = await self.request_extended_configuration() + self._head96_information: Optional[Head96Information] = None + + initialized = await self.request_instrument_initialization_status() + + if not initialized: + if not skip_instrument_initialization: + logger.info("Running backend initialization procedure.") + + await self.pre_initialize_instrument() + else: + # pre_initialize only runs when the robot is not initialized + # pre_initialize will move all channels to Z safety + # so if we skip pre_initialize, we need to raise the channels ourselves + await self.move_all_channels_in_z_safety() + if self.extended_conf.left_x_drive.core_96_head_installed: + # raise the 96-head to Z-safety before the iSWAP (shared left X-drive) moves in set_up_iswap. + # head96_move_to_z_safety can't be used yet: Head96Information is built in set_up_core96_head. + await self._head96_probe_z_max() + + tip_presences = await self.request_tip_presence() + self._num_channels = len(tip_presences) + + async def set_up_pip(): + if skip_pip: + # Skip pip-channel I/O; the __init__ defaults stand in. + # TODO: does not yet gate request_tip_presence or instrument-init moves. + return + if not initialized or any(tip_presences): + await self.initialize_pip() + self._channels_minimum_y_spacing = await self.channels_request_y_minimum_spacing() + + # VW is not supported on firmware from 2016 or older (see issue #1004). Skip the + # query there and leave the cache as None; otherwise populate it for every channel. + pip_fw = self._parse_firmware_version_datetime(await self.request_pip_channel_version(0)) + if pip_fw.year <= 2016: + self._pip_channel_information = None + else: + self._pip_channel_information = [ + await self._pip_channel_request_configuration(ch) for ch in range(self.num_channels) + ] + + async def set_up_autoload(): + if self.machine_conf.auto_load_installed and not skip_autoload: + autoload_initialized = await self.request_autoload_initialization_status() + if not autoload_initialized: + await self.initialize_autoload() + + await self.park_autoload() + + async def set_up_iswap(): + if self.extended_conf.left_x_drive.iswap_installed and not skip_iswap: + iswap_initialized = await self.request_iswap_initialization_status() + if not iswap_initialized: + await self.initialize_iswap() + + await self.park_iswap( + minimum_traverse_height_at_beginning_of_a_command=int(self._iswap_traversal_height * 10) + ) + + rot_predefined = await self._iswap_rotation_drive_request_predefined_increments() + wrist_predefined = await self._iswap_wrist_drive_request_predefined_increments() + + self._iswap_information = iSWAPInformation( + fw_version=await self.request_iswap_version(), + rotation_drive_x_offset=await self._iswap_rotation_drive_request_x_offset(), + rotation_drive_y_max=await self._iswap_rotation_drive_request_y_max(), + link_1_length=await self.iswap_request_link_1_length(), + link_2_length=await self.iswap_request_link_2_length(), + rotation_drive_predefined_increments={ + STARBackend.RotationDriveOrientation.LEFT: rot_predefined["left"], + STARBackend.RotationDriveOrientation.FRONT: rot_predefined["front"], + STARBackend.RotationDriveOrientation.RIGHT: rot_predefined["right"], + STARBackend.RotationDriveOrientation.PARKED_RIGHT: rot_predefined["parking"], + }, + wrist_drive_predefined_increments={ + STARBackend.WristDriveOrientation.RIGHT: wrist_predefined["right"], + STARBackend.WristDriveOrientation.STRAIGHT: wrist_predefined["straight"], + STARBackend.WristDriveOrientation.LEFT: wrist_predefined["left"], + STARBackend.WristDriveOrientation.REVERSE: wrist_predefined["reverse"], + }, + ) + + async def set_up_core96_head(): + if self.extended_conf.left_x_drive.core_96_head_installed and not skip_core96_head: + # Initialize 96-head + core96_head_initialized = await self.request_core_96_head_initialization_status() + if not core96_head_initialized: + await self.initialize_core_96_head( + trash96=self.deck.get_trash_area96(), + z_position_at_the_command_end=self._channel_traversal_height, + ) + + # Cache firmware version and configuration for version-specific behavior + fw_version = await self.head96_request_firmware_version() + configuration_96head = await self._head96_request_configuration() + head96_type = await self.head96_request_type() + + instrument_type: Head96Information.InstrumentType = ( + "legacy" if configuration_96head[2] == "0" else "FM-STAR" + ) + self._head96_information = Head96Information( + fw_version=fw_version, + x_offset=await self._head96_request_x_offset(), + supports_clot_monitoring_clld=bool(int(configuration_96head[0])), + stop_disc_type="core_i" if configuration_96head[1] == "0" else "core_ii", + instrument_type=instrument_type, + head_type=head96_type, + # probing safe max z position also acts a safety retraction of the head96 on every setup call + z_range=( + self._head96_resolve_z_range(instrument_type)[0], + await self._head96_probe_z_max(), + ), + ) + # Seed the mutable Y/Z drive speed/acceleration defaults from the machine's current + # registers; a run can override them via the head96_*_drive_*_default setters afterwards. + self._head96_y_drive_speed_default = await self.head96_request_y_speed() + self._head96_y_drive_acceleration_default = await self.head96_request_y_acceleration() + self._head96_z_drive_speed_default = await self.head96_request_z_speed() + self._head96_z_drive_acceleration_default = await self.head96_request_z_acceleration() + + async def set_up_arm_modules(): + await set_up_pip() + await set_up_iswap() + await set_up_core96_head() + + await asyncio.gather(set_up_autoload(), set_up_arm_modules()) + + # After setup, STAR will have thrown out anything mounted on the pipetting channels, including + # the core grippers. + self._core_parked = True + + self._setup_done = True + + async def stop(self): + await super().stop() + self._setup_done = False + + @property + def setup_done(self) -> bool: + return self._setup_done + + # ============== LiquidHandlerBackend methods ============== + + # ----------------------------------------------------------------------- + # X-Arm + # ----------------------------------------------------------------------- + + async def x_arm_request_firmware_version(self) -> Tuple[str, datetime.date]: + """Request the X-arm firmware version and build date. + + Returns: + A tuple of (version_string, build_date), e.g. ("1.0S", date(2009, 6, 24)). + """ + + resp = await self.send_command(module="X0", command="RF") + version = resp.split("rf")[-1].split(" ")[0] + build_date = self._parse_firmware_version_datetime(resp) + return version, build_date + + async def experimental_x_arm_move( + self, + x: float, + acceleration_level: int = 3, + current_protection_limiter: int = 7, + ): + """Move the X-arm to an absolute X position with specified acceleration. + + Args: + x: Target X coordinate in mm. Must be within the arm's reachable X range + (see the X-drive's `x_range`). + acceleration_level: Acceleration index (hardware units), 1-5. Default 3. + current_protection_limiter: Motor current limit (hardware units), 0-7. Default 7. + """ + + self._check_x_arm_reachable(x) + if not (1 <= acceleration_level <= 5): + raise ValueError(f"acceleration_level must be between 1 and 5, is {acceleration_level}") + if not (0 <= current_protection_limiter <= 7): + raise ValueError( + f"current_protection_limiter must be between 0 and 7, is {current_protection_limiter}" + ) + + return await self.send_command( + module="X0", + command="XP", + la=f"{round(x * 10):05}", + lr=str(acceleration_level), + lw=str(current_protection_limiter), + ) + + def _check_x_arm_reachable(self, x: float, position: Literal["left", "right"] = "left") -> None: + """Raise if x (mm) is outside the X-drive's travel range (``x_range``). + + ``x`` is the arm's position at its reference point (its center for a dual-rail arm, the + right edge for a single-rail arm), so the bound is that point's travel range ``x_range``. + + Args: + x: Target X coordinate in mm. + position: Which X-arm the move drives. Defaults to the left (main) arm. + + Reading ``extended_conf`` already raises if setup() has not run. + + Raises: + RuntimeError: If the installed arm's geometry was not resolved. + ValueError: If no such arm is installed, or x is outside its ``x_range``. + """ + arm = ( + self.extended_conf.left_x_drive if position == "left" else self.extended_conf.right_x_drive + ) + if arm is None: + raise ValueError(f"No {position} X-arm is installed.") + if arm.x_range is None: + raise RuntimeError(f"{position} X-arm geometry not resolved") + x_min, x_max = arm.x_range + if not x_min <= x <= x_max: + drives = (self.extended_conf.left_x_drive, self.extended_conf.right_x_drive) + label = f"{position} X-arm" if sum(d is not None for d in drives) > 1 else "X-arm" + raise ValueError(f"{label} x={x}mm is outside its drive travel range [{x_min}, {x_max}].") + + # # # # Single-Channel Pipette Commands # # # # + + # # # Machine Query (MEM-READ) Commands: Single-Channel # # # + + async def channel_request_y_minimum_spacing(self, channel_idx: int) -> float: + """Request the minimum Y spacing for a given channel. + + Args: + channel_idx: the channel index to query. (0-indexed) + + Returns: + The minimum Y spacing in mm. + """ + + if not 0 <= channel_idx <= self.num_channels - 1: + raise ValueError( + f"channel_idx must be between 0 and {self.num_channels - 1}, got {channel_idx}." + ) + + resp = await self.send_command( + module=self.channel_id(channel_idx), + command="VY", + fmt="yc### (n)", + ) + return self.y_drive_increment_to_mm(resp["yc"][1]) + + async def channels_request_y_minimum_spacing(self) -> List[float]: + """Query all channels for their minimum Y spacing in parallel. + + Returns: + A list of minimum Y spacings in mm, one per channel. + """ + return list( + await asyncio.gather( + *(self.channel_request_y_minimum_spacing(i) for i in range(self.num_channels)) + ) + ) + + def can_reach_position(self, channel_idx: int, position: Coordinate) -> bool: + """Check if a position is reachable by a channel (center-based).""" + if not (0 <= channel_idx < self.num_channels): + raise ValueError(f"Channel {channel_idx} is out of range for this robot.") + + # frontmost channel can go to y=6, every channel behind it constrains its min Y + spacings = self._channels_minimum_y_spacing + min_y_pos = self.extended_conf.left_arm_min_y_position + sum(spacings[channel_idx + 1 :]) + if position.y < min_y_pos: + return False + + # backmost channel max Y from config, every channel in front constrains its max Y + max_y_pos = self.extended_conf.pip_maximal_y_position - sum(spacings[:channel_idx]) + if position.y > max_y_pos: + return False + + return True + + def ensure_can_reach_position( + self, use_channels: List[int], ops: Sequence[PipettingOp], op_name: str + ): + locs = [(op.resource.get_location_wrt(self.deck, y="c") + op.offset) for op in ops] + cant_reach = [ + channel_idx + for channel_idx, loc in zip(use_channels, locs) + if not self.can_reach_position(channel_idx, loc) + ] + if len(cant_reach) > 0: + raise ValueError( + f"Channels {cant_reach} cannot reach their target positions in '{op_name}' operation.\n" + "Robots with more than 8 channels have limited Y-axis reach per channel; they don't have random access to the full deck area.\n" + "Try the operation with different channels or a different target position (i.e. different labware placement)." + ) + + class ChannelCycleCounts(TypedDict): + tip_pick_up_cycles: int + tip_discard_cycles: int + aspiration_cycles: int + dispensing_cycles: int + + async def channel_request_cycle_counts(self, channel_idx: int) -> ChannelCycleCounts: + """Request cycle counters for a single channel. + + Returns the number of tip pick-up, tip discard, aspiration, and dispensing cycles + performed by the channel. + + Args: + channel_idx: The channel index to query (0-indexed). + + Returns: + A dict with keys ``tip_pick_up_cycles``, ``tip_discard_cycles``, + ``aspiration_cycles``, and ``dispensing_cycles``. + """ + + if not (0 <= channel_idx < self.num_channels): + raise ValueError( + f"channel_idx must be between 0 and {self.num_channels - 1}, got {channel_idx}." + ) + + resp = await self.send_command( + module=self.channel_id(channel_idx), + command="RV", + fmt="na##########nb##########nc##########nd##########", + ) + return { + "tip_pick_up_cycles": resp["na"], + "tip_discard_cycles": resp["nb"], + "aspiration_cycles": resp["nc"], + "dispensing_cycles": resp["nd"], + } + + async def channels_request_cycle_counts(self) -> List[ChannelCycleCounts]: + """Request cycle counters for all channels. + + Returns: + A list of dicts (one per channel, ordered by channel index), each with keys + ``tip_pick_up_cycles``, ``tip_discard_cycles``, ``aspiration_cycles``, + and ``dispensing_cycles``. + """ + + return list( + await asyncio.gather( + *(self.channel_request_cycle_counts(channel_idx=idx) for idx in range(self.num_channels)) + ) + ) + + # # # ACTION Commands # # # + + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + begin_tip_pick_up_process: Optional[float] = None, + end_tip_pick_up_process: Optional[float] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + pickup_method: Optional[TipPickupMethod] = None, + ): + """Pick up tips from a resource.""" + + self.ensure_can_reach_position(use_channels, ops, "pick_up_tips") + + x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) + + tip_spots = [op.resource for op in ops] + tips = set(cast(HamiltonTip, tip_spot.get_tip()) for tip_spot in tip_spots) + if len(tips) > 1: + raise ValueError("Cannot mix tips with different tip types.") + ttti = await self.get_or_assign_tip_type_index(tips.pop()) + + max_z = max(op.resource.get_location_wrt(self.deck).z + op.offset.z for op in ops) + max_total_tip_length = max(op.tip.total_tip_length for op in ops) + max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) + + # not sure why this is necessary, but it is according to log files and experiments + if self._get_hamilton_tip([op.resource for op in ops]).tip_size == TipSize.LOW_VOLUME: + max_tip_length += 2 + elif self._get_hamilton_tip([op.resource for op in ops]).tip_size != TipSize.STANDARD_VOLUME: + max_tip_length -= 2 + + tip = ops[0].tip + if not isinstance(tip, HamiltonTip): + raise TypeError("Tip type must be HamiltonTip.") + + begin_tip_pick_up_process = ( + round((max_z + max_total_tip_length) * 10) + if begin_tip_pick_up_process is None + else int(begin_tip_pick_up_process * 10) + ) + end_tip_pick_up_process = ( + round((max_z + max_tip_length) * 10) + if end_tip_pick_up_process is None + else round(end_tip_pick_up_process * 10) + ) + minimum_traverse_height_at_beginning_of_a_command = ( + round(self._channel_traversal_height * 10) + if minimum_traverse_height_at_beginning_of_a_command is None + else round(minimum_traverse_height_at_beginning_of_a_command * 10) + ) + pickup_method = pickup_method or tip.pickup_method + + try: + return await self.pick_up_tip( + x_positions=x_positions, + y_positions=y_positions, + tip_pattern=channels_involved, + tip_type_idx=ttti, + begin_tip_pick_up_process=begin_tip_pick_up_process, + end_tip_pick_up_process=end_tip_pick_up_process, + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, + pickup_method=pickup_method, + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise e + + async def drop_tips( + self, + ops: List[Drop], + use_channels: List[int], + drop_method: Optional[TipDropMethod] = None, + begin_tip_deposit_process: Optional[float] = None, + end_tip_deposit_process: Optional[float] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + z_position_at_end_of_a_command: Optional[float] = None, + ): + """Drop tips to a resource. + + Args: + drop_method: The method to use for dropping tips. If None, the default method for dropping to + tip spots is `DROP`, and everything else is `PLACE_SHIFT`. Note that `DROP` is only the + default if *all* tips are being dropped to a tip spot. + """ + + self.ensure_can_reach_position(use_channels, ops, "drop_tips") + + if drop_method is None: + if any(not isinstance(op.resource, TipSpot) for op in ops): + drop_method = TipDropMethod.PLACE_SHIFT + else: + drop_method = TipDropMethod.DROP + + x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) + + # get highest z position + max_z = max(op.resource.get_location_wrt(self.deck).z + op.offset.z for op in ops) + if drop_method == TipDropMethod.PLACE_SHIFT: + # magic values empirically found in https://github.com/PyLabRobot/pylabrobot/pull/63 + begin_tip_deposit_process = ( + round((max_z + 59.9) * 10) + if begin_tip_deposit_process is None + else round(begin_tip_deposit_process * 10) + ) + end_tip_deposit_process = ( + round((max_z + 49.9) * 10) + if end_tip_deposit_process is None + else round(end_tip_deposit_process * 10) + ) + else: + max_total_tip_length = max(op.tip.total_tip_length for op in ops) + max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) + begin_tip_deposit_process = ( + round((max_z + max_total_tip_length) * 10) + if begin_tip_deposit_process is None + else round(begin_tip_deposit_process * 10) + ) + end_tip_deposit_process = ( + round((max_z + max_tip_length) * 10) + if end_tip_deposit_process is None + else round(end_tip_deposit_process * 10) + ) + + minimum_traverse_height_at_beginning_of_a_command = ( + round(self._channel_traversal_height * 10) + if minimum_traverse_height_at_beginning_of_a_command is None + else round(minimum_traverse_height_at_beginning_of_a_command * 10) + ) + z_position_at_end_of_a_command = ( + round(self._channel_traversal_height * 10) + if z_position_at_end_of_a_command is None + else round(z_position_at_end_of_a_command * 10) + ) + + try: + return await self.discard_tip( + x_positions=x_positions, + y_positions=y_positions, + tip_pattern=channels_involved, + begin_tip_deposit_process=begin_tip_deposit_process, + end_tip_deposit_process=end_tip_deposit_process, + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, + z_position_at_end_of_a_command=z_position_at_end_of_a_command, + discarding_method=drop_method, + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise e + + def _assert_valid_resources(self, resources: Sequence[Resource]) -> None: + """Assert that resources are in a valid location for pipetting.""" + for resource in resources: + if resource.get_location_wrt(self.deck).z < 100: + raise ValueError( + f"Resource {resource} is too low: {resource.get_location_wrt(self.deck).z} < 100" + ) + + class LLDMode(enum.Enum): + """Liquid level detection mode.""" + + OFF = 0 + GAMMA = 1 + PRESSURE = 2 + DUAL = 3 + Z_TOUCH_OFF = 4 + + class PressureLLDMode(enum.Enum): + """Pressure liquid level detection mode.""" + + LIQUID = 0 + FOAM = 1 + + async def execute_batched( + self, + func: Callable[[ChannelBatch], Awaitable[T]], + batches: List[ChannelBatch], + min_traverse_height_during_command: Optional[float] = None, + ) -> List[T]: + """Execute a Z-axis callback across pre-planned batches with X/Y positioning. + + Handles inter-batch safety: raises channels between batches, moves X when the + X group changes, and positions Y before calling *func*. On error or + KeyboardInterrupt, channels are moved to Z safety before re-raising. + + Args: + func: Async callback that receives a ``ChannelBatch`` and performs Z-axis work + (e.g. liquid level detection, z-touch probing). Must not move X or Y. + batches: Pre-planned batches from ``plan_batches()``. + min_traverse_height_during_command: Absolute Z height (mm) for inter-batch + channel raises. ``None`` uses full Z safety. + + Returns: + List of results from each batch callback, in batch order. + """ + log_batches(batches) + results: List[T] = [] + try: + prev_batch: Optional[ChannelBatch] = None + for batch in batches: + if prev_batch is not None: + if min_traverse_height_during_command is None: + await self.move_all_channels_in_z_safety() + else: + await self.position_channels_in_z_direction( + {ch: min_traverse_height_during_command for ch in prev_batch.channels} + ) + + if prev_batch is None or not math.isclose(batch.x_position, prev_batch.x_position): + await self.move_channel_x(0, batch.x_position) + + await self.position_channels_in_y_direction(batch.y_positions) + results.append(await func(batch)) + prev_batch = batch + + except Exception: # firmware errors, RuntimeError, etc. + await self.move_all_channels_in_z_safety() + raise + except BaseException: # KeyboardInterrupt, SystemExit — still must raise channels + await self.move_all_channels_in_z_safety() + raise + + return results + + async def _prepare_batched( + self, + containers: List[Container], + use_channels: Optional[List[int]] = None, + resource_offsets: Optional[List[Coordinate]] = None, + x_grouping_tolerance: Optional[float] = None, + min_traverse_height_at_beginning_of_command: Optional[float] = None, + ) -> Tuple[List[int], List[float], List[ChannelBatch]]: + """Validate channels, verify tips, position Z, resolve targets, plan batches. + + Shared setup for any batched channel operation (probing, aspirate, + dispense). Returns everything the caller needs to define its callback + and call ``execute_batched``. + + Returns: + (use_channels, tip_lengths, batches). + """ + if x_grouping_tolerance is None: + x_grouping_tolerance = self._x_grouping_tolerance_mm + + use_channels = validate_channel_selections( + containers=containers, + num_channels=self.num_channels, + use_channels=use_channels, + ) + + # Verify tips and query tip lengths + tip_presence = await self.request_tip_presence() + if not all(tip_presence[idx] for idx in use_channels): + raise RuntimeError("All specified channels must have tips attached.") + tip_lengths = [await self.request_tip_len_on_channel(channel_idx=idx) for idx in use_channels] + + # Z pre-positioning + idle_channels = sorted(set(range(self.num_channels)) - set(use_channels)) + if min_traverse_height_at_beginning_of_command is not None: + await asyncio.gather( + *[ + self.move_channel_stop_disk_z(channel_idx=ch_idx, z=self.MAXIMUM_CHANNEL_Z_POSITION) + for ch_idx in idle_channels + ] + ) + await self.position_channels_in_z_direction( + {ch: min_traverse_height_at_beginning_of_command for ch in use_channels} + ) + else: + await self.move_all_channels_in_z_safety() + + # Plan batches directly from containers (per-batch spread, no-go-zone aware). + batches = plan_batches( + use_channels=use_channels, + containers=containers, + channel_spacings=self._channels_minimum_y_spacing, + wrt_resource=self.deck, + x_tolerance=x_grouping_tolerance, + resource_offsets=resource_offsets, + ) + + return use_channels, tip_lengths, batches + + async def _run_lld_on_channel_batch( + self, + batch: ChannelBatch, + containers: List[Container], + tip_lengths: List[float], + z_cavity_bottom: List[float], + z_top: List[float], + lld_mode: List[LLDMode], + search_speed: float, + n_replicates: int, + ) -> Dict[int, List[Optional[float]]]: + """Per-batch liquid level detection. Override to substitute simulated sensing. + + *lld_mode* is indexed by original container position (``batch.indices[i]``), + so channels within a single batch may use different detection modes concurrently. + + Returns absolute heights keyed by job index (``batch.indices[i]``), so duplicate + channels across batches don't collide. One list per job, with ``None`` entries for + replicates where no liquid was detected. The caller subtracts ``z_cavity_bottom`` + to get heights relative to container bottom. + """ + + def _detect_func(mode: "STARBackend.LLDMode") -> Callable[..., Any]: + return ( + self._move_z_drive_to_liquid_surface_using_clld + if mode == self.LLDMode.GAMMA + else self._search_for_surface_using_plld + ) + + batch_lowest_immers = [ + z_cavity_bottom[i] + tip_lengths[i] - self.DEFAULT_TIP_FITTING_DEPTH for i in batch.indices + ] + batch_start_pos = [ + z_top[i] + tip_lengths[i] - self.DEFAULT_TIP_FITTING_DEPTH + self.SEARCH_START_CLEARANCE_MM + for i in batch.indices + ] + + measurements: Dict[int, List[Optional[float]]] = {orig_idx: [] for orig_idx in batch.indices} + + for _ in range(n_replicates): + results = await asyncio.gather( + *[ + _detect_func(lld_mode[orig_idx])( + channel_idx=channel, + lowest_immers_pos=lip, + start_pos_search=sps, + channel_speed=search_speed, + ) + for channel, lip, sps, orig_idx in zip( + batch.channels, batch_lowest_immers, batch_start_pos, batch.indices + ) + ], + return_exceptions=True, + ) + + current_absolute_liquid_heights = await self.request_pip_height_last_lld() + for local_idx, (ch_idx, result) in enumerate(zip(batch.channels, results)): + orig_idx = batch.indices[local_idx] + if isinstance(result, STARFirmwareError): + error_msg = str(result).lower() + if "no liquid level found" in error_msg or "no liquid was present" in error_msg: + height = None + msg = ( + f"Channel {ch_idx}: No liquid detected. Could be because there is " + f"no liquid in container {containers[orig_idx].name} or liquid level " + f"is too low." + ) + if lld_mode[orig_idx] == self.LLDMode.GAMMA: + msg += " Consider using pressure-based LLD if liquid is believed to exist." + logger.warning(msg) + else: + raise result + elif isinstance(result, Exception): + raise result + else: + height = current_absolute_liquid_heights[ch_idx] + measurements[orig_idx].append(height) + + return measurements + + async def probe_liquid_heights( + self, + containers: List[Container], + use_channels: Optional[List[int]] = None, + resource_offsets: Optional[List[Coordinate]] = None, + lld_mode: Union[LLDMode, List[LLDMode], None] = None, + search_speed: float = 10.0, + n_replicates: int = 1, + # Traverse height parameters (None = full Z safety, float = absolute Z position in mm) + min_traverse_height_at_beginning_of_command: Optional[float] = None, + min_traverse_height_during_command: Optional[float] = None, + z_position_at_end_of_command: Optional[float] = None, + x_grouping_tolerance: Optional[float] = None, + # Deprecated + move_to_z_safety_after: Optional[bool] = None, + ) -> List[float]: + """Probe liquid surface heights in containers using liquid level detection. + + Performs capacitive or pressure-based liquid level detection (LLD) by moving channels to + container positions and sensing the liquid surface. Heights are measured from the bottom + of each container's cavity. + + Uses ``plan_batches`` for X/Y partitioning with per-batch container spread + (respecting no-go zones), then ``execute_batched`` to iterate batches with + Z safety. + + Args: + containers: List of Container objects to probe, one per channel. + use_channels: Channel indices to use for probing (0-indexed). + resource_offsets: Optional XYZ offsets from container centers. When not provided, + ``plan_batches`` auto-spreads channels targeting the same container. + lld_mode: Detection mode. Either a single ``LLDMode`` applied to all containers + (deprecated, removed in v1b1) or a list of ``LLDMode``s (one per container) + allowing mixed GAMMA/PRESSURE within one call. ``None`` (default) applies + GAMMA to all containers. + search_speed: Z-axis search speed in mm/s. Default 10.0 mm/s. + n_replicates: Number of measurements per channel. Default 1. + min_traverse_height_at_beginning_of_command: Absolute Z height (mm) to move involved + channels to before the first batch. Must clear all deck obstacles since channels + travel laterally at this height. None (default) uses full Z safety. + min_traverse_height_during_command: Absolute Z height (mm) to move involved channels to + between batches. None (default) uses full Z safety. + z_position_at_end_of_command: Absolute Z height (mm) to move involved channels to after + probing. None (default) uses full Z safety. + x_grouping_tolerance: Containers within this X distance (mm) are grouped and probed + together. Defaults to ``_x_grouping_tolerance_mm`` (0.1 mm). + move_to_z_safety_after: Deprecated. Use ``z_position_at_end_of_command`` instead. + Returns: + Mean of measured liquid heights for each container (mm from cavity bottom). + + Raises: + ValueError: If ``use_channels`` is empty, contains out-of-range indices, + or if input list lengths don't match. + RuntimeError: If any specified channel lacks a tip. + """ + + if move_to_z_safety_after is not None: + warnings.warn( + "The 'move_to_z_safety_after' parameter is deprecated and will be removed in a " + "future release. Use 'z_position_at_end_of_command' with an appropriate Z height " + "instead. If not set, the default behavior will be to move to full Z safety after " + "the command.", + DeprecationWarning, + stacklevel=2, + ) + + if n_replicates < 1: + raise ValueError(f"n_replicates must be >= 1, got {n_replicates}.") + + if lld_mode is None: + lld_mode = [self.LLDMode.GAMMA] * len(containers) + elif isinstance(lld_mode, self.LLDMode): + warnings.warn( + "Passing a single LLDMode to probe_liquid_heights is deprecated and will be " + "removed in v1b1. Pass a list of LLDModes (one per container) instead.", + DeprecationWarning, + stacklevel=2, + ) + lld_mode = [lld_mode] * len(containers) + elif not isinstance(lld_mode, list): + raise TypeError(f"lld_mode must be List[LLDMode], got {type(lld_mode).__name__}") + + if len(lld_mode) != len(containers): + raise ValueError( + f"lld_mode list length must match containers: got {len(lld_mode)} LLD modes " + f"for {len(containers)} containers." + ) + for m in lld_mode: + if m not in (self.LLDMode.GAMMA, self.LLDMode.PRESSURE): + raise ValueError(f"Unsupported lld_mode: {m!r}") + + z_cavity_bottom = [ + r.get_location_wrt(self.deck, "c", "c", "cavity_bottom").z for r in containers + ] + z_top = [r.get_location_wrt(self.deck, "c", "c", "t").z for r in containers] + + use_channels, tip_lengths, batches = await self._prepare_batched( + containers=containers, + use_channels=use_channels, + resource_offsets=resource_offsets, + x_grouping_tolerance=x_grouping_tolerance, + min_traverse_height_at_beginning_of_command=min_traverse_height_at_beginning_of_command, + ) + batch_results = await self.execute_batched( + func=lambda b: self._run_lld_on_channel_batch( + batch=b, + containers=containers, + tip_lengths=tip_lengths, + z_cavity_bottom=z_cavity_bottom, + z_top=z_top, + lld_mode=lld_mode, + search_speed=search_speed, + n_replicates=n_replicates, + ), + batches=batches, + min_traverse_height_during_command=min_traverse_height_during_command, + ) + + absolute_heights_measurements: Dict[int, List[Optional[float]]] = {} + for batch_measurements in batch_results: + for orig_idx, heights in batch_measurements.items(): + absolute_heights_measurements.setdefault(orig_idx, []).extend(heights) + + # Compute liquid heights relative to well bottom + relative_to_well: List[float] = [] + inconsistent_channels: List[str] = [] + + for idx, (ch, container) in enumerate(zip(use_channels, containers)): + measurements = absolute_heights_measurements[idx] + valid = [m for m in measurements if m is not None] + cavity_bottom = z_cavity_bottom[idx] + + if len(valid) == 0: + relative_to_well.append(0.0) + elif len(valid) == len(measurements): + relative_to_well.append(sum(valid) / len(valid) - cavity_bottom) + else: + inconsistent_channels.append( + f"Channel {ch}: {len(valid)}/{len(measurements)} replicates detected liquid" + ) + + if inconsistent_channels: + raise RuntimeError( + "Inconsistent liquid detection across replicates. " + "This may indicate liquid levels near the detection limit:\n" + + "\n".join(inconsistent_channels) + ) + + if z_position_at_end_of_command is not None: + await self.position_channels_in_z_direction( + {ch: z_position_at_end_of_command for ch in use_channels} + ) + else: + await self.move_all_channels_in_z_safety() + + return relative_to_well + + async def probe_liquid_volumes( + self, + containers: List[Container], + use_channels: Optional[List[int]] = None, + resource_offsets: Optional[List[Coordinate]] = None, + lld_mode: Union[LLDMode, List[LLDMode], None] = None, + search_speed: float = 10.0, + n_replicates: int = 1, + # Traverse height parameters (None = full Z safety, float = absolute Z position in mm) + min_traverse_height_at_beginning_of_command: Optional[float] = None, + min_traverse_height_during_command: Optional[float] = None, + z_position_at_end_of_command: Optional[float] = None, + x_grouping_tolerance: Optional[float] = None, + # Deprecated + move_to_z_safety_after: Optional[bool] = None, + ) -> List[float]: + """Probe liquid volumes in containers by measuring heights and converting to volumes. + + Performs liquid level detection to measure surface heights, then converts heights to + volumes using each container's geometric model. This is a convenience wrapper around + probe_liquid_heights that handles the height-to-volume conversion. + + Args: + containers: List of Container objects to probe, one per channel. All must support height-to-volume conversion via compute_volume_from_height(). + use_channels: Channel indices to use for probing (0-indexed). None (default) uses channels [0, 1, ..., len(containers)-1]. + resource_offsets: Optional XYZ offsets from container centers. Auto-calculated for single containers with odd channel counts. Defaults to container centers. + lld_mode: Detection mode. Either a single ``LLDMode`` applied to all containers + (deprecated, removed in v1b1) or a list of ``LLDMode``s (one per container). ``None`` + (default) applies GAMMA (capacitive cLLD) to all containers. + search_speed: Z-axis search speed in mm/s. Default 10.0 mm/s. + n_replicates: Number of measurements per channel. Default 1. + min_traverse_height_at_beginning_of_command: Absolute Z height (mm) to move involved + channels to before the first batch. Must clear all deck obstacles since channels + travel laterally at this height. None (default) uses full Z safety. + min_traverse_height_during_command: Absolute Z height (mm) to move involved channels to + between batches. None (default) uses full Z safety. + z_position_at_end_of_command: Absolute Z height (mm) to move involved channels to after + probing. None (default) uses full Z safety. + x_grouping_tolerance: Containers within this X distance (mm) are grouped and probed + together. Defaults to ``_x_grouping_tolerance_mm`` (0.1 mm). + move_to_z_safety_after: Deprecated. Use ``z_position_at_end_of_command`` instead. + + Returns: + Volumes in each container (uL). + + Raises: + ValueError: If any container doesn't support height-to-volume conversion. + + Notes: + - Delegates all motion, LLD, validation, and safety logic to probe_liquid_heights + - All containers must support height-volume functions. Volume calculation uses Container.compute_volume_from_height() + """ + + if move_to_z_safety_after is not None: + warnings.warn( + "The 'move_to_z_safety_after' parameter is deprecated and will be removed in a " + "future release. Use 'z_position_at_end_of_command' with an appropriate Z height " + "instead. If not set, the default behavior will be to move to full Z safety after " + "the command.", + DeprecationWarning, + stacklevel=2, + ) + + if any(not resource.supports_compute_height_volume_functions() for resource in containers): + raise ValueError( + "probe_liquid_volumes can only be used with containers that support height<->volume functions." + ) + + liquid_heights = await self.probe_liquid_heights( + containers=containers, + use_channels=use_channels, + resource_offsets=resource_offsets, + lld_mode=lld_mode, + search_speed=search_speed, + n_replicates=n_replicates, + min_traverse_height_at_beginning_of_command=min_traverse_height_at_beginning_of_command, + min_traverse_height_during_command=min_traverse_height_during_command, + z_position_at_end_of_command=z_position_at_end_of_command, + x_grouping_tolerance=x_grouping_tolerance, + ) + + return [ + container.compute_volume_from_height(height) + for container, height in zip(containers, liquid_heights) + ] + + # # # Granular channel control methods # # # + + DISPENSING_DRIVE_VOL_LIMIT_BOTTOM = -45 # vol TODO: confirm with others + DISPENSING_DRIVE_VOL_LIMIT_TOP = 1_250 # vol + + async def channel_dispensing_drive_request_position(self, channel_idx: int) -> float: + """Request the current position of the channel's dispensing drive""" + + if not (0 <= channel_idx < self.num_channels): + raise ValueError(f"channel_idx must be between 0 and {self.num_channels - 1}") + + resp = await self.send_command( + module=STARBackend.channel_id(channel_idx), command="RD", fmt="rd##### #####" + ) + return STARBackend.dispensing_drive_increment_to_volume(resp["rd"]) + + async def channel_dispensing_drive_move_to_volume_position( + self, + channel_idx: int, + vol: float, + flow_rate: float = 200.0, # uL/sec + acceleration: float = 3000.0, # uL/sec**2, + current_limit: int = 5, + ): + """Move channel's dispensing drive to specified volume position + + Args: + channel_idx: Index of the channel to move (0-indexed). + vol: Target volume position to move the dispensing drive piston to (uL). + flow_rate: Speed of the movement (uL/sec). Default is 200.0 uL/sec. + acceleration: Acceleration of the movement (uL/sec**2). Default is 3000.0 uL/sec**2. + current_limit: Current limit for the drive (1-7). Default is 5. + """ + + if not (self.DISPENSING_DRIVE_VOL_LIMIT_BOTTOM <= vol <= self.DISPENSING_DRIVE_VOL_LIMIT_TOP): + raise ValueError( + f"Target dispensing Drive vol must be between {self.DISPENSING_DRIVE_VOL_LIMIT_BOTTOM}" + f" and {self.DISPENSING_DRIVE_VOL_LIMIT_TOP}, is {vol}" + ) + if not (0.9 <= flow_rate <= 632.8): + raise ValueError( + f"Dispensing drive speed must be between 0.9 and 632.8 uL/sec, is {flow_rate}" + ) + if not (234.4 <= acceleration <= 28125.6): + raise ValueError( + f"Dispensing drive acceleration must be between 234.4 and 28125.6 uL/sec**2, is {acceleration}" + ) + if not (1 <= current_limit <= 7): + raise ValueError( + f"Dispensing drive current limit must be between 1 and 7, is {current_limit}" + ) + + current_position = await self.channel_dispensing_drive_request_position(channel_idx=channel_idx) + relative_vol_movement = round(vol - current_position, 1) + relative_vol_movement_increment = STARBackend.dispensing_drive_vol_to_increment( + abs(relative_vol_movement) + ) + speed_increment = STARBackend.dispensing_drive_vol_to_increment(flow_rate) + acceleration_increment = STARBackend.dispensing_drive_vol_to_increment(acceleration) + acceleration_increment_thousands = round(acceleration_increment * 0.001) + + await self.send_command( + module=STARBackend.channel_id(channel_idx), + command="DS", + ds=f"{relative_vol_movement_increment:05}", + dt="0" if relative_vol_movement >= 0 else "1", + dv=f"{speed_increment:05}", + dr=f"{acceleration_increment_thousands:03}", + dw=f"{current_limit}", + ) + + async def empty_tip( + self, + channel_idx: int, + vol: Optional[float] = None, + flow_rate: float = 200.0, # vol/sec + acceleration: float = 3000.0, # vol/sec**2, + current_limit: int = 5, + reset_dispensing_drive_after: bool = True, + ): + """Empty tip by moving to `vol` (default bottom limit), optionally returning plunger position to 0. + + Args: + channel_idx: Index of the channel to empty (0-indexed). + vol: Target volume position to move the dispensing drive piston to (uL). If None, defaults to bottom limit. + flow_rate: Speed of the movement (uL/sec). Default is 200.0 uL/sec. + acceleration: Acceleration of the movement (uL/sec**2). Default is 3000.0 uL/sec**2. + current_limit: Current limit for the drive (1-7). Default is 5. + reset_dispensing_drive_after: Whether to return the dispensing drive to 0 after emptying. Default is True + """ + + if vol is None: + vol = self.DISPENSING_DRIVE_VOL_LIMIT_BOTTOM + + # Empty tip + await self.channel_dispensing_drive_move_to_volume_position( + channel_idx=channel_idx, + vol=vol, + flow_rate=flow_rate, + acceleration=acceleration, + current_limit=current_limit, + ) + + if reset_dispensing_drive_after: + # Reset only channel used back to vol=0.0 position + await self.channel_dispensing_drive_move_to_volume_position( + channel_idx=channel_idx, + vol=0, + flow_rate=flow_rate, + acceleration=acceleration, + current_limit=current_limit, + ) + + async def empty_tips( + self, + channels: Optional[List[int]] = None, + vol: Optional[float] = None, + flow_rate: float = 200.0, # vol/sec + acceleration: float = 3000.0, # vol/sec**2, + current_limit: int = 5, + reset_dispensing_drive_after: bool = True, + ): + """Empty multiple tips by moving to `vol` (default bottom limit), optionally returning plunger position to 0. + + Args: + channels: List of channel indices to empty (0-indexed). If None, all channels with tips mounted are emptied. + vol: Target volume position to move the dispensing drive piston to (uL). If None, defaults to bottom limit. + flow_rate: Speed of the movement (uL/sec). Default is 200.0 uL/sec. + acceleration: Acceleration of the movement (uL/sec**2). Default is 3000.0 uL/sec**2. + current_limit: Current limit for the drive (1-7). Default is 5. + reset_dispensing_drive_after: Whether to return the dispensing drive to 0 after emptying. Default is True + """ + + if channels is None: + channel_occupancy = await self.request_tip_presence() + channels = [ch for ch, occupied in enumerate(channel_occupancy) if occupied] + else: + # Validate that all provided channels are within valid range + if not all(0 <= ch < self.num_channels for ch in channels): + raise ValueError( + f"channel_idx must be between 0 and {self.num_channels - 1}, got {channels}" + ) + + await asyncio.gather( + *[ + self.empty_tip( + channel_idx=ch, + vol=vol, + flow_rate=flow_rate, + acceleration=acceleration, + current_limit=current_limit, + reset_dispensing_drive_after=reset_dispensing_drive_after, + ) + for ch in channels + ] + ) + + # # # Channel Liquid Handling Commands # # # + + async def aspirate( + self, + ops: List[SingleChannelAspiration], + use_channels: List[int], + jet: Optional[List[bool]] = None, + blow_out: Optional[List[bool]] = None, + lld_search_height: Optional[List[float]] = None, + clot_detection_height: Optional[List[float]] = None, + pull_out_distance_transport_air: Optional[List[float]] = None, + second_section_height: Optional[List[float]] = None, + second_section_ratio: Optional[List[float]] = None, + minimum_height: Optional[List[float]] = None, + immersion_depth: Optional[List[float]] = None, + surface_following_distance: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + pre_wetting_volume: Optional[List[float]] = None, + lld_mode: Optional[List[LLDMode]] = None, + gamma_lld_sensitivity: Optional[List[int]] = None, + dp_lld_sensitivity: Optional[List[int]] = None, + aspirate_position_above_z_touch_off: Optional[List[float]] = None, + detection_height_difference_for_dual_lld: Optional[List[float]] = None, + swap_speed: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + mix_position_from_liquid_surface: Optional[List[float]] = None, + mix_surface_following_distance: Optional[List[float]] = None, + limit_curve_index: Optional[List[int]] = None, + use_2nd_section_aspiration: Optional[List[bool]] = None, + retract_height_over_2nd_section_to_empty_tip: Optional[List[float]] = None, + dispensation_speed_during_emptying_tip: Optional[List[float]] = None, + dosing_drive_speed_during_2nd_section_search: Optional[List[float]] = None, + z_drive_speed_during_2nd_section_search: Optional[List[float]] = None, + cup_upper_edge: Optional[List[float]] = None, + ratio_liquid_rise_to_tip_deep_in: Optional[List[int]] = None, + immersion_depth_2nd_section: Optional[List[float]] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + min_z_endpos: Optional[float] = None, + liquid_surface_no_lld: Optional[List[float]] = None, + # PLR: + probe_liquid_height: bool = False, + auto_surface_following_distance: bool = False, + hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None, + disable_volume_correction: Optional[List[bool]] = None, + # remove >2026-01 + mix_volume: Optional[List[float]] = None, + mix_cycles: Optional[List[int]] = None, + mix_speed: Optional[List[float]] = None, + immersion_depth_direction: Optional[List[int]] = None, + liquid_surfaces_no_lld: Optional[List[float]] = None, + ): + """Aspirate liquid from the specified channels. + + For all parameters where `None` is the default value, STAR will use the default value, based on + the aspirations. For all list parameters, the length of the list must be equal to the number of + operations. + + Args: + ops: The aspiration operations to perform. + use_channels: The channels to use for the operations. + jet: whether to search for a jet liquid class. Only used on dispense. Default is False. + blow_out: whether to blow out air. Only used on dispense. Note that in the VENUS Liquid + Editor, this is called "empty". Default is False. + + lld_search_height: The height to start searching for the liquid level when using LLD. + clot_detection_height: Unknown, but probably the height to search for clots when doing LLD. + pull_out_distance_transport_air: The distance to pull out when aspirating air, if LLD is + disabled. + second_section_height: The height to start the second section of aspiration. + second_section_ratio: + minimum_height: The minimum height to move to, this is the end of aspiration. The channel will move linearly from the liquid surface to this height over the course of the aspiration. + immersion_depth: The z distance to move after detecting the liquid, can be into or away from the liquid surface. + surface_following_distance: The distance to follow the liquid surface. + transport_air_volume: The volume of air to aspirate after the liquid. + pre_wetting_volume: The volume of liquid to use for pre-wetting. + lld_mode: The liquid level detection mode to use. + gamma_lld_sensitivity: The sensitivity of the gamma LLD. + dp_lld_sensitivity: The sensitivity of the DP LLD. + aspirate_position_above_z_touch_off: If the LLD mode is Z_TOUCH_OFF, this is the height above the bottom of the well (presumably) to aspirate from. + detection_height_difference_for_dual_lld: Difference between the gamma and DP LLD heights if the LLD mode is DUAL. + swap_speed: Swap speed (on leaving liquid) [mm/s]. Must be between 3 and 1600. Default 100. + settling_time: The time to wait after mix. + mix_position_from_liquid_surface: The height to aspirate from for mix (LLD or absolute terms). + mix_surface_following_distance: The distance to follow the liquid surface for mix. + limit_curve_index: The index of the limit curve to use. + + use_2nd_section_aspiration: Whether to use the second section of aspiration. + retract_height_over_2nd_section_to_empty_tip: Unknown. + dispensation_speed_during_emptying_tip: Unknown. + dosing_drive_speed_during_2nd_section_search: Unknown. + z_drive_speed_during_2nd_section_search: Unknown. + cup_upper_edge: Unknown. + + minimum_traverse_height_at_beginning_of_a_command: The minimum height to move to before starting an aspiration. + min_z_endpos: The minimum height to move to, this is the end of aspiration. + + hamilton_liquid_classes: Override the default liquid classes. See pylabrobot/liquid_handling/liquid_classes/hamilton/STARBackend.py + liquid_surface_no_lld: Liquid surface at function without LLD [mm]. Must be between 0 and 360. Defaults to well bottom + liquid height. Should use absolute z. + disable_volume_correction: Whether to disable liquid class volume correction for each operation. + + probe_liquid_height: PLR-specific parameter. If True, probe the liquid height using cLLD before aspirating to set the liquid_height of every operation instead of using the default 0. Liquid heights must not be set when using this function. + auto_surface_following_distance: automatically compute the surface following distance based on the container height<->volume functions. Requires liquid height to be specified or `probe_liquid_height=True`. + """ + + # # # TODO: delete > 2026-01 # # # + if mix_volume is not None or mix_cycles is not None or mix_speed is not None: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.aspirate instead. " + "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" + ) + + if immersion_depth_direction is not None: + warnings.warn( + "The immersion_depth_direction parameter is deprecated and will be removed in the future. " + "Use positive values for immersion_depth to move into the liquid, and negative values to move " + "out of the liquid.", + DeprecationWarning, + ) + + if liquid_surfaces_no_lld is not None: + warnings.warn( + "The liquid_surfaces_no_lld parameter is deprecated and will be removed in the future. " + "Use liquid_surface_no_lld instead.", + DeprecationWarning, + ) + liquid_surface_no_lld = liquid_surface_no_lld or liquid_surfaces_no_lld + # # # delete # # # + + self.ensure_can_reach_position(use_channels, ops, "aspirate") + + x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) + + n = len(ops) + + if jet is None: + jet = [False] * n + if blow_out is None: + blow_out = [False] * n + + if hamilton_liquid_classes is None: + hamilton_liquid_classes = [] + for i, op in enumerate(ops): + hamilton_liquid_classes.append( + get_star_liquid_class( + tip_volume=op.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=op.tip.has_filter, + liquid=Liquid.WATER, # default to WATER + jet=jet[i], + blow_out=blow_out[i], + ) + ) + + # correct volumes using the liquid class + disable_volume_correction = fill_in_defaults(disable_volume_correction, [False] * n) + volumes = [ + hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume + for op, hlc, disabled in zip(ops, hamilton_liquid_classes, disable_volume_correction) + ] + + well_bottoms = [ + op.resource.get_location_wrt(self.deck).z + op.offset.z + op.resource.material_z_thickness + for op in ops + ] + if lld_search_height is None: + lld_search_height = [ + ( + wb + op.resource.get_absolute_size_z() + (2.7 if isinstance(op.resource, Well) else 5) + ) # ? + for wb, op in zip(well_bottoms, ops) + ] + else: + lld_search_height = [(wb + sh) for wb, sh in zip(well_bottoms, lld_search_height)] + clot_detection_height = fill_in_defaults( + clot_detection_height, + default=[ + hlc.aspiration_clot_retract_height if hlc is not None else 0.0 + for hlc in hamilton_liquid_classes + ], + ) + pull_out_distance_transport_air = fill_in_defaults(pull_out_distance_transport_air, [10] * n) + second_section_height = fill_in_defaults(second_section_height, [3.2] * n) + second_section_ratio = fill_in_defaults(second_section_ratio, [618.0] * n) + minimum_height = fill_in_defaults(minimum_height, well_bottoms) + if immersion_depth is None: + immersion_depth = [0.0] * n + immersion_depth_direction = immersion_depth_direction or [ + 0 if (id_ >= 0) else 1 for id_ in immersion_depth + ] + immersion_depth = [ + im * (-1 if immersion_depth_direction[i] else 1) for i, im in enumerate(immersion_depth) + ] + flow_rates = [ + op.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 100.0) + for op, hlc in zip(ops, hamilton_liquid_classes) + ] + transport_air_volume = fill_in_defaults( + transport_air_volume, + default=[ + hlc.aspiration_air_transport_volume if hlc is not None else 0.0 + for hlc in hamilton_liquid_classes + ], + ) + blow_out_air_volumes = [ + (op.blow_out_air_volume or (hlc.aspiration_blow_out_volume if hlc is not None else 0.0)) + for op, hlc in zip(ops, hamilton_liquid_classes) + ] + pre_wetting_volume = fill_in_defaults(pre_wetting_volume, [0.0] * n) + lld_mode = fill_in_defaults(lld_mode, [self.__class__.LLDMode.OFF] * n) + gamma_lld_sensitivity = fill_in_defaults(gamma_lld_sensitivity, [1] * n) + dp_lld_sensitivity = fill_in_defaults(dp_lld_sensitivity, [1] * n) + aspirate_position_above_z_touch_off = fill_in_defaults( + aspirate_position_above_z_touch_off, [0.0] * n + ) + detection_height_difference_for_dual_lld = fill_in_defaults( + detection_height_difference_for_dual_lld, [0.0] * n + ) + swap_speed = fill_in_defaults( + swap_speed, + default=[ + hlc.aspiration_swap_speed if hlc is not None else 100.0 for hlc in hamilton_liquid_classes + ], + ) + settling_time = fill_in_defaults( + settling_time, + default=[ + hlc.aspiration_settling_time if hlc is not None else 0.0 for hlc in hamilton_liquid_classes + ], + ) + mix_volume = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_position_from_liquid_surface = fill_in_defaults(mix_position_from_liquid_surface, [0.0] * n) + mix_speed = [op.mix.flow_rate if op.mix is not None else 100.0 for op in ops] + mix_surface_following_distance = fill_in_defaults(mix_surface_following_distance, [0.0] * n) + limit_curve_index = fill_in_defaults(limit_curve_index, [0] * n) + + use_2nd_section_aspiration = fill_in_defaults(use_2nd_section_aspiration, [False] * n) + retract_height_over_2nd_section_to_empty_tip = fill_in_defaults( + retract_height_over_2nd_section_to_empty_tip, [0.0] * n + ) + dispensation_speed_during_emptying_tip = fill_in_defaults( + dispensation_speed_during_emptying_tip, [50.0] * n + ) + dosing_drive_speed_during_2nd_section_search = fill_in_defaults( + dosing_drive_speed_during_2nd_section_search, [50.0] * n + ) + z_drive_speed_during_2nd_section_search = fill_in_defaults( + z_drive_speed_during_2nd_section_search, [30.0] * n + ) + cup_upper_edge = fill_in_defaults(cup_upper_edge, [0.0] * n) + + # Deprecated params - warn if passed, but don't use them + if ratio_liquid_rise_to_tip_deep_in is not None: + warnings.warn( + "ratio_liquid_rise_to_tip_deep_in is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + if immersion_depth_2nd_section is not None: + warnings.warn( + "immersion_depth_2nd_section is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + + if probe_liquid_height: + if any(op.liquid_height is not None for op in ops): + raise ValueError("Cannot use probe_liquid_height when liquid heights are set.") + + liquid_heights = await self.probe_liquid_heights( + containers=[op.resource for op in ops], + use_channels=use_channels, + resource_offsets=[op.offset for op in ops], + z_position_at_end_of_command=100, + ) + + # override minimum traversal height because we don't want to move channels up. we are already above the liquid. + minimum_traverse_height_at_beginning_of_a_command = 100 + logger.info(f"Detected liquid heights: {liquid_heights}") + else: + liquid_heights = [op.liquid_height or 0 for op in ops] + + liquid_surfaces_no_lld = liquid_surface_no_lld or [ + wb + lh for wb, lh in zip(well_bottoms, liquid_heights) + ] + + if auto_surface_following_distance: + if any(op.liquid_height is None for op in ops) and not probe_liquid_height: + raise ValueError( + "To use auto_surface_following_distance all liquid heights must be set or probe_liquid_height must be True." + ) + + if any(not op.resource.supports_compute_height_volume_functions() for op in ops): + raise ValueError( + "automatic_surface_following can only be used with containers that support height<->volume functions." + ) + + current_volumes = [ + op.resource.compute_volume_from_height(liquid_heights[i]) for i, op in enumerate(ops) + ] + + # compute new liquid_height after aspiration + liquid_height_after_aspiration = [ + op.resource.compute_height_from_volume(current_volumes[i] - op.volume) + for i, op in enumerate(ops) + ] + + # compute new surface_following_distance + surface_following_distance = [ + liquid_heights[i] - liquid_height_after_aspiration[i] + for i in range(len(liquid_height_after_aspiration)) + ] + else: + surface_following_distance = fill_in_defaults(surface_following_distance, [0.0] * n) + + # check if the surface_following_distance would fall below the minimum height + # if lld is enabled, we expect to find liquid above the well bottom so we don't need to raise an error + if any( + ( + well_bottoms[i] + liquid_heights[i] - surface_following_distance[i] - minimum_height[i] + < -1e-6 + ) + and lld_mode[i] == STARBackend.LLDMode.OFF + for i in range(n) + ): + raise ValueError( + f"surface_following_distance would result in a height that goes below the minimum_height. " + f"Well bottom: {well_bottoms}, liquid height: {liquid_heights}, surface_following_distance: {surface_following_distance}, minimum_height: {minimum_height}" + ) + + # A tip fills to one of two transient peaks that never coexist: volume + pre_wetting + # (pre-wet over-aspiration) and volume + transport_air (air gap drawn above the liquid); + # blow-out air counts toward neither. + over_capacity = [] + for i, op in enumerate(ops): + for label, extra in ( + ("pre-wetting", pre_wetting_volume[i]), + ("transport-air", transport_air_volume[i]), + ): + peak = volumes[i] + extra + if peak > op.tip.maximal_volume: + over_capacity.append((i, label, peak, op.tip.maximal_volume)) + if over_capacity: + raise ValueError( + "Aspiration would exceed tip capacity: " + + "; ".join( + f"channel {i} {label} peak {peak:.1f} uL > tip maximal volume {tip_max:.1f} uL" + for i, label, peak, tip_max in over_capacity + ) + ) + + try: + return await self.aspirate_pip( + aspiration_type=[0 for _ in range(n)], + tip_pattern=channels_involved, + x_positions=x_positions, + y_positions=y_positions, + aspiration_volumes=[round(vol * 10) for vol in volumes], + lld_search_height=[round(lsh * 10) for lsh in lld_search_height], + clot_detection_height=[round(cd * 10) for cd in clot_detection_height], + liquid_surface_no_lld=[round(ls * 10) for ls in liquid_surfaces_no_lld], + pull_out_distance_transport_air=[round(po * 10) for po in pull_out_distance_transport_air], + second_section_height=[round(sh * 10) for sh in second_section_height], + second_section_ratio=[round(sr * 10) for sr in second_section_ratio], + minimum_height=[round(mh * 10) for mh in minimum_height], + immersion_depth=[round(id_ * 10) for id_ in immersion_depth], + immersion_depth_direction=immersion_depth_direction, + surface_following_distance=[round(sfd * 10) for sfd in surface_following_distance], + aspiration_speed=[round(fr * 10) for fr in flow_rates], + transport_air_volume=[round(tav * 10) for tav in transport_air_volume], + blow_out_air_volume=[round(boa * 10) for boa in blow_out_air_volumes], + pre_wetting_volume=[round(pwv * 10) for pwv in pre_wetting_volume], + lld_mode=[mode.value for mode in lld_mode], + gamma_lld_sensitivity=gamma_lld_sensitivity, + dp_lld_sensitivity=dp_lld_sensitivity, + aspirate_position_above_z_touch_off=[ + round(ap * 10) for ap in aspirate_position_above_z_touch_off + ], + detection_height_difference_for_dual_lld=[ + round(dh * 10) for dh in detection_height_difference_for_dual_lld + ], + swap_speed=[round(ss * 10) for ss in swap_speed], + settling_time=[round(st * 10) for st in settling_time], + mix_volume=[round(hv * 10) for hv in mix_volume], + mix_cycles=mix_cycles, + mix_position_from_liquid_surface=[ + round(hp * 10) for hp in mix_position_from_liquid_surface + ], + mix_speed=[round(hs * 10) for hs in mix_speed], + mix_surface_following_distance=[round(hsd * 10) for hsd in mix_surface_following_distance], + limit_curve_index=limit_curve_index, + use_2nd_section_aspiration=use_2nd_section_aspiration, + retract_height_over_2nd_section_to_empty_tip=[ + round(rh * 10) for rh in retract_height_over_2nd_section_to_empty_tip + ], + dispensation_speed_during_emptying_tip=[ + round(ds * 10) for ds in dispensation_speed_during_emptying_tip + ], + dosing_drive_speed_during_2nd_section_search=[ + round(ds * 10) for ds in dosing_drive_speed_during_2nd_section_search + ], + z_drive_speed_during_2nd_section_search=[ + round(zs * 10) for zs in z_drive_speed_during_2nd_section_search + ], + cup_upper_edge=[round(cue * 10) for cue in cup_upper_edge], + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 + ), + min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10), + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise e + + async def dispense( + self, + ops: List[SingleChannelDispense], + use_channels: List[int], + lld_search_height: Optional[List[float]] = None, + liquid_surface_no_lld: Optional[List[float]] = None, + pull_out_distance_transport_air: Optional[List[float]] = None, + second_section_height: Optional[List[float]] = None, + second_section_ratio: Optional[List[float]] = None, + minimum_height: Optional[List[float]] = None, + immersion_depth: Optional[List[float]] = None, + surface_following_distance: Optional[List[float]] = None, + cut_off_speed: Optional[List[float]] = None, + stop_back_volume: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + lld_mode: Optional[List[LLDMode]] = None, + dispense_position_above_z_touch_off: Optional[List[float]] = None, + gamma_lld_sensitivity: Optional[List[int]] = None, + dp_lld_sensitivity: Optional[List[int]] = None, + swap_speed: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + mix_position_from_liquid_surface: Optional[List[float]] = None, + mix_surface_following_distance: Optional[List[float]] = None, + limit_curve_index: Optional[List[int]] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[int] = None, + min_z_endpos: Optional[float] = None, + side_touch_off_distance: float = 0, + jet: Optional[List[bool]] = None, + blow_out: Optional[List[bool]] = None, # "empty" in the VENUS liquid editor + empty: Optional[List[bool]] = None, # truly "empty", does not exist in liquid editor, dm4 + # PLR specific + probe_liquid_height: bool = False, + auto_surface_following_distance: bool = False, + hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None, + disable_volume_correction: Optional[List[bool]] = None, + # remove in the future + immersion_depth_direction: Optional[List[int]] = None, + mix_volume: Optional[List[float]] = None, + mix_cycles: Optional[List[int]] = None, + mix_speed: Optional[List[float]] = None, + dispensing_mode: Optional[List[int]] = None, + ): + """Dispense liquid from the specified channels. + + For all parameters where `None` is the default value, STAR will use the default value, based on + the dispenses. For all list parameters, the length of the list must be equal to the number of + operations. + + Args: + ops: The dispense operations to perform. + use_channels: The channels to use for the dispense operations. + lld_search_height: The height to start searching for the liquid level when using LLD. + liquid_surface_no_lld: Liquid surface at function without LLD. + pull_out_distance_transport_air: The distance to pull out the tip for aspirating transport air if LLD is disabled. + second_section_height: The height of the second section. + second_section_ratio: The ratio of [the bottom of the container * 10000] / [the height top of the container]. + minimum_height: The minimum height at the end of the dispense. + immersion_depth: The distance above or below to liquid level to start dispensing. + surface_following_distance: The distance to follow the liquid surface. + cut_off_speed: Unknown. + stop_back_volume: Unknown. + transport_air_volume: The volume of air to dispense before dispensing the liquid. + lld_mode: The liquid level detection mode to use. + dispense_position_above_z_touch_off: The height to move after LLD mode found the Z touch off + position. + gamma_lld_sensitivity: The gamma LLD sensitivity. (1 = high, 4 = low) + dp_lld_sensitivity: The dp LLD sensitivity. (1 = high, 4 = low) + swap_speed: Swap speed (on leaving liquid) [mm/s]. Must be between 3 and 1600. Default 100. + settling_time: The settling time. + mix_position_from_liquid_surface: The height to move above the liquid surface for + mix. + mix_surface_following_distance: The distance to follow the liquid surface for mix. + limit_curve_index: The limit curve to use for the dispense. + minimum_traverse_height_at_beginning_of_a_command: The minimum height to move to before + starting a dispense. + min_z_endpos: The minimum height to move to after a dispense. + side_touch_off_distance: The distance to move to the side from the well for a dispense. + + hamilton_liquid_classes: Override the default liquid classes. See + pylabrobot/liquid_handling/liquid_classes/hamilton/STARBackend.py + disable_volume_correction: Whether to disable liquid class volume correction for each operation. + + jet: Whether to use jetting for each dispense. Defaults to `False` for all. Used for + determining the dispense mode. True for dispense mode 0 or 1. + blow_out: Whether to use "blow out" dispense mode for each dispense. Defaults to `False` for + all. This is labelled as "empty" in the VENUS liquid editor, but "blow out" in the firmware + documentation. True for dispense mode 1 or 3. + empty: Whether to use "empty" dispense mode for each dispense. Defaults to `False` for all. + Truly empty the tip, not available in the VENUS liquid editor, but is in the firmware + documentation. Dispense mode 4. + + probe_liquid_height: PLR-specific parameter. If True, probe the liquid height using cLLD before aspirating to set the liquid_height of every operation instead of using the default 0. Liquid heights must not be set when using this function. + auto_surface_following_distance: automatically compute the surface following distance based on the container height<->volume functions. Requires liquid height to be specified or `probe_liquid_height=True`. + """ + + self.ensure_can_reach_position(use_channels, ops, "dispense") + + n = len(ops) + + if jet is None: + jet = [False] * n + if empty is None: + empty = [False] * n + if blow_out is None: + blow_out = [False] * n + + # # # TODO: delete > 2026-01 # # # + if mix_volume is not None or mix_cycles is not None or mix_speed is not None: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense instead. " + "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" + ) + + if immersion_depth_direction is not None: + warnings.warn( + "The immersion_depth_direction parameter is deprecated and will be removed in the future. " + "Use positive values for immersion_depth to move into the liquid, and negative values to move " + "out of the liquid.", + DeprecationWarning, + ) + + if dispensing_mode is not None: + warnings.warn( + "The dispensing_mode parameter is deprecated and will be removed in the future. " + "Use the jet, blow_out and empty parameters instead. " + "dispensing_mode currently supersedes the other three parameters if both are provided.", + DeprecationWarning, + ) + dispensing_modes = dispensing_mode + else: + dispensing_modes = [ + _dispensing_mode_for_op(empty=empty[i], jet=jet[i], blow_out=blow_out[i]) + for i in range(len(ops)) + ] + # # # delete # # # + + x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) + + if hamilton_liquid_classes is None: + hamilton_liquid_classes = [] + for i, op in enumerate(ops): + hamilton_liquid_classes.append( + get_star_liquid_class( + tip_volume=op.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=op.tip.has_filter, + liquid=Liquid.WATER, # default to WATER + jet=jet[i], + blow_out=blow_out[i], + ) + ) + + # correct volumes using the liquid class + disable_volume_correction = fill_in_defaults(disable_volume_correction, [False] * n) + volumes = [ + hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume + for op, hlc, disabled in zip(ops, hamilton_liquid_classes, disable_volume_correction) + ] + + well_bottoms = [ + op.resource.get_location_wrt(self.deck).z + op.offset.z + op.resource.material_z_thickness + for op in ops + ] + if lld_search_height is None: + lld_search_height = [ + ( + wb + op.resource.get_absolute_size_z() + (2.7 if isinstance(op.resource, Well) else 5) + ) # ? + for wb, op in zip(well_bottoms, ops) + ] + else: + lld_search_height = [wb + sh for wb, sh in zip(well_bottoms, lld_search_height)] + + pull_out_distance_transport_air = fill_in_defaults(pull_out_distance_transport_air, [10.0] * n) + second_section_height = fill_in_defaults(second_section_height, [3.2] * n) + second_section_ratio = fill_in_defaults(second_section_ratio, [618.0] * n) + minimum_height = fill_in_defaults(minimum_height, well_bottoms) + if immersion_depth is None: + immersion_depth = [0.0] * n + immersion_depth_direction = immersion_depth_direction or [ + 0 if (id_ >= 0) else 1 for id_ in immersion_depth + ] + immersion_depth = [ + im * (-1 if immersion_depth_direction[i] else 1) for i, im in enumerate(immersion_depth) + ] + flow_rates = [ + op.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 120.0) + for op, hlc in zip(ops, hamilton_liquid_classes) + ] + cut_off_speed = fill_in_defaults(cut_off_speed, [5.0] * n) + stop_back_volume = fill_in_defaults( + stop_back_volume, + default=[ + hlc.dispense_stop_back_volume if hlc is not None else 0.0 for hlc in hamilton_liquid_classes + ], + ) + transport_air_volume = fill_in_defaults( + transport_air_volume, + default=[ + hlc.dispense_air_transport_volume if hlc is not None else 0.0 + for hlc in hamilton_liquid_classes + ], + ) + blow_out_air_volumes = [ + (op.blow_out_air_volume or (hlc.dispense_blow_out_volume if hlc is not None else 0.0)) + for op, hlc in zip(ops, hamilton_liquid_classes) + ] + lld_mode = fill_in_defaults(lld_mode, [self.__class__.LLDMode.OFF] * n) + dispense_position_above_z_touch_off = fill_in_defaults( + dispense_position_above_z_touch_off, default=[0] * n + ) + gamma_lld_sensitivity = fill_in_defaults(gamma_lld_sensitivity, [1] * n) + dp_lld_sensitivity = fill_in_defaults(dp_lld_sensitivity, [1] * n) + swap_speed = fill_in_defaults( + swap_speed, + default=[ + hlc.dispense_swap_speed if hlc is not None else 10.0 for hlc in hamilton_liquid_classes + ], + ) + settling_time = fill_in_defaults( + settling_time, + default=[ + hlc.dispense_settling_time if hlc is not None else 0.0 for hlc in hamilton_liquid_classes + ], + ) + mix_volume = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_position_from_liquid_surface = fill_in_defaults(mix_position_from_liquid_surface, [0.0] * n) + mix_speed = [op.mix.flow_rate if op.mix is not None else 1.0 for op in ops] + mix_surface_following_distance = fill_in_defaults(mix_surface_following_distance, [0.0] * n) + limit_curve_index = fill_in_defaults(limit_curve_index, [0] * n) + + if probe_liquid_height: + if any(op.liquid_height is not None for op in ops): + raise ValueError("Cannot use probe_liquid_height when liquid heights are set.") + + liquid_heights = await self.probe_liquid_heights( + containers=[op.resource for op in ops], + use_channels=use_channels, + resource_offsets=[op.offset for op in ops], + z_position_at_end_of_command=100, + ) + + # override minimum traversal height because we don't want to move channels up. we are already above the liquid. + minimum_traverse_height_at_beginning_of_a_command = 100 + logger.info(f"Detected liquid heights: {liquid_heights}") + else: + liquid_heights = [op.liquid_height or 0 for op in ops] + + if auto_surface_following_distance: + if any(op.liquid_height is None for op in ops) and not probe_liquid_height: + raise ValueError( + "To use auto_surface_following_distance all liquid heights must be set or probe_liquid_height must be True." + ) + + if any(not op.resource.supports_compute_height_volume_functions() for op in ops): + raise ValueError( + "automatic_surface_following can only be used with containers that support height<->volume functions." + ) + + current_volumes = [ + op.resource.compute_volume_from_height(liquid_heights[i]) for i, op in enumerate(ops) + ] + + # compute new liquid_height after aspiration + liquid_height_after_aspiration = [ + op.resource.compute_height_from_volume(current_volumes[i] + op.volume) + for i, op in enumerate(ops) + ] + + # compute new surface_following_distance + surface_following_distance = [ + liquid_height_after_aspiration[i] - liquid_heights[i] + for i in range(len(liquid_height_after_aspiration)) + ] + else: + surface_following_distance = fill_in_defaults(surface_following_distance, [0.0] * n) + + liquid_surfaces_no_lld = liquid_surface_no_lld or [ + wb + lh for wb, lh in zip(well_bottoms, liquid_heights) + ] + + try: + ret = await self.dispense_pip( + tip_pattern=channels_involved, + x_positions=x_positions, + y_positions=y_positions, + dispensing_mode=dispensing_modes, + dispense_volumes=[round(vol * 10) for vol in volumes], + lld_search_height=[round(lsh * 10) for lsh in lld_search_height], + liquid_surface_no_lld=[round(ls * 10) for ls in liquid_surfaces_no_lld], + pull_out_distance_transport_air=[round(po * 10) for po in pull_out_distance_transport_air], + second_section_height=[round(sh * 10) for sh in second_section_height], + second_section_ratio=[round(sr * 10) for sr in second_section_ratio], + minimum_height=[round(mh * 10) for mh in minimum_height], + immersion_depth=[round(id_ * 10) for id_ in immersion_depth], + immersion_depth_direction=immersion_depth_direction, + surface_following_distance=[round(sfd * 10) for sfd in surface_following_distance], + dispense_speed=[round(fr * 10) for fr in flow_rates], + cut_off_speed=[round(cs * 10) for cs in cut_off_speed], + stop_back_volume=[round(sbv * 10) for sbv in stop_back_volume], + transport_air_volume=[round(tav * 10) for tav in transport_air_volume], + blow_out_air_volume=[round(boa * 10) for boa in blow_out_air_volumes], + lld_mode=[mode.value for mode in lld_mode], + dispense_position_above_z_touch_off=[ + round(dp * 10) for dp in dispense_position_above_z_touch_off + ], + gamma_lld_sensitivity=gamma_lld_sensitivity, + dp_lld_sensitivity=dp_lld_sensitivity, + swap_speed=[round(ss * 10) for ss in swap_speed], + settling_time=[round(st * 10) for st in settling_time], + mix_volume=[round(mv * 10) for mv in mix_volume], + mix_cycles=mix_cycles, + mix_position_from_liquid_surface=[ + round(mp * 10) for mp in mix_position_from_liquid_surface + ], + mix_speed=[round(ms * 10) for ms in mix_speed], + mix_surface_following_distance=[ + round(msfd * 10) for msfd in mix_surface_following_distance + ], + limit_curve_index=limit_curve_index, + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 + ), + min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10), + side_touch_off_distance=round(side_touch_off_distance * 10), + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise e + + return ret + + @_requires_head96 + async def pick_up_tips96( + self, + pickup: PickupTipRack, + tip_pickup_method: Literal["from_rack", "from_waste", "full_blowout"] = "from_rack", + minimum_height_command_end: Optional[float] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + experimental_alignment_tipspot_identifier: str = "A1", + ): + """Pick up tips using the 96 head. + + `tip_pickup_method` can be one of the following: + - "from_rack": standard tip pickup from a tip rack. this moves the plunger all the way down before mounting tips. + - "from_waste": + 1. it actually moves the plunger all the way up + 2. mounts tips + 3. moves up like 10mm + 4. moves plunger all the way down + 5. moves to traversal height (tips out of rack) + - "full_blowout": + 1. it actually moves the plunger all the way up + 2. mounts tips + 3. moves to traversal height (tips out of rack) + + Args: + pickup: The standard `PickupTipRack` operation. + tip_pickup_method: The method to use for picking up tips. One of "from_rack", "from_waste", "full_blowout". + minimum_height_command_end: The minimum height to move to at the end of the command. + minimum_traverse_height_at_beginning_of_a_command: The minimum height to move to at the beginning of the command. + experimental_alignment_tipspot_identifier: The tipspot to use for alignment with head's A1 channel. Defaults to "tipspot A1". allowed range is A1 to H12. + """ + + if isinstance(tip_pickup_method, int): + warnings.warn( + "tip_pickup_method as int is deprecated and will be removed in the future. Use string literals instead.", + DeprecationWarning, + ) + tip_pickup_method = {0: "from_rack", 1: "from_waste", 2: "full_blowout"}[tip_pickup_method] + + if tip_pickup_method not in {"from_rack", "from_waste", "full_blowout"}: + raise ValueError(f"Invalid tip_pickup_method: '{tip_pickup_method}'.") + + prototypical_tip = next((tip for tip in pickup.tips if tip is not None), None) + if prototypical_tip is None: + raise ValueError("No tips found in the tip rack.") + if not isinstance(prototypical_tip, HamiltonTip): + raise TypeError("Tip type must be HamiltonTip.") + + ttti = await self.get_or_assign_tip_type_index(prototypical_tip) + + tip_length = prototypical_tip.total_tip_length + fitting_depth = prototypical_tip.fitting_depth + tip_engage_height_from_tipspot = tip_length - fitting_depth + + # Adjust tip engage height based on tip size + if prototypical_tip.tip_size == TipSize.LOW_VOLUME: + tip_engage_height_from_tipspot += 2 + elif prototypical_tip.tip_size != TipSize.STANDARD_VOLUME: + tip_engage_height_from_tipspot -= 2 + + # Compute pickup Z + alignment_tipspot = pickup.resource.get_item(experimental_alignment_tipspot_identifier) + tip_spot_z = alignment_tipspot.get_location_wrt(self.deck).z + pickup.offset.z + z_pickup_position = tip_spot_z + tip_engage_height_from_tipspot + + # Compute full position (used for x/y) + pickup_position = ( + alignment_tipspot.get_location_wrt(self.deck) + alignment_tipspot.center() + pickup.offset + ) + pickup_position.z = round(z_pickup_position, 2) + + self._check_96_position_legal(pickup_position, skip_z=True) + + if tip_pickup_method == "from_rack": + # the STAR will not automatically move the dispensing drive down if it is still up + # so we need to move it down here + # see https://github.com/PyLabRobot/pylabrobot/pull/835 + lowest_dispensing_drive_height_no_tips = 218.19 + await self.head96_dispensing_drive_move_to_position(lowest_dispensing_drive_height_no_tips) + + try: + await self.pick_up_tips_core96( + x_position=abs(round(pickup_position.x * 10)), + x_direction=0 if pickup_position.x >= 0 else 1, + y_position=round(pickup_position.y * 10), + tip_type_idx=ttti, + tip_pickup_method={ + "from_rack": 0, + "from_waste": 1, + "full_blowout": 2, + }[tip_pickup_method], + z_deposit_position=round(pickup_position.z * 10), + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 + ), + minimum_height_command_end=round( + (minimum_height_command_end or self._channel_traversal_height) * 10 + ), + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise e + + @_requires_head96 + async def drop_tips96( + self, + drop: DropTipRack, + minimum_height_command_end: Optional[float] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + experimental_alignment_tipspot_identifier: str = "A1", + ): + """Drop tips from the 96 head.""" + + if isinstance(drop.resource, TipRack): + tip_spot_a1 = drop.resource.get_item(experimental_alignment_tipspot_identifier) + position = tip_spot_a1.get_location_wrt(self.deck) + tip_spot_a1.center() + drop.offset + tip_rack = tip_spot_a1.parent + assert tip_rack is not None + position.z = tip_rack.get_location_wrt(self.deck).z + 1.45 + # This should be the case for all normal hamilton tip carriers + racks + # In the future, we might want to make this more flexible + assert abs(position.z - 216.4) < 1e-6, f"z position must be 216.4, got {position.z}" + else: + position = self._position_96_head_in_resource(drop.resource) + drop.offset + + self._check_96_position_legal(position, skip_z=True) + + x_direction = 0 if position.x >= 0 else 1 + + return await self.discard_tips_core96( + x_position=abs(round(position.x * 10)), + x_direction=x_direction, + y_position=round(position.y * 10), + z_deposit_position=round(position.z * 10), + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 + ), + minimum_height_command_end=round( + (minimum_height_command_end or self._channel_traversal_height) * 10 + ), + ) + + def _is_core96_slave_timeout(self, error: STARFirmwareError) -> bool: + """Check if a firmware error is a slave command timeout from the CoRe 96 head. + + The firmware master has an internal ~5 minute timeout for slave commands. For slow liquid + handling operations (e.g. large volumes at low flow rates), the master may report a timeout + error even though the CoRe 96 head is still working and will finish successfully. + + The error looks like: C0EAid####er99/00 H002/11 + H0 error_code=02 (HardwareError), trace_information=11 (not a standard H0 code, but the + master's "Slave command time out" forwarded to the H0 module). + """ + h0_error = error.errors.get("CoRe 96 Head") + return ( + h0_error is not None + and isinstance(h0_error, HardwareError) + and h0_error.trace_information == 11 + ) + + async def _core96_wait_for_idle(self, timeout: float = 600, poll_interval: float = 5): + """Poll the CoRe 96 head until it finishes its current operation. + + Sends the "move to Z safety" command (C0 EV). If the head is busy, the firmware rejects + with H0 CommandSyntaxError trace 40 ("No parallel processes permitted"). When the head + finishes, EV succeeds and harmlessly ensures the Z axis is at the safe position. + """ + start = asyncio.get_event_loop().time() + while asyncio.get_event_loop().time() - start < timeout: + await asyncio.sleep(poll_interval) + try: + await self.send_command(module="C0", command="EV", read_timeout=10) + logger.info("CoRe 96 head finished (EV succeeded)") + return + except STARFirmwareError as e: + h0_error = e.errors.get("CoRe 96 Head") + if ( + h0_error is not None + and isinstance(h0_error, CommandSyntaxError) + and h0_error.trace_information == 40 + ): + logger.debug("CoRe 96 head still busy, waiting...") + continue + raise + raise TimeoutError("CoRe 96 head did not become idle within timeout") + + @_requires_head96 + async def aspirate96( + self, + aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer], + jet: bool = False, + blow_out: bool = False, + use_lld: bool = False, + pull_out_distance_transport_air: float = 10, + hlc: Optional[HamiltonLiquidClass] = None, + aspiration_type: int = 0, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + min_z_endpos: Optional[float] = None, + lld_search_height: float = 199.9, + minimum_height: Optional[float] = None, + second_section_height: float = 3.2, + second_section_ratio: float = 618.0, + immersion_depth: float = 0, + surface_following_distance: float = 0, + transport_air_volume: float = 5.0, + pre_wetting_volume: float = 5.0, + gamma_lld_sensitivity: int = 1, + swap_speed: float = 2.0, + settling_time: float = 1.0, + mix_position_from_liquid_surface: float = 0, + mix_surface_following_distance: float = 0, + limit_curve_index: int = 0, + disable_volume_correction: bool = False, + # Deprecated parameters, to be removed in future versions + # rm: >2026-01 + liquid_surface_sink_distance_at_the_end_of_aspiration: float = 0, + minimal_end_height: Optional[float] = None, + air_transport_retract_dist: Optional[float] = None, + maximum_immersion_depth: Optional[float] = None, + surface_following_distance_during_mix: float = 0, + tube_2nd_section_height_measured_from_zm: float = 3.2, + tube_2nd_section_ratio: float = 618.0, + immersion_depth_direction: Optional[int] = None, + mix_volume: float = 0, + mix_cycles: int = 0, + speed_of_mix: float = 0.0, + ): + """Aspirate using the Core96 head. + + Args: + aspiration: The aspiration to perform. + + jet: Whether to search for a jet liquid class. Only used on dispense. + blow_out: Whether to use "blow out" dispense mode. Only used on dispense. Note that this is + labelled as "empty" in the VENUS liquid editor, but "blow out" in the firmware + documentation. + hlc: The Hamiltonian liquid class to use. If `None`, the liquid class will be determined + automatically. + + use_lld: If True, use gamma liquid level detection. If False, use liquid height. + pull_out_distance_transport_air: The distance to retract after aspirating, in millimeters. + + aspiration_type: The type of aspiration to perform. (0 = simple; 1 = sequence; 2 = cup emptied) + minimum_traverse_height_at_beginning_of_a_command: The minimum height to move to before + starting the command. + min_z_endpos: The minimum height to move to after the command. + lld_search_height: The height to search for the liquid level. + minimum_height: Minimum height (maximum immersion depth) + second_section_height: Height of the second section. + second_section_ratio: Ratio of [the diameter of the bottom * 10000] / [the diameter of the top] + immersion_depth: The immersion depth above or below the liquid level. + surface_following_distance: The distance to follow the liquid surface when aspirating. + transport_air_volume: The volume of air to aspirate after the liquid. + pre_wetting_volume: The volume of liquid to use for pre-wetting. + gamma_lld_sensitivity: The sensitivity of the gamma liquid level detection. + swap_speed: Swap speed (on leaving liquid) [1mm/s]. Must be between 0.3 and 160. Default 2. + settling_time: The time to wait after aspirating. + mix_position_from_liquid_surface: The position of the mix from the liquid surface. + mix_surface_following_distance: The distance to follow the liquid surface during mix. + limit_curve_index: The index of the limit curve to use. + disable_volume_correction: Whether to disable liquid class volume correction. + """ + + # # # TODO: delete > 2026-01 # # # + if mix_volume != 0 or mix_cycles != 0 or speed_of_mix != 0: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.aspirate96 instead. " + "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" + ) + + if immersion_depth_direction is not None: + warnings.warn( + "The immersion_depth_direction parameter is deprecated and will be removed in the future. " + "Use positive values for immersion_depth to move into the liquid, and negative values to move " + "out of the liquid.", + DeprecationWarning, + ) + + if liquid_surface_sink_distance_at_the_end_of_aspiration != 0: + surface_following_distance = liquid_surface_sink_distance_at_the_end_of_aspiration + warnings.warn( + "The liquid_surface_sink_distance_at_the_end_of_aspiration parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard surface_following_distance parameter instead.\n" + "liquid_surface_sink_distance_at_the_end_of_aspiration currently superseding surface_following_distance.", + DeprecationWarning, + ) + + if minimal_end_height is not None: + min_z_endpos = minimal_end_height + warnings.warn( + "The minimal_end_height parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard min_z_endpos parameter instead.\n" + "min_z_endpos currently superseding minimal_end_height.", + DeprecationWarning, + ) + + if air_transport_retract_dist is not None: + pull_out_distance_transport_air = air_transport_retract_dist + warnings.warn( + "The air_transport_retract_dist parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard pull_out_distance_transport_air parameter instead.\n" + "pull_out_distance_transport_air currently superseding air_transport_retract_dist.", + DeprecationWarning, + ) + + if maximum_immersion_depth is not None: + minimum_height = maximum_immersion_depth + warnings.warn( + "The maximum_immersion_depth parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard minimum_height parameter instead.\n" + "minimum_height currently superseding maximum_immersion_depth.", + DeprecationWarning, + ) + + if surface_following_distance_during_mix != 0: + mix_surface_following_distance = surface_following_distance_during_mix + warnings.warn( + "The surface_following_distance_during_mix parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard mix_surface_following_distance parameter instead.\n" + "mix_surface_following_distance currently superseding surface_following_distance_during_mix.", + DeprecationWarning, + ) + + if tube_2nd_section_height_measured_from_zm != 3.2: + second_section_height = tube_2nd_section_height_measured_from_zm + warnings.warn( + "The tube_2nd_section_height_measured_from_zm parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard second_section_height parameter instead.\n" + "second_section_height_measured_from_zm currently superseding second_section_height.", + DeprecationWarning, + ) + + if tube_2nd_section_ratio != 618.0: + second_section_ratio = tube_2nd_section_ratio + warnings.warn( + "The tube_2nd_section_ratio parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard second_section_ratio parameter instead.\n" + "second_section_ratio currently superseding tube_2nd_section_ratio.", + DeprecationWarning, + ) + # # # delete # # # + + # get the first well and tip as representatives + if isinstance(aspiration, MultiHeadAspirationPlate): + plate = aspiration.wells[0].parent + assert isinstance(plate, Plate), "MultiHeadAspirationPlate well parent must be a Plate" + rot = plate.get_absolute_rotation() + if rot.x % 360 != 0 or rot.y % 360 != 0: + raise ValueError("Plate rotation around x or y is not supported for 96 head operations") + if rot.z % 360 == 180: + ref_well = aspiration.wells[-1] + elif rot.z % 360 == 0: + ref_well = aspiration.wells[0] + else: + raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") + + position = ( + ref_well.get_location_wrt(self.deck) + + ref_well.center() + + Coordinate(z=ref_well.material_z_thickness) + + aspiration.offset + ) + else: + x_width = (12 - 1) * 9 # 12 tips in a row, 9 mm between them + y_width = (8 - 1) * 9 # 8 tips in a column, 9 mm between them + x_position = (aspiration.container.get_absolute_size_x() - x_width) / 2 + y_position = (aspiration.container.get_absolute_size_y() - y_width) / 2 + y_width + position = ( + aspiration.container.get_location_wrt(self.deck, z="cavity_bottom") + + Coordinate(x=x_position, y=y_position) + + aspiration.offset + ) + self._check_96_position_legal(position, skip_z=True) + + tip = next(tip for tip in aspiration.tips if tip is not None) + + liquid_height = position.z + (aspiration.liquid_height or 0) + + hlc = hlc or get_star_liquid_class( + tip_volume=tip.maximal_volume, + is_core=True, + is_tip=True, + has_filter=tip.has_filter, + # get last liquid in pipette, first to be dispensed + liquid=Liquid.WATER, # default to WATER + jet=jet, + blow_out=blow_out, # see comment in method docstring + ) + + if disable_volume_correction or hlc is None: + volume = aspiration.volume + else: # hlc is not None and not disable_volume_correction + volume = hlc.compute_corrected_volume(aspiration.volume) + + # Get better default values from the HLC if available + transport_air_volume = transport_air_volume or ( + hlc.aspiration_air_transport_volume if hlc is not None else 0 + ) + blow_out_air_volume = aspiration.blow_out_air_volume or ( + hlc.aspiration_blow_out_volume if hlc is not None else 0 + ) + flow_rate = aspiration.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 250) + swap_speed = swap_speed or (hlc.aspiration_swap_speed if hlc is not None else 100) + settling_time = settling_time or (hlc.aspiration_settling_time if hlc is not None else 0.5) + + x_direction = 0 if position.x >= 0 else 1 + try: + return await self.aspirate_core_96( + x_position=abs(round(position.x * 10)), + x_direction=x_direction, + y_positions=round(position.y * 10), + aspiration_type=aspiration_type, + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 + ), + min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10), + lld_search_height=round(lld_search_height * 10), + liquid_surface_no_lld=round(liquid_height * 10), + pull_out_distance_transport_air=round(pull_out_distance_transport_air * 10), + minimum_height=round((minimum_height or position.z) * 10), + second_section_height=round(second_section_height * 10), + second_section_ratio=round(second_section_ratio * 10), + immersion_depth=round(immersion_depth * 10), + immersion_depth_direction=immersion_depth_direction or (0 if (immersion_depth >= 0) else 1), + surface_following_distance=round(surface_following_distance * 10), + aspiration_volumes=round(volume * 10), + aspiration_speed=round(flow_rate * 10), + transport_air_volume=round(transport_air_volume * 10), + blow_out_air_volume=round(blow_out_air_volume * 10), + pre_wetting_volume=round(pre_wetting_volume * 10), + lld_mode=int(use_lld), + gamma_lld_sensitivity=gamma_lld_sensitivity, + swap_speed=round(swap_speed * 10), + settling_time=round(settling_time * 10), + mix_volume=round(aspiration.mix.volume * 10) if aspiration.mix is not None else 0, + mix_cycles=aspiration.mix.repetitions if aspiration.mix is not None else 0, + mix_position_from_liquid_surface=round(mix_position_from_liquid_surface * 10), + mix_surface_following_distance=round(mix_surface_following_distance * 10), + speed_of_mix=round(aspiration.mix.flow_rate * 10) if aspiration.mix is not None else 1200, + channel_pattern=[True] * 12 * 8, + limit_curve_index=limit_curve_index, + tadm_algorithm=False, + recording_mode=0, + ) + except STARFirmwareError as e: + if self._is_core96_slave_timeout(e): + logger.warning("Firmware slave timeout during aspirate96, polling for completion") + await self._core96_wait_for_idle() + else: + raise + + @_requires_head96 + async def dispense96( + self, + dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer], + jet: bool = False, + empty: bool = False, + blow_out: bool = False, + hlc: Optional[HamiltonLiquidClass] = None, + pull_out_distance_transport_air=10, + use_lld: bool = False, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + min_z_endpos: Optional[float] = None, + lld_search_height: float = 199.9, + minimum_height: Optional[float] = None, + second_section_height: float = 3.2, + second_section_ratio: float = 618.0, + immersion_depth: float = 0, + surface_following_distance: float = 0, + transport_air_volume: float = 5.0, + gamma_lld_sensitivity: int = 1, + swap_speed: float = 2.0, + settling_time: float = 0, + mix_position_from_liquid_surface: float = 0, + mix_surface_following_distance: float = 0, + limit_curve_index: int = 0, + cut_off_speed: float = 5.0, + stop_back_volume: float = 0, + disable_volume_correction: bool = False, + # Deprecated parameters, to be removed in future versions + # rm: >2026-01 + liquid_surface_sink_distance_at_the_end_of_dispense: float = 0, # surface_following_distance! + maximum_immersion_depth: Optional[float] = None, + minimal_end_height: Optional[float] = None, + mixing_position_from_liquid_surface: float = 0, + surface_following_distance_during_mixing: float = 0, + air_transport_retract_dist=10, + tube_2nd_section_ratio: float = 618.0, + tube_2nd_section_height_measured_from_zm: float = 3.2, + immersion_depth_direction: Optional[int] = None, + mixing_volume: float = 0, + mixing_cycles: int = 0, + speed_of_mixing: float = 0.0, + dispense_mode: Optional[int] = None, + ): + """Dispense using the Core96 head. + + Args: + dispense: The Dispense command to execute. + jet: Whether to use jet dispense mode. + empty: Whether to use empty dispense mode. + blow_out: Whether to blow out after dispensing. + pull_out_distance_transport_air: The distance to retract after dispensing, in mm. + use_lld: Whether to use gamma LLD. + + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command, in mm. + min_z_endpos: Minimal end height, in mm. + lld_search_height: LLD search height, in mm. + minimum_height: Maximum immersion depth, in mm. Equals Minimum height during command. + second_section_height: Height of the second section, in mm. + second_section_ratio: Ratio of [the diameter of the bottom * 10000] / [the diameter of the top]. + immersion_depth: Immersion depth, in mm. + surface_following_distance: Surface following distance, in mm. Default 0. + transport_air_volume: Transport air volume, to dispense before aspiration. + gamma_lld_sensitivity: Gamma LLD sensitivity. + swap_speed: Swap speed (on leaving liquid) [mm/s]. Must be between 0.3 and 160. Default 10. + settling_time: Settling time, in seconds. + mix_position_from_liquid_surface: Mixing position from liquid surface, in mm. + mix_surface_following_distance: Surface following distance during mixing, in mm. + limit_curve_index: Limit curve index. + cut_off_speed: Unknown. + stop_back_volume: Unknown. + disable_volume_correction: Whether to disable liquid class volume correction. + """ + + # # # TODO: delete > 2026-01 # # # + if mixing_volume != 0 or mixing_cycles != 0 or speed_of_mixing != 0: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense instead. " + "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" + ) + + if immersion_depth_direction is not None: + warnings.warn( + "The immersion_depth_direction parameter is deprecated and will be removed in the future. " + "Use positive values for immersion_depth to move into the liquid, and negative values to move " + "out of the liquid.", + DeprecationWarning, + ) + + if liquid_surface_sink_distance_at_the_end_of_dispense != 0: + surface_following_distance = liquid_surface_sink_distance_at_the_end_of_dispense + warnings.warn( + "The liquid_surface_sink_distance_at_the_end_of_dispense parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard surface_following_distance parameter instead.\n" + "liquid_surface_sink_distance_at_the_end_of_dispense currently superseding surface_following_distance.", + DeprecationWarning, + ) + + if maximum_immersion_depth is not None: + minimum_height = maximum_immersion_depth + warnings.warn( + "The maximum_immersion_depth parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard minimum_height parameter instead.\n" + "minimum_height currently superseding maximum_immersion_depth.", + DeprecationWarning, + ) + + if minimal_end_height is not None: + min_z_endpos = minimal_end_height + warnings.warn( + "The minimal_end_height parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard min_z_endpos parameter instead.\n" + "min_z_endpos currently superseding minimal_end_height.", + DeprecationWarning, + ) + + if mixing_position_from_liquid_surface != 0: + mix_position_from_liquid_surface = mixing_position_from_liquid_surface + warnings.warn( + "The mixing_position_from_liquid_surface parameter is deprecated and will be removed in the future " + "Use the Hamilton-standard mix_position_from_liquid_surface parameter instead.\n" + "mix_position_from_liquid_surface currently superseding mixing_position_from_liquid_surface.", + DeprecationWarning, + ) + + if surface_following_distance_during_mixing != 0: + mix_surface_following_distance = surface_following_distance_during_mixing + warnings.warn( + "The surface_following_distance_during_mixing parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard mix_surface_following_distance parameter instead.\n" + "mix_surface_following_distance currently superseding surface_following_distance_during_mixing.", + DeprecationWarning, + ) + + if air_transport_retract_dist != 10: + pull_out_distance_transport_air = air_transport_retract_dist + warnings.warn( + "The air_transport_retract_dist parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard pull_out_distance_transport_air parameter instead.\n" + "pull_out_distance_transport_air currently superseding air_transport_retract_dist.", + DeprecationWarning, + ) + + if tube_2nd_section_ratio != 618.0: + second_section_ratio = tube_2nd_section_ratio + warnings.warn( + "The tube_2nd_section_ratio parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard second_section_ratio parameter instead.\n" + "second_section_ratio currently superseding tube_2nd_section_ratio.", + DeprecationWarning, + ) + + if tube_2nd_section_height_measured_from_zm != 3.2: + second_section_height = tube_2nd_section_height_measured_from_zm + warnings.warn( + "The tube_2nd_section_height_measured_from_zm parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard second_section_height parameter instead.\n" + "second_section_height currently superseding tube_2nd_section_height_measured_from_zm.", + DeprecationWarning, + ) + + if dispense_mode is not None: + warnings.warn( + "The dispense_mode parameter is deprecated and will be removed in the future. " + "Use the combination of the `jet`, `empty` and `blow_out` parameters instead. " + "dispense_mode currently superseding those parameters.", + DeprecationWarning, + ) + else: + dispense_mode = _dispensing_mode_for_op(empty=empty, jet=jet, blow_out=blow_out) + # # # delete # # # + + # get the first well and tip as representatives + if isinstance(dispense, MultiHeadDispensePlate): + plate = dispense.wells[0].parent + assert isinstance(plate, Plate), "MultiHeadDispensePlate well parent must be a Plate" + rot = plate.get_absolute_rotation() + if rot.x % 360 != 0 or rot.y % 360 != 0: + raise ValueError("Plate rotation around x or y is not supported for 96 head operations") + if rot.z % 360 == 180: + ref_well = dispense.wells[-1] + elif rot.z % 360 == 0: + ref_well = dispense.wells[0] + else: + raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") + + position = ( + ref_well.get_location_wrt(self.deck) + + ref_well.center() + + Coordinate(z=ref_well.material_z_thickness) + + dispense.offset + ) + else: + # dispense in the center of the container + # but we have to get the position of the center of tip A1 + x_width = (12 - 1) * 9 # 12 tips in a row, 9 mm between them + y_width = (8 - 1) * 9 # 8 tips in a column, 9 mm between them + x_position = (dispense.container.get_absolute_size_x() - x_width) / 2 + y_position = (dispense.container.get_absolute_size_y() - y_width) / 2 + y_width + position = ( + dispense.container.get_location_wrt(self.deck, z="cavity_bottom") + + Coordinate(x=x_position, y=y_position) + + dispense.offset + ) + self._check_96_position_legal(position, skip_z=True) + tip = next(tip for tip in dispense.tips if tip is not None) + + liquid_height = position.z + (dispense.liquid_height or 0) + + hlc = hlc or get_star_liquid_class( + tip_volume=tip.maximal_volume, + is_core=True, + is_tip=True, + has_filter=tip.has_filter, + # get last liquid in pipette, first to be dispensed + liquid=Liquid.WATER, # default to WATER + jet=jet, + blow_out=blow_out, # see comment in method docstring + ) + + if disable_volume_correction or hlc is None: + volume = dispense.volume + else: # hlc is not None and not disable_volume_correction + volume = hlc.compute_corrected_volume(dispense.volume) + + transport_air_volume = transport_air_volume or ( + hlc.dispense_air_transport_volume if hlc is not None else 0 + ) + blow_out_air_volume = dispense.blow_out_air_volume or ( + hlc.dispense_blow_out_volume if hlc is not None else 0 + ) + flow_rate = dispense.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 120) + swap_speed = swap_speed or (hlc.dispense_swap_speed if hlc is not None else 100) + settling_time = settling_time or (hlc.dispense_settling_time if hlc is not None else 5) + + try: + return await self.dispense_core_96( + dispensing_mode=dispense_mode, + x_position=abs(round(position.x * 10)), + x_direction=0 if position.x >= 0 else 1, + y_position=round(position.y * 10), + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 + ), + min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10), + lld_search_height=round(lld_search_height * 10), + liquid_surface_no_lld=round(liquid_height * 10), + pull_out_distance_transport_air=round(pull_out_distance_transport_air * 10), + minimum_height=round((minimum_height or position.z) * 10), + second_section_height=round(second_section_height * 10), + second_section_ratio=round(second_section_ratio * 10), + immersion_depth=round(immersion_depth * 10), + immersion_depth_direction=immersion_depth_direction or (0 if (immersion_depth >= 0) else 1), + surface_following_distance=round(surface_following_distance * 10), + dispense_volume=round(volume * 10), + dispense_speed=round(flow_rate * 10), + transport_air_volume=round(transport_air_volume * 10), + blow_out_air_volume=round(blow_out_air_volume * 10), + lld_mode=int(use_lld), + gamma_lld_sensitivity=gamma_lld_sensitivity, + swap_speed=round(swap_speed * 10), + settling_time=round(settling_time * 10), + mixing_volume=round(dispense.mix.volume * 10) if dispense.mix is not None else 0, + mixing_cycles=dispense.mix.repetitions if dispense.mix is not None else 0, + mix_position_from_liquid_surface=round(mix_position_from_liquid_surface * 10), + mix_surface_following_distance=round(mix_surface_following_distance * 10), + speed_of_mixing=round(dispense.mix.flow_rate * 10) if dispense.mix is not None else 1200, + channel_pattern=[True] * 12 * 8, + limit_curve_index=limit_curve_index, + tadm_algorithm=False, + recording_mode=0, + cut_off_speed=round(cut_off_speed * 10), + stop_back_volume=round(stop_back_volume * 10), + ) + except STARFirmwareError as e: + if self._is_core96_slave_timeout(e): + logger.warning( + "Firmware slave command timeout during dispense96, waiting for head to finish" + ) + await self._core96_wait_for_idle() + else: + raise + + async def iswap_move_picked_up_resource( + self, + center: Coordinate, + grip_direction: GripDirection, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + collision_control_level: int = 1, + acceleration_index_high_acc: int = 4, + acceleration_index_low_acc: int = 1, + ): + """After a resource is picked up, move it to a new location but don't release it yet. + Low level component of :meth:`move_resource` + """ + + assert self.extended_conf.left_x_drive.iswap_installed, "iswap must be installed" + + x_direction = 0 if center.x >= 0 else 1 + y_direction = 0 if center.y >= 0 else 1 + + await self.move_plate_to_position( + x_position=round(abs(center.x) * 10), + x_direction=x_direction, + y_position=round(abs(center.y) * 10), + y_direction=y_direction, + z_position=round(center.z * 10), + z_direction=0, + grip_direction={ + GripDirection.FRONT: 1, + GripDirection.RIGHT: 2, + GripDirection.BACK: 3, + GripDirection.LEFT: 4, + }[grip_direction], + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height) * 10 + ), + collision_control_level=collision_control_level, + acceleration_index_high_acc=acceleration_index_high_acc, + acceleration_index_low_acc=acceleration_index_low_acc, + ) + + async def core_pick_up_resource( + self, + resource: Resource, + pickup_distance_from_top: float, + offset: Coordinate = Coordinate.zero(), + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + minimum_z_position_at_the_command_end: Optional[float] = None, + grip_strength: int = 15, + z_speed: float = 50.0, + y_gripping_speed: float = 5.0, + front_channel: int = 7, + ): + """Pick up resource with CoRe gripper tool + Low level component of :meth:`move_resource` + + Args: + resource: Resource to pick up. + offset: Offset from resource position in mm. + pickup_distance_from_top: Distance from top of resource to pick up. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command [mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be + between 0 and 360. + grip_strength: Grip strength (0 = weak, 99 = strong). Must be between 0 and 99. Default 15. + z_speed: Z speed [mm/s]. Must be between 0.4 and 128.7. Default 50.0. + y_gripping_speed: Y gripping speed [mm/s]. Must be between 0 and 370.0. Default 5.0. + front_channel: Channel 1. Must be between 1 and self._num_channels - 1. Default 7. + """ + + # Get center of source plate. Also gripping height and plate width. + center = resource.get_location_wrt(self.deck, x="c", y="c", z="b") + offset + grip_height = center.z + resource.get_absolute_size_z() - pickup_distance_from_top + grip_width = resource.get_absolute_size_y() # grip width is y size of resource + + if self.core_parked: + await self.pick_up_core_gripper_tools(front_channel=front_channel) + + await self.core_get_plate( + x_position=round(center.x * 10), + x_direction=0, + y_position=round(center.y * 10), + y_gripping_speed=round(y_gripping_speed * 10), + z_position=round(grip_height * 10), + z_speed=round(z_speed * 10), + open_gripper_position=round(grip_width * 10) + 30, + plate_width=round(grip_width * 10) - 30, + grip_strength=grip_strength, + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height) * 10 + ), + minimum_z_position_at_the_command_end=round( + (minimum_z_position_at_the_command_end or self._iswap_traversal_height) * 10 + ), + ) + + async def core_move_picked_up_resource( + self, + center: Coordinate, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + acceleration_index: int = 4, + z_speed: float = 50.0, + ): + """After a resource is picked up, move it to a new location but don't release it yet. + Low level component of :meth:`move_resource` + + Args: + location: Location to move to. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command [0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be + between 0 and 3600. Default 3600. + acceleration_index: Acceleration index (0 = 0.1 mm/s2, 1 = 0.2 mm/s2, 2 = 0.5 mm/s2, + 3 = 1.0 mm/s2, 4 = 2.0 mm/s2, 5 = 5.0 mm/s2, 6 = 10.0 mm/s2, 7 = 20.0 mm/s2). Must be + between 0 and 7. Default 4. + z_speed: Z speed [0.1mm/s]. Must be between 3 and 1600. Default 500. + """ + + await self.core_move_plate_to_position( + x_position=round(center.x * 10), + x_direction=0, + x_acceleration_index=acceleration_index, + y_position=round(center.y * 10), + z_position=round(center.z * 10), + z_speed=round(z_speed * 10), + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height) * 10 + ), + ) + + async def core_release_picked_up_resource( + self, + location: Coordinate, + resource: Resource, + pickup_distance_from_top: float, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + z_position_at_the_command_end: Optional[float] = None, + return_tool: bool = True, + ): + """Place resource with CoRe gripper tool + Low level component of :meth:`move_resource` + + Args: + resource: Location to place. + pickup_distance_from_top: Distance from top of resource to place. + offset: Offset from resource position in mm. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command [mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be + between 0 and 360.0. + z_position_at_the_command_end: Minimum z-Position at end of a command [mm] (refers to all + channels independent of tip pattern parameter 'tm'). Must be between 0 and 360.0 + return_tool: Return tool to wasteblock mount after placing. Default True. + """ + + # Get center of destination location. Also gripping height and plate width. + grip_height = location.z + resource.get_absolute_size_z() - pickup_distance_from_top + grip_width = resource.get_absolute_size_y() + + await self.core_put_plate( + x_position=round(location.x * 10), + x_direction=0, + y_position=round(location.y * 10), + z_position=round(grip_height * 10), + z_press_on_distance=0, + z_speed=500, + open_gripper_position=round(grip_width * 10) + 30, + minimum_traverse_height_at_beginning_of_a_command=round( + (minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height) * 10 + ), + z_position_at_the_command_end=round( + (z_position_at_the_command_end or self._iswap_traversal_height) * 10 + ), + return_tool=return_tool, + ) + + async def pick_up_resource( + self, + pickup: ResourcePickup, + use_arm: Literal["iswap", "core"] = "iswap", + core_front_channel: int = 7, + iswap_grip_strength: int = 4, + core_grip_strength: int = 15, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + z_position_at_the_command_end: Optional[float] = None, + plate_width_tolerance: float = 2.0, + open_gripper_position: Optional[float] = None, + hotel_depth=160.0, + hotel_clearance_height=7.5, + high_speed=False, + plate_width: Optional[float] = None, + use_unsafe_hotel: bool = False, + iswap_collision_control_level: int = 0, + iswap_fold_up_sequence_at_the_end_of_process: bool = False, + # deprecated + channel_1: Optional[int] = None, + channel_2: Optional[int] = None, + ): + if use_arm == "iswap": + assert ( + pickup.resource.get_absolute_rotation().x == 0 + and pickup.resource.get_absolute_rotation().y == 0 + ) + assert pickup.resource.get_absolute_rotation().z % 90 == 0 + if plate_width is None: + if pickup.direction in (GripDirection.FRONT, GripDirection.BACK): + plate_width = pickup.resource.get_absolute_size_x() + else: + plate_width = pickup.resource.get_absolute_size_y() + + center_in_absolute_space = pickup.resource.center().rotated( + pickup.resource.get_absolute_rotation() + ) + x, y, z = ( + pickup.resource.get_location_wrt(self.deck, "l", "f", "t") + + center_in_absolute_space + + pickup.offset + ) + z -= pickup.pickup_distance_from_top + + traverse_height_at_beginning = ( + minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height + ) + z_position_at_the_command_end = z_position_at_the_command_end or self._iswap_traversal_height + + if open_gripper_position is None: + if use_unsafe_hotel: + open_gripper_position = plate_width + 5 + else: + open_gripper_position = plate_width + 3 + + if use_unsafe_hotel: + await self.unsafe.get_from_hotel( + hotel_center_x_coord=round(abs(x) * 10), + hotel_center_y_coord=round(abs(y) * 10), + # hotel_center_z_coord=int((z * 10)+0.5), # use sensible rounding (.5 goes up) + hotel_center_z_coord=round(abs(z) * 10), + hotel_center_x_direction=0 if x >= 0 else 1, + hotel_center_y_direction=0 if y >= 0 else 1, + hotel_center_z_direction=0 if z >= 0 else 1, + clearance_height=round(hotel_clearance_height * 10), + hotel_depth=round(hotel_depth * 10), + grip_direction=pickup.direction, + open_gripper_position=round(open_gripper_position * 10), + traverse_height_at_beginning=round(traverse_height_at_beginning * 10), + z_position_at_end=round(z_position_at_the_command_end * 10), + high_acceleration_index=4 if high_speed else 1, + low_acceleration_index=1, + plate_width=round(plate_width * 10), + plate_width_tolerance=round(plate_width_tolerance * 10), + ) + else: + await self.iswap_get_plate( + x_position=round(abs(x) * 10), + y_position=round(abs(y) * 10), + z_position=round(abs(z) * 10), + x_direction=0 if x >= 0 else 1, + y_direction=0 if y >= 0 else 1, + z_direction=0 if z >= 0 else 1, + grip_direction={ + GripDirection.FRONT: 1, + GripDirection.RIGHT: 2, + GripDirection.BACK: 3, + GripDirection.LEFT: 4, + }[pickup.direction], + minimum_traverse_height_at_beginning_of_a_command=round( + traverse_height_at_beginning * 10 + ), + z_position_at_the_command_end=round(z_position_at_the_command_end * 10), + grip_strength=iswap_grip_strength, + open_gripper_position=round(open_gripper_position * 10), + plate_width=round(plate_width * 10) - 33, + plate_width_tolerance=round(plate_width_tolerance * 10), + collision_control_level=iswap_collision_control_level, + acceleration_index_high_acc=4 if high_speed else 1, + acceleration_index_low_acc=1, + iswap_fold_up_sequence_at_the_end_of_process=iswap_fold_up_sequence_at_the_end_of_process, + ) + elif use_arm == "core": + if use_unsafe_hotel: + raise ValueError("Cannot use iswap hotel mode with core grippers") + + if pickup.direction != GripDirection.FRONT: + raise NotImplementedError("Core grippers only support FRONT (default)") + + if channel_1 is not None or channel_2 is not None: + warnings.warn( + "The channel_1 and channel_2 parameters are deprecated and will be removed in future versions. " + "Please use the core_front_channel parameter instead.", + DeprecationWarning, + ) + assert channel_1 is not None and channel_2 is not None, ( + "Both channel_1 and channel_2 must be provided" + ) + assert channel_1 + 1 == channel_2, "channel_2 must be channel_1 + 1" + core_front_channel = ( + channel_2 - 1 + ) # core_front_channel is the first channel of the gripper tool + + await self.core_pick_up_resource( + resource=pickup.resource, + pickup_distance_from_top=pickup.pickup_distance_from_top, + offset=pickup.offset, + minimum_traverse_height_at_beginning_of_a_command=self._iswap_traversal_height, + minimum_z_position_at_the_command_end=self._iswap_traversal_height, + front_channel=core_front_channel, + grip_strength=core_grip_strength, + ) + else: + raise ValueError(f"use_arm must be either 'iswap' or 'core', not {use_arm}") + + async def move_picked_up_resource( + self, move: ResourceMove, use_arm: Literal["iswap", "core"] = "iswap" + ): + center = ( + move.location + + move.resource.get_anchor("c", "c", "t") + - Coordinate(z=move.pickup_distance_from_top) + + move.offset + ) + + if use_arm == "iswap": + await self.iswap_move_picked_up_resource( + center=center, + grip_direction=move.gripped_direction, + minimum_traverse_height_at_beginning_of_a_command=self._iswap_traversal_height, + collision_control_level=1, + acceleration_index_high_acc=4, + acceleration_index_low_acc=1, + ) + else: + await self.core_move_picked_up_resource( + center=center, + minimum_traverse_height_at_beginning_of_a_command=self._iswap_traversal_height, + acceleration_index=4, + ) + + async def drop_resource( + self, + drop: ResourceDrop, + use_arm: Literal["iswap", "core"] = "iswap", + return_core_gripper: bool = True, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + z_position_at_the_command_end: Optional[float] = None, + open_gripper_position: Optional[float] = None, + hotel_depth=160.0, + hotel_clearance_height=7.5, + hotel_high_speed=False, + use_unsafe_hotel: bool = False, + iswap_collision_control_level: int = 0, + iswap_fold_up_sequence_at_the_end_of_process: bool = False, + ): + # Get center of source plate in absolute space. + # The computation of the center has to be rotated so that the offset is in absolute space. + # center_in_absolute_space will be the vector pointing from the destination origin to the + # center of the moved the resource after drop. + # This means that the center vector has to be rotated from the child local space by the + # new child absolute rotation. The moved resource's rotation will be the original child + # rotation plus the rotation applied by the movement. + # The resource is moved by drop.rotation + # The new resource absolute location is + # drop.resource.get_absolute_rotation().z + drop.rotation + center_in_absolute_space = drop.resource.center().rotated( + Rotation(z=drop.resource.get_absolute_rotation().z + drop.rotation) + ) + x, y, z = drop.destination + center_in_absolute_space + drop.offset + + if use_arm == "iswap": + traversal_height_start = ( + minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height + ) + z_position_at_the_command_end = z_position_at_the_command_end or self._iswap_traversal_height + assert ( + drop.resource.get_absolute_rotation().x == 0 + and drop.resource.get_absolute_rotation().y == 0 + ) + assert drop.resource.get_absolute_rotation().z % 90 == 0 + + # Use the pickup direction to determine how wide the plate is gripped. + # Note that the plate is still in the original orientation at this point, + # so get_absolute_size_{x,y}() will return the size of the plate in the original orientation. + if ( + drop.pickup_direction == GripDirection.FRONT or drop.pickup_direction == GripDirection.BACK + ): + plate_width = drop.resource.get_absolute_size_x() + elif ( + drop.pickup_direction == GripDirection.RIGHT or drop.pickup_direction == GripDirection.LEFT + ): + plate_width = drop.resource.get_absolute_size_y() + else: + raise ValueError("Invalid grip direction") + + z = z + drop.resource.get_absolute_size_z() - drop.pickup_distance_from_top + + if open_gripper_position is None: + if use_unsafe_hotel: + open_gripper_position = plate_width + 5 + else: + open_gripper_position = plate_width + 3 + + if use_unsafe_hotel: + # hotel: down forward down. + # down to level of the destination + the clearance height (so clearance height can be subtracted) + # hotel_depth is forward. + # clearance height is second down. + + await self.unsafe.put_in_hotel( + hotel_center_x_coord=round(abs(x) * 10), + hotel_center_y_coord=round(abs(y) * 10), + hotel_center_z_coord=round(abs(z) * 10), + hotel_center_x_direction=0 if x >= 0 else 1, + hotel_center_y_direction=0 if y >= 0 else 1, + hotel_center_z_direction=0 if z >= 0 else 1, + clearance_height=round(hotel_clearance_height * 10), + hotel_depth=round(hotel_depth * 10), + grip_direction=drop.direction, + open_gripper_position=round(open_gripper_position * 10), + traverse_height_at_beginning=round(traversal_height_start * 10), + z_position_at_end=round(z_position_at_the_command_end * 10), + high_acceleration_index=4 if hotel_high_speed else 1, + low_acceleration_index=1, + ) + else: + await self.iswap_put_plate( + x_position=round(abs(x) * 10), + y_position=round(abs(y) * 10), + z_position=round(abs(z) * 10), + x_direction=0 if x >= 0 else 1, + y_direction=0 if y >= 0 else 1, + z_direction=0 if z >= 0 else 1, + grip_direction={ + GripDirection.FRONT: 1, + GripDirection.RIGHT: 2, + GripDirection.BACK: 3, + GripDirection.LEFT: 4, + }[drop.direction], + minimum_traverse_height_at_beginning_of_a_command=round(traversal_height_start * 10), + z_position_at_the_command_end=round(z_position_at_the_command_end * 10), + open_gripper_position=round(open_gripper_position * 10), + collision_control_level=iswap_collision_control_level, + iswap_fold_up_sequence_at_the_end_of_process=iswap_fold_up_sequence_at_the_end_of_process, + ) + elif use_arm == "core": + if use_unsafe_hotel: + raise ValueError("Cannot use iswap hotel mode with core grippers") + + if drop.direction != GripDirection.FRONT: + raise NotImplementedError("Core grippers only support FRONT direction (default)") + + await self.core_release_picked_up_resource( + location=Coordinate(x, y, z), + resource=drop.resource, + pickup_distance_from_top=drop.pickup_distance_from_top, + minimum_traverse_height_at_beginning_of_a_command=self._iswap_traversal_height, + z_position_at_the_command_end=self._iswap_traversal_height, + # int(previous_location.z + move.resource.get_size_z() / 2) * 10, + return_tool=return_core_gripper, + ) + else: + raise ValueError(f"use_arm must be either 'iswap' or 'core', not {use_arm}") + + async def prepare_for_manual_channel_operation(self, channel: int): + """Prepare for manual operation.""" + + await self.position_max_free_y_for_n(pipetting_channel_index=channel) + + async def move_channel_x(self, channel: int, x: float): + """Move a channel in the x direction.""" + if self.left_side_panel_installed and x < self.PIP_X_MIN_WITH_LEFT_SIDE_PANEL: + raise ValueError( + f"PIP channel x={x}mm is below the minimum {self.PIP_X_MIN_WITH_LEFT_SIDE_PANEL}mm " + f"(left side panel is installed)" + ) + self._check_x_arm_reachable(x) + await self.position_left_x_arm_(round(x * 10)) + + @need_iswap_parked + async def move_channel_y(self, channel: int, y: float): + """Move a channel safely in the y direction.""" + + # Anti-channel-crash feature + if channel > 0: + max_y_pos = await self.request_y_pos_channel_n(channel - 1) + if y > max_y_pos: + raise ValueError( + f"channel {channel} y-target must be <= {max_y_pos} mm " + f"(channel {channel - 1} y-position is {round(y, 2)} mm)" + ) + else: + max_y_pos = self.extended_conf.pip_maximal_y_position + if y > max_y_pos: + raise ValueError(f"channel {channel} y-target must be <= {max_y_pos} mm") + + if channel < (self.num_channels - 1): + min_y_pos = await self.request_y_pos_channel_n(channel + 1) + if y < min_y_pos: + raise ValueError( + f"channel {channel} y-target must be >= {min_y_pos} mm " + f"(channel {channel + 1} y-position is {round(y, 2)} mm)" + ) + else: + # STAR machines do not allow channels y < minimum + if y < self.extended_conf.left_arm_min_y_position: + raise ValueError( + f"channel {channel} y-target must be >= {self.extended_conf.left_arm_min_y_position} mm" + ) + + await self.position_single_pipetting_channel_in_y_direction( + pipetting_channel_index=channel + 1, y_position=round(y * 10) + ) + + async def move_channel_z(self, channel: int, z: float): + """Move a channel in the Z direction. + + .. deprecated:: + Use :meth:`move_channel_stop_disk_z` for moves without a tip attached (stop disk) + or :meth:`move_channel_tool_z` when a tip or tool is attached (tip/tool end). + + The Hamilton firmware interprets this Z position based on its internal + "tip mounted" state for the specified channel. When the firmware state + indicates that no tip is mounted, the absolute Z position refers to the + bottom of the stop disk. In that case, this command is effectively + equivalent to :meth:`move_channel_stop_disk_z` for the same numeric Z value. + + When the firmware state indicates that a tip is mounted on the channel, + the same Z position instead refers to the physical end of the tip. In + this case, the numeric Z value used with this method may differ from the + stop disk Z position used with :meth:`move_channel_stop_disk_z` for the same + physical height above the deck. + """ + # TODO: remove in v1 + warnings.warn( + "move_channel_z is deprecated and will be removed in v1. " + "Use move_channel_stop_disk_z() for moves without a tip attached " + "or move_channel_tool_z() when a tip/tool is attached.", + DeprecationWarning, + stacklevel=2, + ) + await self.position_single_pipetting_channel_in_z_direction( + pipetting_channel_index=channel + 1, z_position=round(z * 10) + ) + + async def move_channel_stop_disk_z( + self, + channel_idx: int, + z: float, + speed: float = 125.0, + acceleration: float = 800.0, + current_limit: int = 3, + ): + """Move a channel's Z-drive to an absolute stop disk position. + + Communicates directly with the individual channel rather than through the + master module. + + Args: + channel_idx: Channel index (0-based, backmost = 0). + z: Target Z position in mm (stop disk). + speed: Max Z-drive speed in mm/sec. Default 125.0 mm/s. + acceleration: Acceleration in mm/sec². Default 800.0. Valid range: ~53.6 to 1609. + current_limit: Current limit (0-7). Default 3. + """ + + z_increment = STARBackend.mm_to_z_drive_increment(z) + speed_increment = STARBackend.mm_to_z_drive_increment(speed) + acceleration_increment = STARBackend.mm_to_z_drive_increment(acceleration / 1000) + + if not isinstance(channel_idx, int): + raise ValueError(f"channel_idx must be an int, got {type(channel_idx).__name__}") + if not (0 <= channel_idx < self.num_channels): + raise ValueError( + f"channel index {channel_idx} out of range for instrument with {self.num_channels} channels" + ) + assert 9320 <= z_increment <= 31200, ( + f"z must be between {STARBackend.z_drive_increment_to_mm(9320)} and " + f"{STARBackend.z_drive_increment_to_mm(31200)} mm, got {z} mm" + ) + assert 20 <= speed_increment <= 15000, ( + f"speed must be between {STARBackend.z_drive_increment_to_mm(20)} and " + f"{STARBackend.z_drive_increment_to_mm(15000)} mm/s, got {speed} mm/s" + ) + assert 5 <= acceleration_increment <= 150, ( + f"acceleration must be between ~53.6 and ~1609 mm/s², got {acceleration} mm/s²" + ) + assert 0 <= current_limit <= 7, f"current_limit must be between 0 and 7, got {current_limit}" + + return await self.send_command( + module=STARBackend.channel_id(channel_idx), + command="ZA", + za=f"{z_increment:05}", + zv=f"{speed_increment:05}", + zr=f"{acceleration_increment:03}", + zw=f"{current_limit:01}", + ) + + async def move_channel_tool_z(self, channel_idx: int, z: float): + """Move a channel in the Z direction (tip/tool end reference). + + Requires a tip or tool to be attached. Use :meth:`move_channel_stop_disk_z` + for moves without a tip. + + Args: + channel_idx: Channel index (0-based, backmost = 0). + z: Target Z position in mm (tip/tool end). + """ + + if not isinstance(channel_idx, int): + raise ValueError(f"channel_idx must be an int, got {type(channel_idx).__name__}") + if not (0 <= channel_idx < self.num_channels): + raise ValueError( + f"channel index {channel_idx} out of range for instrument with {self.num_channels} channels" + ) + + tip_presence = await self.request_tip_presence() + + if not tip_presence[channel_idx]: + raise ValueError( + f"Channel {channel_idx} does not have a tip or tool attached. " + "Use move_channel_stop_disk_z() for Z moves without a tip attached." + ) + + tip_len = await self.request_tip_len_on_channel(channel_idx) + + # The firmware command operates in "tip space" (Z refers to the tip/tool end). + # Convert the head-space limits to tip-space limits: + # tip_space = head_space - tip_len + fitting_depth + max_tip_z = ( + STARBackend.MAXIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) + min_tip_z = ( + STARBackend.MINIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) + + if not (min_tip_z <= z <= max_tip_z): + raise ValueError( + f"z={z} mm out of safe range [{min_tip_z}, {max_tip_z}] mm " + f"for tip length {tip_len} mm on channel {channel_idx}" + ) + + await self.position_single_pipetting_channel_in_z_direction( + pipetting_channel_index=channel_idx + 1, z_position=round(z * 10) + ) + + async def move_channel_x_relative(self, channel: int, distance: float): + """Move a channel in the x direction by a relative amount.""" + current_x = await self.request_x_pos_channel_n(channel) + await self.move_channel_x(channel, current_x + distance) + + async def move_channel_y_relative(self, channel: int, distance: float): + """Move a channel in the y direction by a relative amount.""" + current_y = await self.request_y_pos_channel_n(channel) + await self.move_channel_y(channel, current_y + distance) + + async def move_channel_z_relative(self, channel: int, distance: float): + """Move a channel in the z direction by a relative amount.""" + # TODO: determine whether this refers to stop disk or tip bottom + current_z = await self.request_z_pos_channel_n(channel) + await self.move_channel_z(channel, current_z + distance) + + def get_channel_spacings(self, use_channels: List[int]) -> List[float]: + return [self._channels_minimum_y_spacing[ch] for ch in sorted(use_channels)] + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + if not isinstance(tip, HamiltonTip): + return False + if tip.tip_size in {TipSize.XL}: + return False + return True + + async def core_check_resource_exists_at_location_center( + self, + location: Coordinate, + resource: Resource, + gripper_y_margin: float = 0.5, + offset: Coordinate = Coordinate.zero(), + minimum_traverse_height_at_beginning_of_a_command: float = 275.0, + z_position_at_the_command_end: float = 275.0, + enable_recovery: bool = True, + audio_feedback: bool = True, + ) -> bool: + """Check existence of resource with CoRe gripper tool + a "Get plate using CO-RE gripper" + error handling + Which channels are used for resource check is dependent on which channels have been used for + `STARBackend.get_core(p1: int, p2: int)` (channel indices are 0-based) which is a prerequisite + for this check function. + + Args: + location: Location to check for resource + resource: Resource to check for. + gripper_y_margin = Distance between the front / back wall of the resource + and the grippers during "bumping" / checking + offset: Offset from resource position in mm. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of + a command [mm] (refers to all channels independent of tip pattern parameter 'tm'). + Must be between 0 and 360.0. + z_position_at_the_command_end: Minimum z-Position at end of a command [mm] (refers to + all channels independent of tip pattern parameter 'tm'). Must be between 0 and 360.0. + enable_recovery: if True will ask for user input if resource was not found + audio_feedback: enable controlling computer to emit different sounds when + finding/not finding the resource + + Returns: + True if resource was found, False if resource was not found + """ + + center = location + resource.centers()[0] + offset + y_width_to_gripper_bump = resource.get_absolute_size_y() - gripper_y_margin * 2 + max_spacing = max(self._channels_minimum_y_spacing) + assert max_spacing <= y_width_to_gripper_bump <= round(resource.get_absolute_size_y()), ( + f"width between channels must be between {max_spacing} and " + f"{resource.get_absolute_size_y()} mm" + " (i.e. the maximal distance between channels and the max y size of the resource" + ) + + # Check if CoRe gripper currently in use + cores_used = not self._core_parked + if not cores_used: + raise ValueError("CoRe grippers not yet picked up.") + + # Enable recovery of failed checks + resource_found = False + try_counter = 0 + while not resource_found: + try: + await self.core_get_plate( + x_position=round(center.x * 10), + y_position=round(center.y * 10), + z_position=round(center.z * 10), + open_gripper_position=round(y_width_to_gripper_bump * 10), + plate_width=round(y_width_to_gripper_bump * 10), + # Set default values based on VENUS check_plate commands + y_gripping_speed=50, + x_direction=0, + z_speed=600, + grip_strength=20, + # Enable mods of channel z position for check acceleration + minimum_traverse_height_at_beginning_of_a_command=round( + minimum_traverse_height_at_beginning_of_a_command * 10 + ), + minimum_z_position_at_the_command_end=round(z_position_at_the_command_end * 10), + ) + except STARFirmwareError as exc: + for module_error in exc.errors.values(): + if module_error.trace_information == 62: + resource_found = True + else: + raise ValueError(f"Unexpected error encountered: {exc}") from exc + else: + if audio_feedback: + audio.play_not_found() + if enable_recovery: + print( + f"\nWARNING: Resource '{resource.name}' not found at center" + f" location {(center.x, center.y, center.z)} during check no {try_counter}." + ) + user_prompt = input( + "Have you checked resource is present?" + "\n [ yes ] -> machine will check location again" + "\n [ abort ] -> machine will abort run\n Answer:" + ) + if user_prompt == "yes": + try_counter += 1 + elif user_prompt == "abort": + raise ValueError( + f"Resource '{resource.name}' not found at center" + f" location {(center.x, center.y, center.z)}" + " & error not resolved -> aborted resource movement." + ) + else: + # Resource was not found + return False + + # Resource was found + if audio_feedback: + audio.play_got_item() + return True + + def _position_96_head_in_resource(self, resource: Resource) -> Coordinate: + """The firmware command expects location of tip A1 of the head. We center the head in the given + resource.""" + head_size_x = 9 * 11 # 12 channels, 9mm spacing in between + head_size_y = 9 * 7 # 8 channels, 9mm spacing in between + channel_size = 9 + loc = resource.get_location_wrt(self.deck) + loc.x += (resource.get_size_x() - head_size_x) / 2 + channel_size / 2 + loc.y += (resource.get_size_y() - head_size_y) / 2 + channel_size / 2 + return loc + + def _check_96_position_legal(self, c: Coordinate, skip_z=False) -> None: + """Validate that a coordinate is within the allowed range for the 96 head. + + Args: + c: The coordinate of the A1 position of the head. + skip_z: If True, the z coordinate is not checked. This is useful for commands that handle + the z coordinate separately, such as the big four. + + Raises: + ValueError: If one or more components are out of range. The error message contains all offending components. + """ + + # TODO: these are values for a STARBackend. Find them for a STARlet. + + x_min = self.HEAD96_X_MIN_WITH_LEFT_SIDE_PANEL if self.left_side_panel_installed else -271.0 + + errors = [] + if not (x_min <= c.x <= 974.0): + errors.append(f"x={c.x}") + if not (108.0 <= c.y <= 560.0): + errors.append(f"y={c.y}") + if not (180.5 <= c.z <= 342.5) and not skip_z: + errors.append(f"z={c.z}") + + if len(errors) > 0: + raise ValueError( + "Illegal 96 head position: " + + ", ".join(errors) + + f" (allowed ranges: x [{x_min}, 974], y [108, 560], z [180.5, 342.5])" + ) + + # ============== Firmware Commands ============== + + # -------------- 3.2 System general commands -------------- + + async def pre_initialize_instrument(self): + """Pre-initialize instrument""" + return await self.send_command(module="C0", command="VI", read_timeout=300) + + async def define_tip_needle( + self, + tip_type_table_index: int, + has_filter: bool, + tip_length: int, + maximum_tip_volume: int, + tip_size: TipSize, + pickup_method: TipPickupMethod, + ): + """Tip/needle definition. + + Args: + tip_type_table_index: tip_table_index + has_filter: with(out) filter + tip_length: Tip length [0.1mm] + maximum_tip_volume: Maximum volume of tip [0.1ul] + Note! it's automatically limited to max. channel capacity + tip_type: Type of tip collar (Tip type identification) + pickup_method: pick up method. + Attention! The values set here are temporary and apply only until + power OFF or RESET. After power ON the default values apply. (see Table 3) + """ + + assert 0 <= tip_type_table_index <= 99, "tip_type_table_index must be between 0 and 99" + assert 0 <= tip_type_table_index <= 99, "tip_type_table_index must be between 0 and 99" + assert 1 <= tip_length <= 1999, "tip_length must be between 1 and 1999" + assert 1 <= maximum_tip_volume <= 56000, "maximum_tip_volume must be between 1 and 56000" + + return await self.send_command( + module="C0", + command="TT", + tt=f"{tip_type_table_index:02}", + tf=has_filter, + tl=f"{tip_length:04}", + tv=f"{maximum_tip_volume:05}", + tg=tip_size.value, + tu=pickup_method.value, + ) + + # -------------- 3.2.1 System query -------------- + + async def request_error_code(self): + """Request error code + + Here the last saved error messages can be retrieved. The error buffer is automatically voided + when a new command is started. All configured nodes are displayed. + + Returns: + TODO: + X0##/##: X0 slave + ..##/## see node definitions ( chapter 5) + """ + + return await self.send_command(module="C0", command="RE") + + async def request_firmware_version(self): + """Request firmware version + + Returns: TODO: Rfid0001rf1.0S 2009-06-24 A + """ + + return await self.send_command(module="C0", command="RF") + + async def request_parameter_value(self): + """Request parameter value + + Returns: TODO: Raid1111er00/00yg1200 + """ + + return await self.send_command(module="C0", command="RA") + + class BoardType(enum.Enum): + C167CR_SINGLE_PROCESSOR_BOARD = 0 + C167CR_DUAL_PROCESSOR_BOARD = 1 + LPC2468_XE167_DUAL_PROCESSOR_BOARD = 2 + LPC2468_SINGLE_PROCESSOR_BOARD = 5 + UNKNOWN = -1 + + async def request_electronic_board_type(self): + """Request electronic board type + + Returns: + The board type. + """ + + resp = await self.send_command(module="C0", command="QB") + try: + return STARBackend.BoardType(resp["qb"]) + except ValueError: + return STARBackend.BoardType.UNKNOWN + + # TODO: parse response. + async def request_supply_voltage(self): + """Request supply voltage + + Request supply voltage (for LDPB only) + """ + + return await self.send_command(module="C0", command="MU") + + async def request_instrument_initialization_status(self) -> bool: + """Request instrument initialization status""" + + resp = await self.send_command(module="C0", command="QW", fmt="qw#") + return resp is not None and resp["qw"] == 1 + + async def request_autoload_initialization_status(self) -> bool: + """Request autoload initialization status""" + + resp = await self.send_command(module="I0", command="QW", fmt="qw#") + return resp is not None and resp["qw"] == 1 + + async def request_name_of_last_faulty_parameter(self): + """Request name of last faulty parameter + + Returns: TODO: + Name of last parameter with syntax error + (optional) received value separated with blank + (optional) minimal permitted value separated with blank (optional) + maximal permitted value separated with blank example with min max data: + Vpid2233er00/00vpth 00000 03500 example without min max data: Vpid2233er00/00vpcd + """ + + return await self.send_command(module="C0", command="VP", fmt="vp&&") + + async def request_master_status(self): + """Request master status + + Returns: TODO: see page 19 (SFCO.0036) + """ + + return await self.send_command(module="C0", command="RQ") + + async def request_number_of_presence_sensors_installed(self): + """Request number of presence sensors installed + + Returns: + number of sensors installed (1...103) + """ + + resp = await self.send_command(module="C0", command="SR") + return resp["sr"] + + async def request_eeprom_data_correctness(self): + """Request EEPROM data correctness + + Returns: TODO: (SFCO.0149) + """ + + return await self.send_command(module="C0", command="QV") + + # -------------- 3.3 Settings -------------- + + # -------------- 3.3.1 Volatile Settings -------------- + + async def set_single_step_mode(self, single_step_mode: bool = False): + """Set Single step mode + + Args: + single_step_mode: Single Step Mode. Default False. + """ + + return await self.send_command( + module="C0", + command="AM", + am=single_step_mode, + ) + + async def trigger_next_step(self): + """Trigger next step (Single step mode)""" + + # TODO: this command has no reply!!!! + return await self.send_command(module="C0", command="NS") + + async def halt(self): + """Halt + + Intermediate sequences not yet carried out and the commands in + the command stack are discarded. Sequence already in process is + completed. + """ + + return await self.send_command(module="C0", command="HD") + + async def save_all_cycle_counters(self): + """Save all cycle counters + + Save all cycle counters of the instrument + """ + + return await self.send_command(module="C0", command="AZ") + + async def set_not_stop(self, non_stop): + """Set not stop mode + + Args: + non_stop: True if non stop mode should be turned on after command is sent. + """ + + if non_stop: + # TODO: this command has no reply!!!! + return await self.send_command(module="C0", command="AB") + else: + return await self.send_command(module="C0", command="AW") + + # -------------- 3.3.2 Non volatile settings (stored in EEPROM) -------------- + + async def store_installation_data( + self, + date: datetime.datetime = datetime.datetime.now(), + serial_number: str = "0000", + ): + """Store installation data + + Args: + date: installation date. + """ + + assert len(serial_number) == 4, "serial number must be 4 chars long" + + return await self.send_command(module="C0", command="SI", si=date, sn=serial_number) + + async def store_verification_data( + self, + verification_subject: int = 0, + date: datetime.datetime = datetime.datetime.now(), + verification_status: bool = False, + ): + """Store verification data + + Args: + verification_subject: verification subject. Default 0. Must be between 0 and 24. + date: verification date. + verification_status: verification status. + """ + + assert 0 <= verification_subject <= 24, "verification_subject must be between 0 and 24" + + return await self.send_command( + module="C0", + command="AV", + vo=verification_subject, + vd=date, + vs=verification_status, + ) + + async def additional_time_stamp(self): + """Additional time stamp""" + + return await self.send_command(module="C0", command="AT") + + async def set_x_offset_x_axis_iswap(self, x_offset: int): + """Set X-offset X-axis <-> iSWAP + + Args: + x_offset: X-offset [0.1mm] + """ + + return await self.send_command(module="C0", command="AG", x_offset=x_offset) + + async def set_x_offset_x_axis_core_96_head(self, x_offset: int): + """Set X-offset X-axis <-> CoRe 96 head + + Args: + x_offset: X-offset [0.1mm] + """ + + return await self.send_command(module="C0", command="AF", x_offset=x_offset) + + async def _head96_request_x_offset(self) -> float: + """Read the X-offset i.e. X-arm carriage center <-> CoRe 96 head channel A1, in mm. + + Stored in the master EEPROM as parameter `kf` (set via the AF command), read with the + generic master-EEPROM read RA - mirroring the iSWAP rotation-drive x-offset (`kg`). + Required for deriving the head's X-arm carriage X from a target A1 X. Cached on the + backend as `head96_information.x_offset` during setup. + """ + if not self.extended_conf.left_x_drive.core_96_head_installed: + raise RuntimeError("96-head is not installed") + # 4-digit field: the head96 offset is ~10x the iSWAP's (~368 mm vs ~34 mm), so it exceeds + # 3 digits in 0.1 mm units - "kf###" silently truncates 3684 -> 368. + resp = await self.send_command(module="C0", command="RA", ra="kf", fmt="kf####") + return cast(int, resp["kf"]) / 10.0 + + async def set_x_offset_x_axis_core_nano_pipettor_head(self, x_offset: int): + """Set X-offset X-axis <-> CoRe 96 head + + Args: + x_offset: X-offset [0.1mm] + """ + + return await self.send_command(module="C0", command="AF", x_offset=x_offset) + + async def save_download_date(self, date: datetime.datetime = datetime.datetime.now()): + """Save Download date + + Args: + date: download date. Default now. + """ + + return await self.send_command( + module="C0", + command="AO", + ao=date, + ) + + async def save_technical_status_of_assemblies(self, processor_board: str, power_supply: str): + """Save technical status of assemblies + + Args: + processor_board: Processor board. Art.Nr./Rev./Ser.No. (000000/00/0000) + power_supply: Power supply. Art.Nr./Rev./Ser.No. (000000/00/0000) + """ + + return await self.send_command( + module="C0", + command="BT", + qt=processor_board + " " + power_supply, + ) + + async def set_instrument_configuration( + self, + configuration_data_1: Optional[str] = None, # TODO: configuration byte + configuration_data_2: Optional[str] = None, # TODO: configuration byte + configuration_data_3: Optional[str] = None, # TODO: configuration byte + instrument_size_in_slots_x_range: int = 54, + auto_load_size_in_slots: int = 54, + tip_waste_x_position: int = 13400, + right_x_drive_configuration_byte_1: int = 0, + right_x_drive_configuration_byte_2: int = 0, + minimal_iswap_collision_free_position: int = 3500, + maximal_iswap_collision_free_position: int = 11400, + left_x_arm_width: int = 3700, + right_x_arm_width: int = 3700, + num_pip_channels: int = 0, + num_xl_channels: int = 0, + num_robotic_channels: int = 0, + minimal_raster_pitch_of_pip_channels: int = 90, + minimal_raster_pitch_of_xl_channels: int = 360, + minimal_raster_pitch_of_robotic_channels: int = 360, + pip_maximal_y_position: int = 6065, + left_arm_minimal_y_position: int = 60, + right_arm_minimal_y_position: int = 60, + ): + """Set instrument configuration + + Args: + configuration_data_1: configuration data 1. + configuration_data_2: configuration data 2. + configuration_data_3: configuration data 3. + instrument_size_in_slots_x_range: instrument size in slots (X range). + Must be between 10 and 99. Default 54. + auto_load_size_in_slots: auto load size in slots. Must be between 10 + and 54. Default 54. + tip_waste_x_position: tip waste X-position. Must be between 1000 and + 25000. Default 13400. + right_x_drive_configuration_byte_1: right X drive configuration byte 1 (see + xl parameter bits). Must be between 0 and 1. Default 0. # TODO: this. + right_x_drive_configuration_byte_2: right X drive configuration byte 2 (see + xn parameter bits). Must be between 0 and 1. Default 0. # TODO: this. + minimal_iswap_collision_free_position: minimal iSWAP collision free position for + direct X access. For explanation of calculation see Fig. 4. Must be between 0 and 30000. + Default 3500. + maximal_iswap_collision_free_position: maximal iSWAP collision free position for + direct X access. For explanation of calculation see Fig. 4. Must be between 0 and 30000. + Default 11400 + left_x_arm_width: width of left X arm [0.1 mm]. Must be between 0 and 9999. Default 3700. + right_x_arm_width: width of right X arm [0.1 mm]. Must be between 0 and 9999. Default 3700. + num_pip_channels: number of PIP channels. Must be between 0 and 16. Default 0. + num_xl_channels: number of XL channels. Must be between 0 and 8. Default 0. + num_robotic_channels: number of Robotic channels. Must be between 0 and 8. Default 0. + minimal_raster_pitch_of_pip_channels: minimal raster pitch of PIP channels [0.1 mm]. Must + be between 0 and 999. Default 90. + minimal_raster_pitch_of_xl_channels: minimal raster pitch of XL channels [0.1 mm]. Must be + between 0 and 999. Default 360. + minimal_raster_pitch_of_robotic_channels: minimal raster pitch of Robotic channels [0.1 mm]. + Must be between 0 and 999. Default 360. + pip_maximal_y_position: PIP maximal Y position [0.1 mm]. Must be between 0 and 9999. + Default 6065. + left_arm_minimal_y_position: left arm minimal Y position [0.1 mm]. Must be between 0 and 9999. + Default 60. + right_arm_minimal_y_position: right arm minimal Y position [0.1 mm]. Must be between 0 + and 9999. Default 60. + """ + + assert 1 <= instrument_size_in_slots_x_range <= 9, ( + "instrument_size_in_slots_x_range must be between 1 and 99" + ) + assert 1 <= auto_load_size_in_slots <= 54, "auto_load_size_in_slots must be between 1 and 54" + assert 1000 <= tip_waste_x_position <= 25000, "tip_waste_x_position must be between 1 and 25000" + assert 0 <= right_x_drive_configuration_byte_1 <= 1, ( + "right_x_drive_configuration_byte_1 must be between 0 and 1" + ) + assert 0 <= right_x_drive_configuration_byte_2 <= 1, ( + "right_x_drive_configuration_byte_2 must be between 0 and must1" + ) + assert 0 <= minimal_iswap_collision_free_position <= 30000, ( + "minimal_iswap_collision_free_position must be between 0 and 30000" + ) + assert 0 <= maximal_iswap_collision_free_position <= 30000, ( + "maximal_iswap_collision_free_position must be between 0 and 30000" + ) + assert 0 <= left_x_arm_width <= 9999, "left_x_arm_width must be between 0 and 9999" + assert 0 <= right_x_arm_width <= 9999, "right_x_arm_width must be between 0 and 9999" + assert 0 <= num_pip_channels <= 16, "num_pip_channels must be between 0 and 16" + assert 0 <= num_xl_channels <= 8, "num_xl_channels must be between 0 and 8" + assert 0 <= num_robotic_channels <= 8, "num_robotic_channels must be between 0 and 8" + assert 0 <= minimal_raster_pitch_of_pip_channels <= 999, ( + "minimal_raster_pitch_of_pip_channels must be between 0 and 999" + ) + assert 0 <= minimal_raster_pitch_of_xl_channels <= 999, ( + "minimal_raster_pitch_of_xl_channels must be between 0 and 999" + ) + assert 0 <= minimal_raster_pitch_of_robotic_channels <= 999, ( + "minimal_raster_pitch_of_robotic_channels must be between 0 and 999" + ) + assert 0 <= pip_maximal_y_position <= 9999, "pip_maximal_y_position must be between 0 and 9999" + assert 0 <= left_arm_minimal_y_position <= 9999, ( + "left_arm_minimal_y_position must be between 0 and 9999" + ) + assert 0 <= right_arm_minimal_y_position <= 9999, ( + "right_arm_minimal_y_position must be between 0 and 9999" + ) + + return await self.send_command( + module="C0", + command="AK", + kb=configuration_data_1, + ka=configuration_data_2, + ke=configuration_data_3, + xt=instrument_size_in_slots_x_range, + xa=auto_load_size_in_slots, + xw=tip_waste_x_position, + xr=right_x_drive_configuration_byte_1, + xo=right_x_drive_configuration_byte_2, + xm=minimal_iswap_collision_free_position, + xx=maximal_iswap_collision_free_position, + xu=left_x_arm_width, + xv=right_x_arm_width, + kp=num_pip_channels, + kc=num_xl_channels, + kr=num_robotic_channels, + ys=minimal_raster_pitch_of_pip_channels, + kl=minimal_raster_pitch_of_xl_channels, + km=minimal_raster_pitch_of_robotic_channels, + ym=pip_maximal_y_position, + yu=left_arm_minimal_y_position, + yx=right_arm_minimal_y_position, + ) + + async def save_pip_channel_validation_status(self, validation_status: bool = False): + """Save PIP channel validation status + + Args: + validation_status: PIP channel validation status. Default False. + """ + + return await self.send_command( + module="C0", + command="AJ", + tq=validation_status, + ) + + async def save_xl_channel_validation_status(self, validation_status: bool = False): + """Save XL channel validation status + + Args: + validation_status: XL channel validation status. Default False. + """ + + return await self.send_command( + module="C0", + command="AE", + tx=validation_status, + ) + + # TODO: response + async def configure_node_names(self): + """Configure node names""" + + return await self.send_command(module="C0", command="AJ") + + async def set_deck_data(self, data_index: int = 0, data_stream: str = "0"): + """set deck data + + Args: + data_index: data index. Must be between 0 and 9. Default 0. + data_stream: data stream (12 characters). Default . + """ + + assert 0 <= data_index <= 9, "data_index must be between 0 and 9" + assert len(data_stream) == 12, "data_stream must be 12 chars" + + return await self.send_command( + module="C0", + command="DD", + vi=data_index, + vj=data_stream, + ) + + # -------------- 3.3.3 Settings query (stored in EEPROM) -------------- + + async def request_technical_status_of_assemblies(self): + """Request Technical status of assemblies""" + + # TODO: parse res + return await self.send_command(module="C0", command="QT") + + async def request_installation_data(self): + """Request installation data""" + + # TODO: parse res + return await self.send_command(module="C0", command="RI") + + async def request_device_serial_number(self) -> str: + """Request device serial number""" + return (await self.send_command("C0", "RI", fmt="si####sn&&&&sn&&&&"))["sn"] # type: ignore + + async def request_download_date(self): + """Request download date""" + + # TODO: parse res + return await self.send_command(module="C0", command="RO") + + async def request_verification_data(self, verification_subject: int = 0): + """Request download date + + Args: + verification_subject: verification subject. Must be between 0 and 24. Default 0. + """ + + assert 0 <= verification_subject <= 24, "verification_subject must be between 0 and 24" + + # TODO: parse results. + return await self.send_command(module="C0", command="RO", vo=verification_subject) + + async def request_additional_timestamp_data(self): + """Request additional timestamp data""" + + # TODO: parse res + return await self.send_command(module="C0", command="RS") + + async def request_pip_channel_validation_status(self): + """Request PIP channel validation status""" + + # TODO: parse res + return await self.send_command(module="C0", command="RJ") + + async def request_xl_channel_validation_status(self): + """Request XL channel validation status""" + + # TODO: parse res + return await self.send_command(module="C0", command="UJ") + + async def request_machine_configuration(self) -> MachineConfiguration: + """Request machine configuration (RM command) [SFCO.0035]. + + Returns the basic machine configuration including configuration data 1 (kb) + and number of PIP channels (kp). + """ + + resp = await self.send_command(module="C0", command="RM", fmt="kb**kp##") + kb = resp["kb"] + return MachineConfiguration( + pip_type_1000ul=bool(kb & (1 << 0)), + kb_iswap_installed=bool(kb & (1 << 1)), + main_front_cover_monitoring_installed=bool(kb & (1 << 2)), + auto_load_installed=bool(kb & (1 << 3)), + wash_station_1_installed=bool(kb & (1 << 4)), + wash_station_2_installed=bool(kb & (1 << 5)), + temp_controlled_carrier_1_installed=bool(kb & (1 << 6)), + temp_controlled_carrier_2_installed=bool(kb & (1 << 7)), + num_pip_channels=resp["kp"], + ) + + async def request_extended_configuration(self) -> ExtendedConfiguration: + """Request extended configuration (QM command) with X-arm geometry resolved. + + Returns the full instrument configuration matching the AK + (Set Instrument Configuration) [SFCO.0026] parameter set. Each installed X-drive's + geometry (width, travel range, workspace range) is resolved from the X-drive range + (RU) and working-envelope (UA) queries; `right_x_drive` is None when no second arm + is installed. + """ + + resp = await self.send_command( + module="C0", + command="QM", + fmt="ka******ke********xt##xa##xw#####xl**xn**xr**xo**xm#####xx#####xu####xv####kc#kr#" + + "ys###kl###km###ym####yu####yx####", + ) + + ranges = await self.request_maximal_ranges_of_x_drives() + wraps = await self.request_working_envelopes_per_arm() + + def _build_drive( + byte1: int, byte2: int, side: Literal["left", "right"], width: float + ) -> Optional[DriveConfiguration]: + wrap, workspace_range = wraps[side] + if wrap == 0: # arm not installed + return None + return DriveConfiguration( + pip_installed=bool(byte1 & (1 << 0)), + iswap_installed=bool(byte1 & (1 << 1)), + core_96_head_installed=bool(byte1 & (1 << 2)), + nano_pipettor_installed=bool(byte1 & (1 << 3)), + dispensing_head_384_installed=bool(byte1 & (1 << 4)), + xl_channels_installed=bool(byte1 & (1 << 5)), + tube_gripper_installed=bool(byte1 & (1 << 6)), + imaging_channel_installed=bool(byte1 & (1 << 7)), + robotic_channel_installed=bool(byte2 & (1 << 0)), + width=width, + x_range=ranges[side], + workspace_range=workspace_range, + ) + + left_x_drive = _build_drive(resp["xl"], resp["xn"], "left", resp["xu"] / 10) + assert left_x_drive is not None, "STAR must have a left X-arm" + + ka = resp["ka"] + return ExtendedConfiguration( + left_x_drive_large=bool(ka & (1 << 0)), + ka_core_96_head_installed=bool(ka & (1 << 1)), + right_x_drive_large=bool(ka & (1 << 2)), + pump_station_1_installed=bool(ka & (1 << 3)), + pump_station_2_installed=bool(ka & (1 << 4)), + wash_station_1_type_cr=bool(ka & (1 << 5)), + wash_station_2_type_cr=bool(ka & (1 << 6)), + left_cover_installed=bool(ka & (1 << 7)), + right_cover_installed=bool(ka & (1 << 8)), + additional_front_cover_monitoring_installed=bool(ka & (1 << 9)), + pump_station_3_installed=bool(ka & (1 << 10)), + multi_channel_nano_pipettor_installed=bool(ka & (1 << 11)), + dispensing_head_384_installed=bool(ka & (1 << 12)), + xl_channels_installed=bool(ka & (1 << 13)), + tube_gripper_installed=bool(ka & (1 << 14)), + waste_direction_left=bool(ka & (1 << 15)), + iswap_gripper_wide=bool(ka & (1 << 16)), + additional_channel_nano_pipettor_installed=bool(ka & (1 << 17)), + imaging_channel_installed=bool(ka & (1 << 18)), + robotic_channel_installed=bool(ka & (1 << 19)), + channel_order_ox_first=bool(ka & (1 << 20)), + x0_interface_ham_can=bool(ka & (1 << 21)), + park_heads_with_iswap_off=bool(ka & (1 << 22)), + configuration_data_3=resp["ke"], + instrument_size_slots=resp["xt"], + auto_load_size_slots=resp["xa"], + tip_waste_x_position=resp["xw"] / 10, + left_x_drive=left_x_drive, + right_x_drive=_build_drive(resp["xr"], resp["xo"], "right", resp["xv"] / 10), + min_iswap_collision_free_position=resp["xm"] / 10, + max_iswap_collision_free_position=resp["xx"] / 10, + left_x_arm_width=resp["xu"] / 10, + right_x_arm_width=resp["xv"] / 10, + num_xl_channels=resp["kc"], + num_robotic_channels=resp["kr"], + min_raster_pitch_pip_channels=resp["ys"] / 10, + min_raster_pitch_xl_channels=resp["kl"] / 10, + min_raster_pitch_robotic_channels=resp["km"] / 10, + pip_maximal_y_position=resp["ym"] / 10, + left_arm_min_y_position=resp["yu"] / 10, + right_arm_min_y_position=resp["yx"] / 10, + ) + + async def request_node_names(self): + """Request node names""" + + # TODO: parse res + return await self.send_command(module="C0", command="RK") + + async def request_deck_data(self): + """Request deck data""" + + # TODO: parse res + return await self.send_command(module="C0", command="VD") + + # -------------- 3.4 X-Axis control -------------- + + # -------------- 3.4.1 Movements -------------- + + async def position_left_x_arm_(self, x_position: int = 0): + """Position left X-Arm + + Collision risk! + + Args: + x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. + """ + + assert 0 <= x_position <= 30000, "x_position_ must be between 0 and 30000" + + return await self.send_command( + module="C0", + command="JX", + xs=f"{x_position:05}", + ) + + async def position_right_x_arm_(self, x_position: int = 0): + """Position right X-Arm + + Collision risk! + + Args: + x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. + """ + + assert 0 <= x_position <= 30000, "x_position_ must be between 0 and 30000" + + return await self.send_command( + module="C0", + command="JS", + xs=f"{x_position:05}", + ) + + async def move_left_x_arm_to_position_with_all_attached_components_in_z_safety_position( + self, x_position: int = 0 + ): + """Move left X-arm to position with all attached components in Z-safety position + + Args: + x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + + return await self.send_command( + module="C0", + command="KX", + xs=x_position, + ) + + async def move_right_x_arm_to_position_with_all_attached_components_in_z_safety_position( + self, x_position: int = 0 + ): + """Move right X-arm to position with all attached components in Z-safety position + + Args: + x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + + return await self.send_command( + module="C0", + command="KR", + xs=x_position, + ) + + # -------------- 3.4.2 X-Area reservation for external access -------------- + + async def occupy_and_provide_area_for_external_access( + self, + taken_area_identification_number: int = 0, + taken_area_left_margin: int = 0, + taken_area_left_margin_direction: int = 0, + taken_area_size: int = 0, + arm_preposition_mode_related_to_taken_areas: int = 0, + ): + """Occupy and provide area for external access + + Args: + taken_area_identification_number: taken area identification number. Must be between 0 and + 9999. Default 0. + taken_area_left_margin: taken area left margin. Must be between 0 and 99. Default 0. + taken_area_left_margin_direction: taken area left margin direction. 1 = negative. Must be + between 0 and 1. Default 0. + taken_area_size: taken area size. Must be between 0 and 50000. Default 0. + arm_preposition_mode_related_to_taken_areas: 0) left arm to left & right arm to right. + 1) all arms left. 2) all arms right. + """ + + assert 0 <= taken_area_identification_number <= 9999, ( + "taken_area_identification_number must be between 0 and 9999" + ) + assert 0 <= taken_area_left_margin <= 99, "taken_area_left_margin must be between 0 and 99" + assert 0 <= taken_area_left_margin_direction <= 1, ( + "taken_area_left_margin_direction must be between 0 and 1" + ) + assert 0 <= taken_area_size <= 50000, "taken_area_size must be between 0 and 50000" + assert 0 <= arm_preposition_mode_related_to_taken_areas <= 2, ( + "arm_preposition_mode_related_to_taken_areas must be between 0 and 2" + ) + + return await self.send_command( + module="C0", + command="BA", + aq=taken_area_identification_number, + al=taken_area_left_margin, + ad=taken_area_left_margin_direction, + ar=taken_area_size, + ap=arm_preposition_mode_related_to_taken_areas, + ) + + async def release_occupied_area(self, taken_area_identification_number: int = 0): + """Release occupied area + + Args: + taken_area_identification_number: taken area identification number. + Must be between 0 and 9999. Default 0. + """ + + assert 0 <= taken_area_identification_number <= 999, ( + "taken_area_identification_number must be between 0 and 9999" + ) + + return await self.send_command( + module="C0", + command="BB", + aq=taken_area_identification_number, + ) + + async def release_all_occupied_areas(self): + """Release all occupied areas""" + + return await self.send_command(module="C0", command="BC") + + # -------------- 3.4.3 X-query -------------- + + async def request_left_x_arm_position(self) -> float: + """Request left X-Arm position""" + resp_dmm = await self.send_command(module="C0", command="RX", fmt="rx#####") + return cast(float, resp_dmm["rx"]) / 10 + + async def request_right_x_arm_position(self) -> float: + """Request right X-Arm position""" + + resp_dmm = await self.send_command(module="C0", command="QX", fmt="rx#####") + return cast(float, resp_dmm["rx"]) / 10 + + async def request_maximal_ranges_of_x_drives(self) -> Dict[str, Tuple[float, float]]: + """Request the maximal travel range of each X drive. + + Returns: + The `(minimum, maximum)` X position in mm each drive can reach, keyed by side: + `{"left": (min, max), "right": (min, max)}`. + """ + resp = await self.send_command(module="C0", command="RU") + values = [int(v) / 10 for v in resp.split("ru")[-1].strip().split()] + left_min, left_max, right_min, right_max = values + return {"left": (left_min, left_max), "right": (right_min, right_max)} + + async def request_working_envelopes_per_arm( + self, + ) -> Dict[str, Tuple[float, Tuple[float, float]]]: + """Request the working envelope of each installed arm. + + Returns: + Per side, `(wrap_size, (workspace_min, workspace_max))` in mm, keyed by side. A + `wrap_size` of 0 means that arm is not installed. + """ + resp = await self.send_command(module="C0", command="UA") + values = [int(v) / 10 for v in resp.split("ua")[-1].strip().split()] + left_wrap, right_wrap, left_min, left_max, right_min, right_max = values + return { + "left": (left_wrap, (left_min, left_max)), + "right": (right_wrap, (right_min, right_max)), + } + + async def request_left_x_arm_last_collision_type(self): + """Request left X-Arm last collision type (after error 27) + + Returns: + False if present positions collide (not reachable), + True if position is never reachable. + """ + + resp = await self.send_command(module="C0", command="XX", fmt="xq#") + return resp["xq"] == 1 + + async def request_right_x_arm_last_collision_type(self) -> bool: + """Request right X-Arm last collision type (after error 27) + + Returns: + False if present positions collide (not reachable), + True if position is never reachable. + """ + + resp = await self.send_command(module="C0", command="XR", fmt="xq#") + return cast(int, resp["xq"]) == 1 + + # -------------- 3.5 Pipetting channel commands -------------- + + # -------------- 3.5.1 Initialization -------------- + + async def initialize_pip(self): + """Wrapper around initialize_pipetting_channels firmware command.""" + dy = (4050 - 2175) // (self.num_channels - 1) + y_positions = [4050 - i * dy for i in range(self.num_channels)] + + await self.initialize_pipetting_channels( + x_positions=[ + int(self.extended_conf.tip_waste_x_position * 10) + ], # Tip eject waste X position. + y_positions=y_positions, + begin_of_tip_deposit_process=int(self._channel_traversal_height * 10), + end_of_tip_deposit_process=1220, + z_position_at_end_of_a_command=3600, + tip_pattern=[True] * self.num_channels, + tip_type=4, # TODO: get from tip types + discarding_method=0, + ) + + async def initialize_pipetting_channels( + self, + x_positions: List[int] = [0], + y_positions: List[int] = [0], + begin_of_tip_deposit_process: int = 0, + end_of_tip_deposit_process: int = 0, + z_position_at_end_of_a_command: int = 3600, + tip_pattern: List[bool] = [True], + tip_type: int = 16, + discarding_method: int = 1, + ): + """Initialize pipetting channels + + Initialize pipetting channels (discard tips) + + Args: + x_positions: X-Position [0.1mm] (discard position). Must be between 0 and 25000. Default 0. + y_positions: y-Position [0.1mm] (discard position). Must be between 0 and 6500. Default 0. + begin_of_tip_deposit_process: Begin of tip deposit process (Z-discard range) [0.1mm]. Must be + between 0 and 3600. Default 0. + end_of_tip_deposit_process: End of tip deposit process (Z-discard range) [0.1mm]. Must be + between 0 and 3600. Default 0. + z-position_at_end_of_a_command: Z-Position at end of a command [0.1mm]. Must be between 0 and + 3600. Default 3600. + tip_pattern: Tip pattern ( channels involved). Default True. + tip_type: Tip type (recommended is index of longest tip see command 'TT') [0.1mm]. Must be + between 0 and 99. Default 16. + discarding_method: discarding method. 0 = place & shift (tp/ tz = tip cone end height), 1 = + drop (no shift) (tp/ tz = stop disk height). Must be between 0 and 1. Default 1. + """ + + assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" + assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" + assert 0 <= begin_of_tip_deposit_process <= 3600, ( + "begin_of_tip_deposit_process must be between 0 and 3600" + ) + assert 0 <= end_of_tip_deposit_process <= 3600, ( + "end_of_tip_deposit_process must be between 0 and 3600" + ) + assert 0 <= z_position_at_end_of_a_command <= 3600, ( + "z_position_at_end_of_a_command must be between 0 and 3600" + ) + assert 0 <= tip_type <= 99, "tip must be between 0 and 99" + assert 0 <= discarding_method <= 1, "discarding_method must be between 0 and 1" + + return await self.send_command( + module="C0", + command="DI", + read_timeout=120, + xp=[f"{xp:05}" for xp in x_positions], + yp=[f"{yp:04}" for yp in y_positions], + tp=f"{begin_of_tip_deposit_process:04}", + tz=f"{end_of_tip_deposit_process:04}", + te=f"{z_position_at_end_of_a_command:04}", + tm=[f"{tm:01}" for tm in tip_pattern], + tt=f"{tip_type:02}", + ti=discarding_method, + ) + + # -------------- 3.5.2 Tip handling commands using PIP -------------- + + @need_iswap_parked + async def pick_up_tip( + self, + x_positions: List[int], + y_positions: List[int], + tip_pattern: List[bool], + tip_type_idx: int, + begin_tip_pick_up_process: int = 0, + end_tip_pick_up_process: int = 0, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + pickup_method: TipPickupMethod = TipPickupMethod.OUT_OF_RACK, + ): + """Tip Pick-up + + Args: + x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. + y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. + tip_pattern: Tip pattern (channels involved). + tip_type_idx: Tip type. + begin_tip_pick_up_process: Begin of tip picking up process (Z- range) [0.1mm]. Must be + between 0 and 3600. Default 0. + end_tip_pick_up_process: End of tip picking up process (Z- range) [0.1mm]. Must be + between 0 and 3600. Default 0. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning + of a command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). + Must be between 0 and 3600. Default 3600. + pickup_method: Pick up method. + """ + + assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" + assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" + assert 0 <= begin_tip_pick_up_process <= 3600, ( + "begin_tip_pick_up_process must be between 0 and 3600" + ) + assert 0 <= end_tip_pick_up_process <= 3600, ( + "end_tip_pick_up_process must be between 0 and 3600" + ) + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + ) + + return await self.send_command( + module="C0", + command="TP", + tip_pattern=tip_pattern, + read_timeout=max(120, self.read_timeout), + xp=[f"{x:05}" for x in x_positions], + yp=[f"{y:04}" for y in y_positions], + tm=tip_pattern, + tt=f"{tip_type_idx:02}", + tp=f"{begin_tip_pick_up_process:04}", + tz=f"{end_tip_pick_up_process:04}", + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + td=pickup_method.value, + ) + + @need_iswap_parked + async def discard_tip( + self, + x_positions: List[int], + y_positions: List[int], + tip_pattern: List[bool], + begin_tip_deposit_process: int = 0, + end_tip_deposit_process: int = 0, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + z_position_at_end_of_a_command: int = 3600, + discarding_method: TipDropMethod = TipDropMethod.DROP, + ): + """discard tip + + Args: + x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. + y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. + tip_pattern: Tip pattern (channels involved). Must be between 0 and 1. Default 1. + begin_tip_deposit_process: Begin of tip deposit process (Z- range) [0.1mm]. Must be between + 0 and 3600. Default 0. + end_tip_deposit_process: End of tip deposit process (Z- range) [0.1mm]. Must be between 0 + and 3600. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must + be between 0 and 3600. + z-position_at_end_of_a_command: Z-Position at end of a command [0.1mm]. + Must be between 0 and 3600. + discarding_method: Pick up method Pick up method. 0 = auto selection (see command TT + parameter tu) 1 = pick up out of rack. 2 = pick up out of wash liquid (slowly). Must be + between 0 and 2. + + If discarding is PLACE_SHIFT (0), tp/ tz = tip cone end height. + Otherwise, tp/ tz = stop disk height. + """ + + assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" + assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" + assert 0 <= begin_tip_deposit_process <= 3600, ( + "begin_tip_deposit_process must be between 0 and 3600" + ) + assert 0 <= end_tip_deposit_process <= 3600, ( + "end_tip_deposit_process must be between 0 and 3600" + ) + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + ) + assert 0 <= z_position_at_end_of_a_command <= 3600, ( + "z_position_at_end_of_a_command must be between 0 and 3600" + ) + + return await self.send_command( + module="C0", + command="TR", + tip_pattern=tip_pattern, + read_timeout=max(120, self.read_timeout), + xp=[f"{x:05}" for x in x_positions], + yp=[f"{y:04}" for y in y_positions], + tm=tip_pattern, + tp=begin_tip_deposit_process, + tz=end_tip_deposit_process, + th=minimum_traverse_height_at_beginning_of_a_command, + te=z_position_at_end_of_a_command, + ti=discarding_method.value, + ) + + # TODO:(command:TW) Tip Pick-up for DC wash procedure + + # -------------- 3.5.3 Liquid handling commands using PIP -------------- + + # TODO:(command:DC) Set multiple dispense values using PIP + + @need_iswap_parked + async def aspirate_pip( + self, + aspiration_type: List[int] = [0], + tip_pattern: List[bool] = [True], + x_positions: List[int] = [0], + y_positions: List[int] = [0], + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + min_z_endpos: int = 3600, + lld_search_height: List[int] = [0], + clot_detection_height: List[int] = [60], + liquid_surface_no_lld: List[int] = [3600], + pull_out_distance_transport_air: List[int] = [50], + second_section_height: List[int] = [0], + second_section_ratio: List[int] = [0], + minimum_height: List[int] = [3600], + immersion_depth: List[int] = [0], + immersion_depth_direction: List[int] = [0], + surface_following_distance: List[int] = [0], + aspiration_volumes: List[int] = [0], + aspiration_speed: List[int] = [500], + transport_air_volume: List[int] = [0], + blow_out_air_volume: List[int] = [200], + pre_wetting_volume: List[int] = [0], + lld_mode: List[int] = [1], + gamma_lld_sensitivity: List[int] = [1], + dp_lld_sensitivity: List[int] = [1], + aspirate_position_above_z_touch_off: List[int] = [5], + detection_height_difference_for_dual_lld: List[int] = [0], + swap_speed: List[int] = [100], + settling_time: List[int] = [5], + mix_volume: List[int] = [0], + mix_cycles: List[int] = [0], + mix_position_from_liquid_surface: List[int] = [250], + mix_speed: List[int] = [500], + mix_surface_following_distance: List[int] = [0], + limit_curve_index: List[int] = [0], + tadm_algorithm: bool = False, + recording_mode: int = 0, + # For second section aspiration only + use_2nd_section_aspiration: List[bool] = [False], + retract_height_over_2nd_section_to_empty_tip: List[int] = [60], + dispensation_speed_during_emptying_tip: List[int] = [468], + dosing_drive_speed_during_2nd_section_search: List[int] = [468], + z_drive_speed_during_2nd_section_search: List[int] = [215], + cup_upper_edge: List[int] = [3600], + # deprecated, remove >2026-06 + ratio_liquid_rise_to_tip_deep_in: Optional[List[int]] = None, + immersion_depth_2nd_section: Optional[List[int]] = None, + ): + """aspirate pip + + Aspiration of liquid using PIP. + + It's not really clear what second section aspiration is, but it does not seem to be used + very often. Probably safe to ignore it. + + LLD restrictions! + - "dP and Dual LLD" are used in aspiration only. During dispensation LLD is set to OFF. + - "side touch off" turns LLD & "Z touch off" to OFF , is not available for simultaneous + Asp/Disp. command + + Args: + aspiration_type: Type of aspiration (0 = simple;1 = sequence; 2 = cup emptied). + Must be between 0 and 2. Default 0. + tip_pattern: Tip pattern (channels involved). Default True. + x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. + y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of + a command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). + Must be between 0 and 3600. Default 3600. + min_z_endpos: Minimum z-Position at end of a command [0.1 mm] (refers to all channels + independent of tip pattern parameter 'tm'). Must be between 0 and 3600. Default 3600. + lld_search_height: LLD search height [0.1 mm]. Must be between 0 and 3600. Default 0. + clot_detection_height: Check height of clot detection above current surface (as computed) + of the liquid [0.1mm]. Must be between 0 and 500. Default 60. + liquid_surface_no_lld: Liquid surface at function without LLD [0.1mm]. Must be between 0 + and 3600. Default 3600. + pull_out_distance_transport_air: pull out distance to take transport air in function + without LLD [0.1mm]. Must be between 0 and 3600. Default 50. + second_section_height: Tube 2nd section height measured from "zx" [0.1mm]. Must be + between 0 and 3600. Default 0. + second_section_ratio: Tube 2nd section ratio (see Fig. 2 in fw guide). Must be between + 0 and 10000. Default 0. + minimum_height: Minimum height (maximum immersion depth) [0.1 mm]. Must be between 0 and + 3600. Default 3600. + immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0. + immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out + of liquid). Must be between 0 and 1. Default 0. + surface_following_distance: Surface following distance during aspiration [0.1mm]. Must + be between 0 and 3600. Default 0. + aspiration_volumes: Aspiration volume [0.1ul]. Must be between 0 and 12500. Default 0. + aspiration_speed: Aspiration speed [0.1ul/s]. Must be between 4 and 5000. Default 500. + transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0. + blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 9999. Default 200. + pre_wetting_volume: Pre-wetting volume. Must be between 0 and 999. Default 0. + lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be + between 0 and 4. Default 1. + gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and + 4. Default 1. + dp_lld_sensitivity: delta p LLD sensitivity (1= high, 4=low). Must be between 1 and + 4. Default 1. + aspirate_position_above_z_touch_off: aspirate position above Z touch off [0.1mm]. Must + be between 0 and 100. Default 5. + detection_height_difference_for_dual_lld: Difference in detection height for dual + LLD [0.1 mm]. Must be between 0 and 99. Default 0. + swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1600. + Default 100. + settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5. + mix_volume: mix volume [0.1ul]. Must be between 0 and 12500. Default 0 + mix_cycles: Number of mix cycles. Must be between 0 and 99. Default 0. + mix_position_from_liquid_surface: mix position in Z- direction from + liquid surface (LLD or absolute terms) [0.1mm]. Must be between 0 and 900. Default 250. + mix_speed: Speed of mix [0.1ul/s]. Must be between 4 and 5000. + Default 500. + mix_surface_following_distance: Surface following distance during + mix [0.1mm]. Must be between 0 and 3600. Default 0. + limit_curve_index: limit curve index. Must be between 0 and 999. Default 0. + tadm_algorithm: TADM algorithm. Default False. + recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. Must + be between 0 and 2. Default 0. + use_2nd_section_aspiration: 2nd section aspiration. Default False. + retract_height_over_2nd_section_to_empty_tip: Retract height over 2nd section to empty + tip [0.1mm]. Must be between 0 and 3600. Default 60. + dispensation_speed_during_emptying_tip: Dispensation speed during emptying tip [0.1ul/s] + Must be between 4 and 5000. Default 468. + dosing_drive_speed_during_2nd_section_search: Dosing drive speed during 2nd section + search [0.1ul/s]. Must be between 4 and 5000. Default 468. + z_drive_speed_during_2nd_section_search: Z drive speed during 2nd section search [0.1mm/s]. + Must be between 3 and 1600. Default 215. + cup_upper_edge: Cup upper edge [0.1mm]. Must be between 0 and 3600. Default 3600. + """ + + if ratio_liquid_rise_to_tip_deep_in is not None: + warnings.warn( + "ratio_liquid_rise_to_tip_deep_in is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + if immersion_depth_2nd_section is not None: + warnings.warn( + "immersion_depth_2nd_section is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + + assert all(0 <= x <= 2 for x in aspiration_type), "aspiration_type must be between 0 and 2" + assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" + assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + ) + assert 0 <= min_z_endpos <= 3600, "min_z_endpos must be between 0 and 3600" + assert all(0 <= x <= 3600 for x in lld_search_height), ( + "lld_search_height must be between 0 and 3600" + ) + assert all(0 <= x <= 500 for x in clot_detection_height), ( + "clot_detection_height must be between 0 and 500" + ) + assert all(0 <= x <= 3600 for x in liquid_surface_no_lld), ( + "liquid_surface_no_lld must be between 0 and 3600" + ) + assert all(0 <= x <= 3600 for x in pull_out_distance_transport_air), ( + "pull_out_distance_transport_air must be between 0 and 3600" + ) + assert all(0 <= x <= 3600 for x in second_section_height), ( + "second_section_height must be between 0 and 3600" + ) + assert all(0 <= x <= 10000 for x in second_section_ratio), ( + "second_section_ratio must be between 0 and 10000" + ) + assert all(0 <= x <= 3600 for x in minimum_height), "minimum_height must be between 0 and 3600" + assert all(0 <= x <= 3600 for x in immersion_depth), ( + "immersion_depth must be between 0 and 3600" + ) + assert all(0 <= x <= 1 for x in immersion_depth_direction), ( + "immersion_depth_direction must be between 0 and 1" + ) + assert all(0 <= x <= 3600 for x in surface_following_distance), ( + "surface_following_distance must be between 0 and 3600" + ) + assert all(0 <= x <= 12500 for x in aspiration_volumes), ( + "aspiration_volumes must be between 0 and 12500" + ) + assert all(4 <= x <= 5000 for x in aspiration_speed), ( + "aspiration_speed must be between 4 and 5000" + ) + assert all(0 <= x <= 500 for x in transport_air_volume), ( + "transport_air_volume must be between 0 and 500" + ) + assert all(0 <= x <= 9999 for x in blow_out_air_volume), ( + "blow_out_air_volume must be between 0 and 9999" + ) + assert all(0 <= x <= 999 for x in pre_wetting_volume), ( + "pre_wetting_volume must be between 0 and 999" + ) + assert all(0 <= x <= 4 for x in lld_mode), "lld_mode must be between 0 and 4" + assert all(1 <= x <= 4 for x in gamma_lld_sensitivity), ( + "gamma_lld_sensitivity must be between 1 and 4" + ) + assert all(1 <= x <= 4 for x in dp_lld_sensitivity), ( + "dp_lld_sensitivity must be between 1 and 4" + ) + assert all(0 <= x <= 100 for x in aspirate_position_above_z_touch_off), ( + "aspirate_position_above_z_touch_off must be between 0 and 100" + ) + assert all(0 <= x <= 99 for x in detection_height_difference_for_dual_lld), ( + "detection_height_difference_for_dual_lld must be between 0 and 99" + ) + assert all(3 <= x <= 1600 for x in swap_speed), "swap_speed must be between 3 and 1600" + assert all(0 <= x <= 99 for x in settling_time), "settling_time must be between 0 and 99" + assert all(0 <= x <= 12500 for x in mix_volume), "mix_volume must be between 0 and 12500" + assert all(0 <= x <= 99 for x in mix_cycles), "mix_cycles must be between 0 and 99" + assert all(0 <= x <= 900 for x in mix_position_from_liquid_surface), ( + "mix_position_from_liquid_surface must be between 0 and 900" + ) + assert all(4 <= x <= 5000 for x in mix_speed), "mix_speed must be between 4 and 5000" + assert all(0 <= x <= 3600 for x in mix_surface_following_distance), ( + "mix_surface_following_distance must be between 0 and 3600" + ) + assert all(0 <= x <= 999 for x in limit_curve_index), ( + "limit_curve_index must be between 0 and 999" + ) + assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" + assert all(0 <= x <= 3600 for x in retract_height_over_2nd_section_to_empty_tip), ( + "retract_height_over_2nd_section_to_empty_tip must be between 0 and 3600" + ) + assert all(4 <= x <= 5000 for x in dispensation_speed_during_emptying_tip), ( + "dispensation_speed_during_emptying_tip must be between 4 and 5000" + ) + assert all(4 <= x <= 5000 for x in dosing_drive_speed_during_2nd_section_search), ( + "dosing_drive_speed_during_2nd_section_search must be between 4 and 5000" + ) + assert all(3 <= x <= 1600 for x in z_drive_speed_during_2nd_section_search), ( + "z_drive_speed_during_2nd_section_search must be between 3 and 1600" + ) + assert all(0 <= x <= 3600 for x in cup_upper_edge), "cup_upper_edge must be between 0 and 3600" + + return await self.send_command( + module="C0", + command="AS", + tip_pattern=tip_pattern, + read_timeout=max(300, self.read_timeout), + at=[f"{at:01}" for at in aspiration_type], + tm=tip_pattern, + xp=[f"{xp:05}" for xp in x_positions], + yp=[f"{yp:04}" for yp in y_positions], + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + te=f"{min_z_endpos:04}", + lp=[f"{lp:04}" for lp in lld_search_height], + ch=[f"{ch:03}" for ch in clot_detection_height], + zl=[f"{zl:04}" for zl in liquid_surface_no_lld], + po=[f"{po:04}" for po in pull_out_distance_transport_air], + zu=[f"{zu:04}" for zu in second_section_height], + zr=[f"{zr:05}" for zr in second_section_ratio], + zx=[f"{zx:04}" for zx in minimum_height], + ip=[f"{ip:04}" for ip in immersion_depth], + it=[f"{it}" for it in immersion_depth_direction], + fp=[f"{fp:04}" for fp in surface_following_distance], + av=[f"{av:05}" for av in aspiration_volumes], + as_=[f"{as_:04}" for as_ in aspiration_speed], + ta=[f"{ta:03}" for ta in transport_air_volume], + ba=[f"{ba:04}" for ba in blow_out_air_volume], + oa=[f"{oa:03}" for oa in pre_wetting_volume], + lm=[f"{lm}" for lm in lld_mode], + ll=[f"{ll}" for ll in gamma_lld_sensitivity], + lv=[f"{lv}" for lv in dp_lld_sensitivity], + zo=[f"{zo:03}" for zo in aspirate_position_above_z_touch_off], + ld=[f"{ld:02}" for ld in detection_height_difference_for_dual_lld], + de=[f"{de:04}" for de in swap_speed], + wt=[f"{wt:02}" for wt in settling_time], + mv=[f"{mv:05}" for mv in mix_volume], + mc=[f"{mc:02}" for mc in mix_cycles], + mp=[f"{mp:03}" for mp in mix_position_from_liquid_surface], + ms=[f"{ms:04}" for ms in mix_speed], + mh=[f"{mh:04}" for mh in mix_surface_following_distance], + gi=[f"{gi:03}" for gi in limit_curve_index], + gj=tadm_algorithm, + gk=recording_mode, + lk=[1 if lk else 0 for lk in use_2nd_section_aspiration], + ik=[f"{ik:04}" for ik in retract_height_over_2nd_section_to_empty_tip], + sd=[f"{sd:04}" for sd in dispensation_speed_during_emptying_tip], + se=[f"{se:04}" for se in dosing_drive_speed_during_2nd_section_search], + sz=[f"{sz:04}" for sz in z_drive_speed_during_2nd_section_search], + io=[f"{io:04}" for io in cup_upper_edge], + ) + + @need_iswap_parked + async def dispense_pip( + self, + tip_pattern: List[bool], + dispensing_mode: List[int] = [0], + x_positions: List[int] = [0], + y_positions: List[int] = [0], + minimum_height: List[int] = [3600], + lld_search_height: List[int] = [0], + liquid_surface_no_lld: List[int] = [3600], + pull_out_distance_transport_air: List[int] = [50], + immersion_depth: List[int] = [0], + immersion_depth_direction: List[int] = [0], + surface_following_distance: List[int] = [0], + second_section_height: List[int] = [0], + second_section_ratio: List[int] = [0], + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + min_z_endpos: int = 3600, # + dispense_volumes: List[int] = [0], + dispense_speed: List[int] = [500], + cut_off_speed: List[int] = [250], + stop_back_volume: List[int] = [0], + transport_air_volume: List[int] = [0], + blow_out_air_volume: List[int] = [200], + lld_mode: List[int] = [1], + side_touch_off_distance: int = 1, + dispense_position_above_z_touch_off: List[int] = [5], + gamma_lld_sensitivity: List[int] = [1], + dp_lld_sensitivity: List[int] = [1], + swap_speed: List[int] = [100], + settling_time: List[int] = [5], + mix_volume: List[int] = [0], + mix_cycles: List[int] = [0], + mix_position_from_liquid_surface: List[int] = [250], + mix_speed: List[int] = [500], + mix_surface_following_distance: List[int] = [0], + limit_curve_index: List[int] = [0], + tadm_algorithm: bool = False, + recording_mode: int = 0, + ): + """dispense pip + + Dispensing of liquid using PIP. + + LLD restrictions! + - "dP and Dual LLD" are used in aspiration only. During dispensation all pressure-based + LLD is set to OFF. + - "side touch off" turns LLD & "Z touch off" to OFF , is not available for simultaneous + Asp/Disp. command + + Args: + dispensing_mode: Type of dispensing mode 0 = Partial volume in jet mode + 1 = Blow out in jet mode 2 = Partial volume at surface + 3 = Blow out at surface 4 = Empty tip at fix position. + tip_pattern: Tip pattern (channels involved). Default True. + x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. + y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. + minimum_height: Minimum height (maximum immersion depth) [0.1 mm]. Must be between 0 and + 3600. Default 3600. + lld_search_height: LLD search height [0.1 mm]. Must be between 0 and 3600. Default 0. + liquid_surface_no_lld: Liquid surface at function without LLD [0.1mm]. Must be between 0 and + 3600. Default 3600. + pull_out_distance_transport_air: pull out distance to take transport air in function without + LLD [0.1mm]. Must be between 0 and 3600. Default 50. + immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0. + immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out of + liquid). Must be between 0 and 1. Default 0. + surface_following_distance: Surface following distance during aspiration [0.1mm]. Must be + between 0 and 3600. Default 0. + second_section_height: Tube 2nd section height measured from "zx" [0.1mm]. Must be between + 0 and 3600. Default 0. + second_section_ratio: Tube 2nd section ratio (see Fig. 2 in fw guide). Must be between 0 and + 10000. Default 0. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be + between 0 and 3600. Default 3600. + min_z_endpos: Minimum z-Position at end of a command [0.1 mm] (refers to all channels + independent of tip pattern parameter 'tm'). Must be between 0 and 3600. Default 3600. + dispense_volumes: Dispense volume [0.1ul]. Must be between 0 and 12500. Default 0. + dispense_speed: Dispense speed [0.1ul/s]. Must be between 4 and 5000. Default 500. + cut_off_speed: Cut-off speed [0.1ul/s]. Must be between 4 and 5000. Default 250. + stop_back_volume: Stop back volume [0.1ul]. Must be between 0 and 180. Default 0. + transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0. + blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 9999. Default 200. + lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be between 0 + and 4. Default 1. + side_touch_off_distance: side touch off distance [0.1 mm] (0 = OFF). Must be between 0 and 45. + Default 1. + dispense_position_above_z_touch_off: dispense position above Z touch off [0.1 s] (0 = OFF) + Turns LLD & Z touch off to OFF if ON!. Must be between 0 and 100. Default 5. + gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and 4. + Default 1. + dp_lld_sensitivity: delta p LLD sensitivity (1= high, 4=low). Must be between 1 and 4. + Default 1. + swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1600. + Default 100. + settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5. + mix_volume: Mix volume [0.1ul]. Must be between 0 and 12500. Default 0. + mix_cycles: Number of mix cycles. Must be between 0 and 99. Default 0. + mix_position_from_liquid_surface: Mix position in Z- direction from liquid surface (LLD or + absolute terms) [0.1mm]. Must be between 0 and 900. Default 250. + mix_speed: Speed of mixing [0.1ul/s]. Must be between 4 and 5000. Default 500. + mix_surface_following_distance: Surface following distance during mixing [0.1mm]. Must be + between 0 and 3600. Default 0. + limit_curve_index: limit curve index. Must be between 0 and 999. Default 0. + tadm_algorithm: TADM algorithm. Default False. + recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. Must + be between 0 and 2. Default 0. + """ + + assert all(0 <= x <= 4 for x in dispensing_mode), "dispensing_mode must be between 0 and 4" + assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" + assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" + assert any(0 <= x <= 3600 for x in minimum_height), "minimum_height must be between 0 and 3600" + assert any(0 <= x <= 3600 for x in lld_search_height), ( + "lld_search_height must be between 0 and 3600" + ) + assert any(0 <= x <= 3600 for x in liquid_surface_no_lld), ( + "liquid_surface_no_lld must be between 0 and 3600" + ) + assert any(0 <= x <= 3600 for x in pull_out_distance_transport_air), ( + "pull_out_distance_transport_air must be between 0 and 3600" + ) + assert any(0 <= x <= 3600 for x in immersion_depth), ( + "immersion_depth must be between 0 and 3600" + ) + assert any(0 <= x <= 1 for x in immersion_depth_direction), ( + "immersion_depth_direction must be between 0 and 1" + ) + assert any(0 <= x <= 3600 for x in surface_following_distance), ( + "surface_following_distance must be between 0 and 3600" + ) + assert any(0 <= x <= 3600 for x in second_section_height), ( + "second_section_height must be between 0 and 3600" + ) + assert any(0 <= x <= 10000 for x in second_section_ratio), ( + "second_section_ratio must be between 0 and 10000" + ) + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + ) + assert 0 <= min_z_endpos <= 3600, "min_z_endpos must be between 0 and 3600" + assert any(0 <= x <= 12500 for x in dispense_volumes), ( + "dispense_volume must be between 0 and 12500" + ) + assert any(4 <= x <= 5000 for x in dispense_speed), "dispense_speed must be between 4 and 5000" + assert any(4 <= x <= 5000 for x in cut_off_speed), "cut_off_speed must be between 4 and 5000" + assert any(0 <= x <= 180 for x in stop_back_volume), ( + "stop_back_volume must be between 0 and 180" + ) + assert any(0 <= x <= 500 for x in transport_air_volume), ( + "transport_air_volume must be between 0 and 500" + ) + assert any(0 <= x <= 9999 for x in blow_out_air_volume), ( + "blow_out_air_volume must be between 0 and 9999" + ) + assert any(0 <= x <= 4 for x in lld_mode), "lld_mode must be between 0 and 4" + assert 0 <= side_touch_off_distance <= 45, "side_touch_off_distance must be between 0 and 45" + assert any(0 <= x <= 100 for x in dispense_position_above_z_touch_off), ( + "dispense_position_above_z_touch_off must be between 0 and 100" + ) + assert any(1 <= x <= 4 for x in gamma_lld_sensitivity), ( + "gamma_lld_sensitivity must be between 1 and 4" + ) + assert any(1 <= x <= 4 for x in dp_lld_sensitivity), ( + "dp_lld_sensitivity must be between 1 and 4" + ) + assert any(3 <= x <= 1600 for x in swap_speed), "swap_speed must be between 3 and 1600" + assert any(0 <= x <= 99 for x in settling_time), "settling_time must be between 0 and 99" + assert any(0 <= x <= 12500 for x in mix_volume), "mix_volume must be between 0 and 12500" + assert any(0 <= x <= 99 for x in mix_cycles), "mix_cycles must be between 0 and 99" + assert any(0 <= x <= 900 for x in mix_position_from_liquid_surface), ( + "mix_position_from_liquid_surface must be between 0 and 900" + ) + assert any(4 <= x <= 5000 for x in mix_speed), "mix_speed must be between 4 and 5000" + assert any(0 <= x <= 3600 for x in mix_surface_following_distance), ( + "mix_surface_following_distance must be between 0 and 3600" + ) + assert any(0 <= x <= 999 for x in limit_curve_index), ( + "limit_curve_index must be between 0 and 999" + ) + assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" + + return await self.send_command( + module="C0", + command="DS", + tip_pattern=tip_pattern, + read_timeout=max(300, self.read_timeout), + dm=[f"{dm:01}" for dm in dispensing_mode], + tm=[f"{tm:01}" for tm in tip_pattern], + xp=[f"{xp:05}" for xp in x_positions], + yp=[f"{yp:04}" for yp in y_positions], + zx=[f"{zx:04}" for zx in minimum_height], + lp=[f"{lp:04}" for lp in lld_search_height], + zl=[f"{zl:04}" for zl in liquid_surface_no_lld], + po=[f"{po:04}" for po in pull_out_distance_transport_air], + ip=[f"{ip:04}" for ip in immersion_depth], + it=[f"{it:01}" for it in immersion_depth_direction], + fp=[f"{fp:04}" for fp in surface_following_distance], + zu=[f"{zu:04}" for zu in second_section_height], + zr=[f"{zr:05}" for zr in second_section_ratio], + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + te=f"{min_z_endpos:04}", + dv=[f"{dv:05}" for dv in dispense_volumes], + ds=[f"{ds:04}" for ds in dispense_speed], + ss=[f"{ss:04}" for ss in cut_off_speed], + rv=[f"{rv:03}" for rv in stop_back_volume], + ta=[f"{ta:03}" for ta in transport_air_volume], + ba=[f"{ba:04}" for ba in blow_out_air_volume], + lm=[f"{lm:01}" for lm in lld_mode], + dj=f"{side_touch_off_distance:02}", # + zo=[f"{zo:03}" for zo in dispense_position_above_z_touch_off], + ll=[f"{ll:01}" for ll in gamma_lld_sensitivity], + lv=[f"{lv:01}" for lv in dp_lld_sensitivity], + de=[f"{de:04}" for de in swap_speed], + wt=[f"{wt:02}" for wt in settling_time], + mv=[f"{mv:05}" for mv in mix_volume], + mc=[f"{mc:02}" for mc in mix_cycles], + mp=[f"{mp:03}" for mp in mix_position_from_liquid_surface], + ms=[f"{ms:04}" for ms in mix_speed], + mh=[f"{mh:04}" for mh in mix_surface_following_distance], + gi=[f"{gi:03}" for gi in limit_curve_index], + gj=tadm_algorithm, # + gk=recording_mode, # + ) + + # TODO:(command:DA) Simultaneous aspiration & dispensation of liquid + + # TODO:(command:DF) Dispense on fly using PIP (Partial volume in jet mode) + + # TODO:(command:LW) DC Wash procedure using PIP + + # -------------- 3.5.5 CoRe gripper commands -------------- + + def _get_core_front_back(self): + core_grippers = self.deck.get_resource("core_grippers") + assert isinstance(core_grippers, HamiltonCoreGrippers), "core_grippers must be CoReGrippers" + back_channel_y_center = int( + ( + core_grippers.get_location_wrt(self.deck).y + + core_grippers.back_channel_y_center + + self.core_adjustment.y + ) + ) + front_channel_y_center = int( + ( + core_grippers.get_location_wrt(self.deck).y + + core_grippers.front_channel_y_center + + self.core_adjustment.y + ) + ) + assert back_channel_y_center > front_channel_y_center, ( + "back_channel_y_center must be greater than front_channel_y_center" + ) + assert front_channel_y_center > self.extended_conf.left_arm_min_y_position, ( + f"front_channel_y_center must be greater than {self.extended_conf.left_arm_min_y_position}mm" + ) + return back_channel_y_center, front_channel_y_center + + def _get_core_x(self) -> float: + """Get the X coordinate for the CoRe grippers based on deck size and adjustment.""" + core_grippers = self.deck.get_resource("core_grippers") + assert isinstance(core_grippers, HamiltonCoreGrippers), "core_grippers must be CoReGrippers" + return core_grippers.get_location_wrt(self.deck).x + self.core_adjustment.x + + async def get_core(self, p1: int, p2: int): + warnings.warn("Deprecated. Use pick_up_core_gripper_tools instead.", DeprecationWarning) + assert p1 + 1 == p2, "p2 must be p1 + 1" + return await self.pick_up_core_gripper_tools(front_channel=p2 - 1) # p1 here is 1-indexed + + @need_iswap_parked + async def pick_up_core_gripper_tools( + self, + front_channel: int, + front_offset: Optional[Coordinate] = None, + back_offset: Optional[Coordinate] = None, + ): + """Get CoRe gripper tool from wasteblock mount.""" + + if not 0 < front_channel < self.num_channels: + raise ValueError(f"front_channel must be between 1 and {self.num_channels - 1} (inclusive)") + back_channel = front_channel - 1 + + # Only enforce x equality if both offsets are explicitly provided. + if front_offset is not None and back_offset is not None and front_offset.x != back_offset.x: + raise ValueError("front_offset.x and back_offset.x must be the same") + + xs = self._get_core_x() + (front_offset.x if front_offset is not None else 0) + + back_channel_y_center, front_channel_y_center = self._get_core_front_back() + if back_offset is not None: + back_channel_y_center += back_offset.y + if front_offset is not None: + front_channel_y_center += front_offset.y + + if front_offset is not None and back_offset is not None and front_offset.z != back_offset.z: + raise ValueError("front_offset.z and back_offset.z must be the same") + z_offset = 0 if front_offset is None else front_offset.z + begin_z_coord = round(235.0 + self.core_adjustment.z + z_offset) + end_z_coord = round(225.0 + self.core_adjustment.z + z_offset) + + command_output = await self.send_command( + module="C0", + command="ZT", + xs=f"{round(xs * 10):05}", + xd="0", + ya=f"{round(back_channel_y_center * 10):04}", + yb=f"{round(front_channel_y_center * 10):04}", + pa=f"{back_channel + 1:02}", # star is 1-indexed + pb=f"{front_channel + 1:02}", # star is 1-indexed + tp=f"{round(begin_z_coord * 10):04}", + tz=f"{round(end_z_coord * 10):04}", + th=round(self._iswap_traversal_height * 10), + tt="14", + ) + self._core_parked = False + return command_output + + async def put_core(self): + warnings.warn("Deprecated. Use return_core_gripper_tools instead.", DeprecationWarning) + return await self.return_core_gripper_tools() + + @need_iswap_parked + async def return_core_gripper_tools( + self, + front_offset: Optional[Coordinate] = None, + back_offset: Optional[Coordinate] = None, + ): + """Put CoRe gripper tool at wasteblock mount.""" + + # Only enforce x equality if both offsets are explicitly provided. + if front_offset is not None and back_offset is not None and back_offset.x != front_offset.x: + raise ValueError("back_offset.x and front_offset.x must be the same") + + xs = self._get_core_x() + (front_offset.x if front_offset is not None else 0) + + back_channel_y_center, front_channel_y_center = self._get_core_front_back() + if back_offset is not None: + back_channel_y_center += back_offset.y + if front_offset is not None: + front_channel_y_center += front_offset.y + + if front_offset is not None and back_offset is not None and back_offset.z != front_offset.z: + raise ValueError("back_offset.z and front_offset.z must be the same") + z_offset = 0 if front_offset is None else front_offset.z + begin_z_coord = round(215.0 + self.core_adjustment.z + z_offset) + end_z_coord = round(205.0 + self.core_adjustment.z + z_offset) + + command_output = await self.send_command( + module="C0", + command="ZS", + xs=f"{round(xs * 10):05}", + xd="0", + ya=f"{round(back_channel_y_center * 10):04}", + yb=f"{round(front_channel_y_center * 10):04}", + tp=f"{round(begin_z_coord * 10):04}", + tz=f"{round(end_z_coord * 10):04}", + th=round(self._iswap_traversal_height * 10), + te=round(self._iswap_traversal_height * 10), + ) + self._core_parked = True + return command_output + + async def core_open_gripper(self): + """Open CoRe gripper tool.""" + return await self.send_command(module="C0", command="ZO") + + @need_iswap_parked + async def core_get_plate( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + y_gripping_speed: int = 50, + z_position: int = 0, + z_speed: int = 500, + open_gripper_position: int = 0, + plate_width: int = 0, + grip_strength: int = 15, + minimum_traverse_height_at_beginning_of_a_command: int = 2750, + minimum_z_position_at_the_command_end: int = 2750, + ): + """Get plate with CoRe gripper tool from wasteblock mount.""" + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + assert 0 <= y_gripping_speed <= 3700, "y_gripping_speed must be between 0 and 3700" + assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" + assert 0 <= z_speed <= 1287, "z_speed must be between 0 and 1287" + assert 0 <= open_gripper_position <= 9999, "open_gripper_position must be between 0 and 9999" + assert 0 <= plate_width <= 9999, "plate_width must be between 0 and 9999" + assert 0 <= grip_strength <= 99, "grip_strength must be between 0 and 99" + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + ) + assert 0 <= minimum_z_position_at_the_command_end <= 3600, ( + "minimum_z_position_at_the_command_end must be between 0 and 3600" + ) + + command_output = await self.send_command( + module="C0", + command="ZP", + xs=f"{x_position:05}", + xd=x_direction, + yj=f"{y_position:04}", + yv=f"{y_gripping_speed:04}", + zj=f"{z_position:04}", + zy=f"{z_speed:04}", + yo=f"{open_gripper_position:04}", + yg=f"{plate_width:04}", + yw=f"{grip_strength:02}", + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + te=f"{minimum_z_position_at_the_command_end:04}", + ) + + return command_output + + @need_iswap_parked + async def core_put_plate( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + z_position: int = 0, + z_press_on_distance: int = 0, + z_speed: int = 500, + open_gripper_position: int = 0, + minimum_traverse_height_at_beginning_of_a_command: int = 2750, + z_position_at_the_command_end: int = 2750, + return_tool: bool = True, + ): + """Put plate with CoRe gripper tool and return to wasteblock mount.""" + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" + assert 0 <= z_press_on_distance <= 50, "z_press_on_distance must be between 0 and 999" + assert 0 <= z_speed <= 1600, "z_speed must be between 0 and 1600" + assert 0 <= open_gripper_position <= 9999, "open_gripper_position must be between 0 and 9999" + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + ) + assert 0 <= z_position_at_the_command_end <= 3600, ( + "z_position_at_the_command_end must be between 0 and 3600" + ) + + command_output = await self.send_command( + module="C0", + command="ZR", + xs=f"{x_position:05}", + xd=x_direction, + yj=f"{y_position:04}", + zj=f"{z_position:04}", + zi=f"{z_press_on_distance:03}", + zy=f"{z_speed:04}", + yo=f"{open_gripper_position:04}", + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + te=f"{z_position_at_the_command_end:04}", + ) + + if return_tool: + await self.return_core_gripper_tools() + + return command_output + + @need_iswap_parked + async def core_move_plate_to_position( + self, + x_position: int = 0, + x_direction: int = 0, + x_acceleration_index: int = 4, + y_position: int = 0, + z_position: int = 0, + z_speed: int = 500, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + ): + """Move a plate with CoRe gripper tool.""" + + command_output = await self.send_command( + module="C0", + command="ZM", + xs=f"{x_position:05}", + xd=x_direction, + xg=x_acceleration_index, + yj=f"{y_position:04}", + zj=f"{z_position:04}", + zy=f"{z_speed:04}", + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + ) + + return command_output + + async def core_read_barcode_of_picked_up_resource( + self, + rails: int, + reading_direction: Literal["vertical", "horizontal", "free"] = "horizontal", + minimal_z_position: float = 220.0, + traverse_height_at_beginning_of_a_command: float = 275.0, + z_speed: float = 128.7, + allow_manual_input: bool = False, + labware_description: Optional[str] = None, + ): + """Read a 1D barcode using the CoRe gripper scanner. + + Args: + rails: Rail/slot number where the barcode to be read is located (1-54). + reading_direction: Direction of barcode reading: 'vertical', 'horizontal', or 'free'. Default is 'horizontal'. + minimal_z_position: Minimal Z position [mm] during barcode reading (220.0-360.0). Default is 220.0. + traverse_height_at_beginning_of_a_command: Traverse height at beginning of command [mm] (0.0-360.0). Default is 275.0. + z_speed: Z speed [mm/s] during barcode reading (0.0-128.7). Default is 128.7. + allow_manual_input: If True, allows the user to manually input a barcode if scanning fails. Default is False. + labware_description: Optional description of the labware being scanned, used in the manual input + prompt to provide context to the user. + + Returns: + A Barcode if one is successfully read, either by the scanner or via manual user input. + + Raises: + STARFirmwareError: if the firmware reports an error in the response. + ValueError: if the response format is unexpected or if no barcode is present and + ``allow_manual_input`` is False, or if manual input is enabled but the user does not + provide a barcode. + """ + + assert 1 <= rails <= 54, "rails must be between 1 and 54" + assert 0 <= minimal_z_position <= 3600, "minimal_z_position must be between 0 and 3600" + assert 0 <= traverse_height_at_beginning_of_a_command <= 3600, ( + "traverse_height_at_beginning_of_a_command must be between 0 and 3600" + ) + assert 0 <= z_speed <= 1287, "z_speed must be between 0 and 1287" + + try: + reading_direction_int = { + "vertical": 0, + "horizontal": 1, + "free": 2, + }[reading_direction] + except KeyError as e: + raise ValueError( + "reading_direction must be one of 'vertical', 'horizontal', or 'free'" + ) from e + + command_output = cast( + str, + await self.send_command( + module="C0", + command="ZB", + cp=f"{rails:02}", + zb=f"{round(minimal_z_position * 10):04}", + th=f"{round(traverse_height_at_beginning_of_a_command * 10):04}", + zy=f"{round(z_speed * 10):04}", + bd=reading_direction_int, + ma="0250 2100 0860 0200", + mr=0, + mo="000 000 000 000 000 000 000", + ), + ) + + if command_output is None: + raise RuntimeError("No response received from CoRe barcode read command.") + + resp = command_output.strip() + er_index = resp.find("er") + if er_index == -1: + # Unexpected format: no error section present. + raise ValueError(f"Unexpected CoRe barcode response (no error section): {resp}") + + self.check_fw_string_error(resp) + + # Parse barcode section: firmware returns `bb/LL` where LL is length (00..99). + bb_index = resp.find("bb/", er_index + 7) + if bb_index == -1: + # Unexpected layout of barcode section. + raise ValueError(f"Unexpected CoRe barcode response format: {resp}") + + if len(resp) < bb_index + 5: + # Need at least 'bb/LL'. + raise ValueError(f"Unexpected CoRe barcode response format: {resp}") + + bb_len_str = resp[bb_index + 3 : bb_index + 5] + try: + bb_len = int(bb_len_str) + except ValueError as e: + raise ValueError(f"Invalid CoRe barcode length field 'bb': {bb_len_str}") from e + + barcode_str = resp[bb_index + 5 :].strip() + + # No barcode present. + if bb_len == 0: + if allow_manual_input: + # Provide context and allow the user to recover by entering a barcode manually. + # Use ANSI color codes to make the prompt stand out in typical terminals. + YELLOW = "\033[93m" + BOLD = "\033[1m" + RESET = "\033[0m" + + lines = [ + f"{YELLOW}{BOLD}=== CoRe barcode scan failed ==={RESET}", + f"{YELLOW}No barcode read by CoRe scanner.{RESET}", + ] + if labware_description is not None: + lines.append(f"{YELLOW}Labware: {labware_description}{RESET}") + lines.append(f"{YELLOW}Enter barcode manually (leave blank to abort): {RESET}") + prompt = "\n".join(lines) + + # Blocking input is acceptable here because this helper is only intended for CLI usage. + user_barcode = input(prompt).strip() + if not user_barcode: + raise ValueError("No barcode read by CoRe scanner and no manual barcode provided.") + + return Barcode( + data=user_barcode, + symbology="code128", + position_on_resource="front", + ) + + raise ValueError("No barcode read by CoRe scanner.") + + if not barcode_str: + # Length > 0 but no data present. + raise ValueError(f"Unexpected CoRe barcode response format: {resp}") + + # If the firmware returns more characters than declared, truncate to the declared length. + if len(barcode_str) > bb_len: + barcode_str = barcode_str[:bb_len] + + return Barcode( + data=barcode_str, + symbology="code128", + position_on_resource="front", + ) + + # -------------- 3.5.6 Adjustment & movement commands -------------- + + async def position_single_pipetting_channel_in_y_direction( + self, pipetting_channel_index: int, y_position: int + ): + """Position single pipetting channel in Y-direction. + + Args: + pipetting_channel_index: Index of pipetting channel. Must be between 1 and 16. + y_position: y position [0.1mm]. Must be between 0 and 6500. + """ + + assert 1 <= pipetting_channel_index <= self.num_channels, ( + "pipetting_channel_index must be between 1 and self" + ) + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + + return await self.send_command( + module="C0", + command="KY", + pn=f"{pipetting_channel_index:02}", + yj=f"{y_position:04}", + ) + + async def position_single_pipetting_channel_in_z_direction( + self, pipetting_channel_index: int, z_position: int + ): + """Position single pipetting channel in Z-direction. + + Note that this refers to the point of the tip if a tip is mounted! + + Args: + pipetting_channel_index: Index of pipetting channel. Must be between 1 and 16. + z_position: y position [0.1mm]. Must be between 0 and 3347. The docs say 3600,but empirically 3347 is the max. + """ + + assert 1 <= pipetting_channel_index <= self.num_channels, ( + "pipetting_channel_index must be between 1 and self.num_channels" + ) + # docs say 3600, but empirically 3347 is the max + assert 0 <= z_position <= 3347, "z_position must be between 0 and 3347" + + return await self.send_command( + module="C0", + command="KZ", + pn=f"{pipetting_channel_index:02}", + zj=f"{z_position:04}", + ) + + async def search_for_teach_in_signal_using_pipetting_channel_n_in_x_direction( + self, pipetting_channel_index: int, x_position: int + ): + """Search for Teach in signal using pipetting channel n in X-direction. + + Args: + pipetting_channel_index: Index of pipetting channel. Must be between 1 and self.num_channels. + x_position: x position [0.1mm]. Must be between 0 and 30000. + """ + + assert 1 <= pipetting_channel_index <= self.num_channels, ( + "pipetting_channel_index must be between 1 and self.num_channels" + ) + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + + return await self.send_command( + module="C0", + command="XL", + pn=f"{pipetting_channel_index:02}", + xs=f"{x_position:05}", + ) + + async def spread_pip_channels(self): + """Spread PIP channels""" + + return await self.send_command(module="C0", command="JE") + + @need_iswap_parked + async def move_all_pipetting_channels_to_defined_position( + self, + tip_pattern: bool = True, + x_positions: int = 0, + y_positions: int = 0, + minimum_traverse_height_at_beginning_of_command: int = 3600, + z_endpos: int = 0, + ): + """Move all pipetting channels to defined position + + Args: + tip_pattern: Tip pattern (channels involved). Default True. + x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. + y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. + minimum_traverse_height_at_beginning_of_command: Minimum traverse height at beginning of a + command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be + between 0 and 3600. Default 3600. + z_endpos: Z-Position at end of a command [0.1 mm] (refers to all channels independent of tip + pattern parameter 'tm'). Must be between 0 and 3600. Default 0. + """ + + if self.left_side_panel_installed: + min_x = round(self.PIP_X_MIN_WITH_LEFT_SIDE_PANEL * 10) + if x_positions < min_x: + raise ValueError( + f"PIP channel x={x_positions / 10}mm is below the minimum " + f"{self.PIP_X_MIN_WITH_LEFT_SIDE_PANEL}mm (left side panel is installed)" + ) + assert 0 <= x_positions <= 25000, "x_positions must be between 0 and 25000" + assert 0 <= y_positions <= 6500, "y_positions must be between 0 and 6500" + assert 0 <= minimum_traverse_height_at_beginning_of_command <= 3600, ( + "minimum_traverse_height_at_beginning_of_command must be between 0 and 3600" + ) + assert 0 <= z_endpos <= 3600, "z_endpos must be between 0 and 3600" + + return await self.send_command( + module="C0", + command="JM", + tm=tip_pattern, + xp=x_positions, + yp=y_positions, + th=minimum_traverse_height_at_beginning_of_command, + zp=z_endpos, + ) + + # TODO:(command:JR): teach rack using pipetting channel n + + @need_iswap_parked + async def position_max_free_y_for_n(self, pipetting_channel_index: int): + """Position all pipetting channels so that there is maximum free Y range for channel n + + Args: + pipetting_channel_index: Index of pipetting channel. Must be between 0 and self.num_channels. + """ + + assert 0 <= pipetting_channel_index < self.num_channels, ( + "pipetting_channel_index must be between 1 and self.num_channels" + ) + # convert Python's 0-based indexing to Hamilton firmware's 1-based indexing + pipetting_channel_index = pipetting_channel_index + 1 + + return await self.send_command( + module="C0", + command="JP", + pn=f"{pipetting_channel_index:02}", + ) + + async def move_all_channels_in_z_safety(self): + """Move all pipetting channels in Z-safety position""" + + return await self.send_command(module="C0", command="ZA") + + # -------------- 3.5.7 PIP query -------------- + + async def request_x_pos_channel_n(self, pipetting_channel_index: int = 0) -> float: + """Request X-Position of Pipetting channel n (in mm)""" + + resp = await self.request_left_x_arm_position() + # TODO: check validity for 2 X-arm system + + return round(resp, 1) + + async def request_y_pos_channel_n(self, pipetting_channel_index: int) -> float: + """Request Y-Position of Pipetting channel n + + Args: + pipetting_channel_index: Index of pipetting channel. Must be between 0 and 15. + 0 is the backmost channel. + """ + + assert 0 <= pipetting_channel_index < self.num_channels, ( + "pipetting_channel_index must be between 0 and self.num_channels" + ) + # convert Python's 0-based indexing to Hamilton firmware's 1-based indexing + pipetting_channel_index = pipetting_channel_index + 1 + + y_pos_query = await self.send_command( + module="C0", + command="RB", + fmt="rb####", + pn=f"{pipetting_channel_index:02}", + ) + # Extract y-coordinate and convert to mm + return float(y_pos_query["rb"] / 10) + + async def channels_request_y_positions(self) -> List[float]: + """Request the Y-positions of all pipetting channels in one command. + + Returns: + Y-position (mm) per channel, ordered by channel index (0 = backmost). + """ + resp = await self.send_command(module="C0", command="RY", fmt="ry#### (n)") + return [v / 10 for v in resp["ry"]] + + async def channels_request_stop_disk_z_positions(self) -> List[float]: + """Request the stop-disk Z-position of every channel, excluding any mounted tip. + + Returns: + Stop-disk Z (mm) per channel, ordered by channel index (0 = backmost). + """ + return [await self.request_probe_z_position(i) for i in range(self.num_channels)] + + async def request_z_pos_channel_n(self, pipetting_channel_index: int) -> float: + warnings.warn( + "Deprecated. Use either request_tip_bottom_z_position or request_probe_z_position. " + "Returning request_tip_bottom_z_position for now." + ) + return await self.request_tip_bottom_z_position(channel_idx=pipetting_channel_index) + + async def request_tip_bottom_z_position(self, channel_idx: int) -> float: + """Request Z-Position of the tip bottom of the tip mounted at on channel `channel_idx`. + + Requires a tip to be mounted and will raise if no tip is mounted. + + To get the z-position of the probe (irrespective of tip), use `request_probe_z_position`. + + Args: + channel_idx: Index of pipetting channel. Must be between 0 and 15. 0 is the backmost channel. + """ + + if not (await self.request_tip_presence())[channel_idx]: + raise RuntimeError(f"No tip mounted on channel {channel_idx}") + + if not 0 <= channel_idx <= self.num_channels - 1: + raise ValueError("channel_idx must be in [0, num_channels - 1]") + + z_pos_query = await self.send_command( + module="C0", + command="RD", + fmt="rd####", + # convert Python's 0-based indexing to Hamilton firmware's 1-based indexing + pn=f"{channel_idx + 1:02}", + ) + # Extract z-coordinate and convert to mm + return float(z_pos_query["rd"] / 10) + + async def request_tip_presence(self) -> List[Optional[bool]]: + """Measure tip presence on all single channels using their sleeve sensors. + + Returns: + A list of length `num_channels` where each element is `True` if a tip is mounted, + `False` if not, or `None` if unknown. + """ + resp = await self.send_command(module="C0", command="RT", fmt="rt# (n)") + return [bool(v) for v in cast(List[int], resp.get("rt"))] + + async def channels_sense_tip_presence(self) -> List[int]: + """Deprecated - use `request_tip_presence` instead.""" + warnings.warn( + "`channels_sense_tip_presence` is deprecated and will be " + "removed in a future version. Use `request_tip_presence` instead.", + DeprecationWarning, + stacklevel=2, + ) + return [int(v) for v in await self.request_tip_presence() if v is not None] + + async def request_pip_height_last_lld(self) -> List[float]: + """ + Return the absolute liquid heights measured during the most recent + liquid-level detection (LLD) event for all channels. + + This value is maintained internally by the STAR/STARlet firmware and is + updated **whenever a liquid level is detected**, regardless of whether the + detection method used was: + - capacitive LLD (cLLD == 'STAR.LLDMode(1)'), or + - pressure-based LLD (pLLD == 'STAR.LLDMode(2)'). + + Heights are returned in millimeters, one value per channel, ordered by + channel index. + + Returns: + Absolute liquid heights (mm) from the last LLD event for each channel. + + Raises: + AssertionError: If the instrument response does not contain a valid ``"lh"`` list. + """ + resp = await self.send_command(module="C0", command="RL", fmt="lh#### (n)") + + liquid_levels = resp.get("lh") + + assert len(liquid_levels) == self.num_channels, ( + f"Expected {self.num_channels} liquid level values, got {len(liquid_levels)} instead" + ) + + current_absolute_liquid_heights = [float(lld_channel / 10) for lld_channel in liquid_levels] + + return current_absolute_liquid_heights + + async def request_tadm_status(self): + """Request PIP height of last LLD + + Returns: + TADM channel status 0 = off, 1 = on + """ + + return await self.send_command(module="C0", command="QS", fmt="qs# (n)") + + # TODO:(command:FS) Request PIP channel dispense on fly status + # TODO:(command:VE) Request PIP channel 2nd section aspiration data + + # -------------- 3.6 XL channel commands -------------- + + # TODO: all XL channel commands + + # -------------- 3.6.1 Initialization XL -------------- + + # TODO:(command:LI) + + # -------------- 3.6.2 Tip handling commands using XL -------------- + + # TODO:(command:LP) + # TODO:(command:LR) + + # -------------- 3.6.3 Liquid handling commands using XL -------------- + + # TODO:(command:LA) + # TODO:(command:LD) + # TODO:(command:LB) + # TODO:(command:LC) + + # -------------- 3.6.4 Wash commands using XL channel -------------- + + # TODO:(command:LE) + # TODO:(command:LF) + + # -------------- 3.6.5 XL CoRe gripper commands -------------- + + # TODO:(command:LT) + # TODO:(command:LS) + # TODO:(command:LU) + # TODO:(command:LV) + # TODO:(command:LM) + # TODO:(command:LO) + # TODO:(command:LG) + + # -------------- 3.6.6 Adjustment & movement commands CP -------------- + + # TODO:(command:LY) + # TODO:(command:LZ) + # TODO:(command:LH) + # TODO:(command:LJ) + # TODO:(command:XM) + # TODO:(command:LL) + # TODO:(command:LQ) + # TODO:(command:LK) + # TODO:(command:UE) + + # -------------- 3.6.7 XL channel query -------------- + + # TODO:(command:UY) + # TODO:(command:UB) + # TODO:(command:UZ) + # TODO:(command:UD) + # TODO:(command:UT) + # TODO:(command:UL) + # TODO:(command:US) + # TODO:(command:UF) + + # -------------- 3.7 Tube gripper commands -------------- + + # TODO: all tube gripper commands + + # -------------- 3.7.1 Movements -------------- + + # TODO:(command:FC) + # TODO:(command:FD) + # TODO:(command:FO) + # TODO:(command:FT) + # TODO:(command:FU) + # TODO:(command:FJ) + # TODO:(command:FM) + # TODO:(command:FW) + + # -------------- 3.7.2 Tube gripper query -------------- + + # TODO:(command:FQ) + # TODO:(command:FN) + + # -------------- 3.8 Imaging channel commands -------------- + + # TODO: all imaging commands + + # -------------- 3.8.1 Movements -------------- + + # TODO:(command:IC) + # TODO:(command:ID) + # TODO:(command:IM) + # TODO:(command:IJ) + + # -------------- 3.8.2 Imaging channel query -------------- + + # TODO:(command:IN) + + # -------------- 3.9 Robotic channel commands -------------- + + # -------------- 3.9.1 Initialization -------------- + + # TODO:(command:OI) + + # -------------- 3.9.2 Cap handling commands -------------- + + # TODO:(command:OP) + # TODO:(command:OQ) + + # -------------- 3.9.3 Adjustment & movement commands -------------- + + # TODO:(command:OY) + # TODO:(command:OZ) + # TODO:(command:OH) + # TODO:(command:OJ) + # TODO:(command:OX) + # TODO:(command:OM) + # TODO:(command:OF) + # TODO:(command:OG) + + # -------------- 3.9.4 Robotic channel query -------------- + + # TODO:(command:OA) + # TODO:(command:OB) + # TODO:(command:OC) + # TODO:(command:OD) + # TODO:(command:OT) + + # -------------- 3.10 96-Head commands -------------- + + async def head96_request_firmware_version(self) -> datetime.date: + """Request 96 Head firmware version (MEM-READ command).""" + resp: str = await self.send_command(module="H0", command="RF") + return self._parse_firmware_version_datetime(resp) + + async def _head96_request_configuration(self) -> List[str]: + """Request the 96-head configuration (raw) using the QU command. + + The instrument returns ten blank-separated decimal values. This method returns + them as a list of strings, undecoded; the list indices currently understood are: + + - index 0: clot_monitoring_with_clld + - index 1: stop_disc_type (codes: 0=core_i, 1=core_ii) + - index 2: instrument_type (codes: 0=legacy, 1=FM-STAR) + - indices 3..9: reserve + + Index 1 (stop_disc_type) is populated on firmware at least back to 2021 (a 2021-10-22 + build reports core_ii). Whether index 2 (instrument_type) is reliably populated on + every build, or on some falls back to reserve (read back as 0 -> legacy), is unverified; + confirm on an FM-STAR head before relying on it to unlock the FM-STAR z-range extension. + + Returns: + Raw positional tokens extracted from the QU response (the portion after the last ``"au"`` marker). + """ + resp: str = await self.send_command(module="H0", command="QU") + return resp.split("au")[-1].split() + + async def head96_request_type(self) -> Head96Information.HeadType: + """Send QG and return the 96-head type as a human-readable string.""" + type_map: Dict[int, Head96Information.HeadType] = { + 0: "Low volume head", + 1: "High volume head", + 2: "96 head II", + 3: "96 head TADM", + } + resp = await self.send_command(module="H0", command="QG", fmt="qg#") + return type_map.get(resp["qg"], "unknown") + + def _head96_resolve_z_range(self, instrument_type: str) -> Tuple[float, float]: + """Z-drive position window (mm); FM-STAR extends it (za/zb/zh all share this range).""" + min_inc, max_inc = (24200, 76200) if instrument_type == "FM-STAR" else (36100, 68500) + return ( + self._head96_z_drive_increment_to_mm(min_inc), + self._head96_z_drive_increment_to_mm(max_inc), + ) + + # -------------- 3.10.1 Initialization -------------- + + async def initialize_core_96_head( + self, trash96: Trash, z_position_at_the_command_end: float = 245.0 + ): + """Initialize CoRe 96 Head + + Args: + trash96: Trash object where tips should be disposed. The 96 head will be positioned in the + center of the trash. + z_position_at_the_command_end: Z position at the end of the command [mm]. + """ + + # The firmware command expects location of tip A1 of the head. + loc = self._position_96_head_in_resource(trash96) + self._check_96_position_legal(loc, skip_z=True) + + return await self.send_command( + module="C0", + command="EI", + read_timeout=60, + xs=f"{abs(round(loc.x * 10)):05}", + xd=0 if loc.x >= 0 else 1, + yh=f"{abs(round(loc.y * 10)):04}", + za=f"{round(loc.z * 10):04}", + ze=f"{round(z_position_at_the_command_end * 10):04}", + ) + + async def request_core_96_head_initialization_status(self) -> bool: + # not available in the C0 docs, so get from module H0 itself instead + response = await self.send_command(module="H0", command="QW", fmt="qw#") + return bool(response.get("qw", 0) == 1) # type? + + async def head96_dispensing_drive_and_squeezer_driver_initialize( + self, + squeezer_speed: float = 15.0, # mm/sec + squeezer_acceleration: float = 62.0, # mm/sec**2, + squeezer_current_limit: int = 15, + dispensing_drive_current_limit: int = 7, + ): + """Initialize 96-head's dispensing drive AND squeezer drive + + This command... + - drops any tips that might be on the channel (in place, without moving to trash!) + - moves the dispense drive to volume position 215.92 uL + (after tip pickup it will be at 218.19 uL) + + Args: + squeezer_speed: Speed of the movement (mm/sec). Default is 15.0 mm/sec. + squeezer_acceleration: Acceleration of the movement (mm/sec**2). Default is 62.0 mm/sec**2. + squeezer_current_limit: Current limit for the squeezer drive (1-15). Default is 15. + dispensing_drive_current_limit: Current limit for the dispensing drive (1-15). Default is 7. + """ + + if not (0.01 <= squeezer_speed <= 16.69): + raise ValueError( + f"96-head squeezer drive speed must be between 0.01 and 16.69 mm/sec, is {squeezer_speed}" + ) + if not (1.04 <= squeezer_acceleration <= 62.6): + raise ValueError( + "96-head squeezer drive acceleration must be between 1.04 and " + f"62.6 mm/sec**2, is {squeezer_acceleration}" + ) + if not (1 <= squeezer_current_limit <= 15): + raise ValueError( + "96-head squeezer drive current limit must be between 1 and 15, " + f"is {squeezer_current_limit}" + ) + if not (1 <= dispensing_drive_current_limit <= 15): + raise ValueError( + "96-head dispensing drive current limit must be between 1 and 15, " + f"is {dispensing_drive_current_limit}" + ) + + squeezer_speed_increment = self._head96_squeezer_drive_mm_to_increment(squeezer_speed) + squeezer_acceleration_increment = self._head96_squeezer_drive_mm_to_increment( + squeezer_acceleration + ) + + resp = await self.send_command( + module="H0", + command="PI", + sv=f"{squeezer_speed_increment:05}", + sr=f"{squeezer_acceleration_increment:06}", + sw=f"{squeezer_current_limit:02}", + dw=f"{dispensing_drive_current_limit:02}", + ) + + return resp + + # -------------- 3.10.2 96-Head Movements -------------- + + # Conversion factors for 96-Head: owned by Head96Information now (encoder resolutions); aliased + # here for backwards compatibility. + _head96_z_drive_mm_per_increment = Head96Information.z_drive_mm_per_increment + _head96_y_drive_mm_per_increment = Head96Information.y_drive_mm_per_increment + _head96_dispensing_drive_mm_per_increment = Head96Information.dispensing_drive_mm_per_increment + _head96_dispensing_drive_uL_per_increment = Head96Information.dispensing_drive_uL_per_increment + _head96_squeezer_drive_mm_per_increment = Head96Information.squeezer_drive_mm_per_increment + + # Y-axis conversions + + def _head96_y_drive_mm_to_increment(self, value_mm: float) -> int: + """Convert mm to Y-axis hardware increments for 96-head.""" + return round(value_mm / self._head96_y_drive_mm_per_increment) + + def _head96_y_drive_increment_to_mm(self, value_increments: int) -> float: + """Convert Y-axis hardware increments to mm for 96-head.""" + return round(value_increments * self._head96_y_drive_mm_per_increment, 2) + + # Z-axis conversions + + def _head96_z_drive_mm_to_increment(self, value_mm: float) -> int: + """Convert mm to Z-axis hardware increments for 96-head.""" + return round(value_mm / self._head96_z_drive_mm_per_increment) + + def _head96_z_drive_increment_to_mm(self, value_increments: int) -> float: + """Convert Z-axis hardware increments to mm for 96-head.""" + return round(value_increments * self._head96_z_drive_mm_per_increment, 2) + + # Dispensing drive conversions (mm and uL) + + def _head96_dispensing_drive_mm_to_increment(self, value_mm: float) -> int: + """Convert mm to dispensing drive hardware increments for 96-head.""" + return round(value_mm / self._head96_dispensing_drive_mm_per_increment) + + def _head96_dispensing_drive_increment_to_mm(self, value_increments: int) -> float: + """Convert dispensing drive hardware increments to mm for 96-head.""" + return round(value_increments * self._head96_dispensing_drive_mm_per_increment, 2) + + def _head96_dispensing_drive_uL_to_increment(self, value_uL: float) -> int: + """Convert uL to dispensing drive hardware increments for 96-head.""" + return round(value_uL / self._head96_dispensing_drive_uL_per_increment) + + def _head96_dispensing_drive_increment_to_uL(self, value_increments: int) -> float: + """Convert dispensing drive hardware increments to uL for 96-head.""" + return round(value_increments * self._head96_dispensing_drive_uL_per_increment, 2) + + def _head96_dispensing_drive_mm_to_uL(self, value_mm: float) -> float: + """Convert dispensing drive mm to uL for 96-head.""" + # Convert mm -> increment -> uL + increment = self._head96_dispensing_drive_mm_to_increment(value_mm) + return self._head96_dispensing_drive_increment_to_uL(increment) + + def _head96_dispensing_drive_uL_to_mm(self, value_uL: float) -> float: + """Convert dispensing drive uL to mm for 96-head.""" + # Convert uL -> increment -> mm + increment = self._head96_dispensing_drive_uL_to_increment(value_uL) + return self._head96_dispensing_drive_increment_to_mm(increment) + + # Squeezer drive conversions + + def _head96_squeezer_drive_mm_to_increment(self, value_mm: float) -> int: + """Convert mm to squeezer drive hardware increments for 96-head.""" + return round(value_mm / self._head96_squeezer_drive_mm_per_increment) + + def _head96_squeezer_drive_increment_to_mm(self, value_increments: int) -> float: + """Convert squeezer drive hardware increments to mm for 96-head.""" + return round(value_increments * self._head96_squeezer_drive_mm_per_increment, 2) + + # Default drive speed/acceleration a move uses when the caller passes none. Each getter returns the + # user override if one was set, otherwise the firmware-resolved Head96Information factory default; + # the setter range-checks against Head96Information so a user can set their own default safely. A + # move does not persist these to the drive - it snapshots the live register and restores it after. + + @property + def head96_y_drive_speed_default(self) -> float: + """Default 96-head Y-drive speed (mm/s) used when a Y move is called without an explicit speed. + + Seeded from the machine at setup; assign your own and it is validated against `y_speed_range` + before taking effect. A move does not persist this to the drive: it snapshots the live register + and restores it afterwards. + """ + assert self._head96_y_drive_speed_default is not None, ( + "96-head defaults not loaded; run setup()" + ) + return self._head96_y_drive_speed_default + + @head96_y_drive_speed_default.setter + def head96_y_drive_speed_default(self, value: float): + assert self._head96_information is not None, "96-head information not loaded; run setup()" + lo, hi = self._head96_information.y_speed_range + if not lo <= value <= hi: + raise ValueError(f"speed must be between {lo} and {hi} mm/sec") + self._head96_y_drive_speed_default = value + + @property + def head96_y_drive_acceleration_default(self) -> float: + """Default 96-head Y-drive acceleration (mm/s2) used when a Y move is called without one. + + Seeded from the machine at setup; assign your own and it is validated against + `y_acceleration_range` before taking effect. A move does not persist this to the drive: it + snapshots the live register and restores it afterwards. + """ + assert self._head96_y_drive_acceleration_default is not None, ( + "96-head defaults not loaded; run setup()" + ) + return self._head96_y_drive_acceleration_default + + @head96_y_drive_acceleration_default.setter + def head96_y_drive_acceleration_default(self, value: float): + assert self._head96_information is not None, "96-head information not loaded; run setup()" + lo, hi = self._head96_information.y_acceleration_range + if not lo <= value <= hi: + raise ValueError(f"acceleration must be between {lo} and {hi} mm/sec**2") + self._head96_y_drive_acceleration_default = value + + @property + def head96_z_drive_speed_default(self) -> float: + """Default 96-head Z-drive speed (mm/s) used when a Z move is called without an explicit speed. + + Seeded from the machine at setup; assign your own and it is validated against `z_speed_range` + before taking effect. A move does not persist this to the drive: it snapshots the live register + and restores it afterwards. + """ + assert self._head96_z_drive_speed_default is not None, ( + "96-head defaults not loaded; run setup()" + ) + return self._head96_z_drive_speed_default + + @head96_z_drive_speed_default.setter + def head96_z_drive_speed_default(self, value: float): + assert self._head96_information is not None, "96-head information not loaded; run setup()" + lo, hi = self._head96_information.z_speed_range + if not lo <= value <= hi: + raise ValueError(f"speed must be between {lo} and {hi} mm/sec") + self._head96_z_drive_speed_default = value + + @property + def head96_z_drive_acceleration_default(self) -> float: + """Default 96-head Z-drive acceleration (mm/s2) used when a Z move is called without one. + + Seeded from the machine at setup; assign your own and it is validated against + `z_acceleration_range` before taking effect. A move does not persist this to the drive: it + snapshots the live register and restores it afterwards. + """ + assert self._head96_z_drive_acceleration_default is not None, ( + "96-head defaults not loaded; run setup()" + ) + return self._head96_z_drive_acceleration_default + + @head96_z_drive_acceleration_default.setter + def head96_z_drive_acceleration_default(self, value: float): + assert self._head96_information is not None, "96-head information not loaded; run setup()" + lo, hi = self._head96_information.z_acceleration_range + if not lo <= value <= hi: + raise ValueError(f"acceleration must be between {lo} and {hi} mm/sec**2") + self._head96_z_drive_acceleration_default = value + + async def head96_request_y_speed(self) -> float: + """Request the persistent 96-head Y-drive speed (mm/s), via H0 RA (read parameter yv). + + The read counterpart of `_head96_set_y_speed`. + """ + resp = await self.send_command(module="H0", command="RA", ra="yv", fmt="yv#####") + return self._head96_y_drive_increment_to_mm(resp["yv"]) + + async def head96_request_y_acceleration(self) -> float: + """Request the persistent 96-head Y-drive acceleration (mm/s^2), via H0 RA (read parameter yr). + + The read counterpart of `_head96_set_y_acceleration`. + """ + resp = await self.send_command(module="H0", command="RA", ra="yr", fmt="yr#####") + return self._head96_y_drive_increment_to_mm(resp["yr"]) + + async def head96_request_z_speed(self) -> float: + """Request the persistent 96-head Z-drive speed (mm/s), via H0 RA (read parameter zv). + + The read counterpart of `_head96_set_z_speed`. + """ + resp = await self.send_command(module="H0", command="RA", ra="zv", fmt="zv#####") + return self._head96_z_drive_increment_to_mm(resp["zv"]) + + async def head96_request_z_acceleration(self) -> float: + """Request the persistent 96-head Z-drive acceleration (mm/s^2), via H0 RA (read parameter zr). + + The read counterpart of `_head96_set_z_acceleration`; undoes the firmware-version acceleration + scaling that the setter (and `head96_move_stop_disk_z`) applies. + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + resp = await self.send_command(module="H0", command="RA", ra="zr", fmt="zr######") + acceleration_multiplier = 1 if self._head96_information.fw_version.year >= 2010 else 0.001 + return self._head96_z_drive_increment_to_mm(round(resp["zr"] / acceleration_multiplier)) + + @_requires_head96 + async def _head96_set_y_speed(self, speed: float): + """Set the persistent 96-head Y-drive speed (mm/s) on the device without moving. + + On-device write for troubleshooting or specialized use, not day-to-day - set + `head96_y_drive_speed_default` for routine control. Subsequent Y moves that don't pass their own + speed - including the C0-level 96-head commands - inherit this until it is changed or the drive + re-initialises. + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + y_speed_min, y_speed_max = self._head96_information.y_speed_range + assert y_speed_min <= speed <= y_speed_max, ( + f"speed must be between {y_speed_min} and {y_speed_max} mm/sec" + ) + return await self.send_command( + module="H0", command="AA", yv=f"{self._head96_y_drive_mm_to_increment(speed):05}" + ) + + @_requires_head96 + async def _head96_set_y_acceleration(self, acceleration: float): + """Set the persistent 96-head Y-drive acceleration (mm/s^2) on the device without moving. + + On-device write for troubleshooting or specialized use, not day-to-day - set + `head96_y_drive_acceleration_default` for routine control. + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + y_accel_min, y_accel_max = self._head96_information.y_acceleration_range + assert y_accel_min <= acceleration <= y_accel_max, ( + f"acceleration must be between {y_accel_min} and {y_accel_max} mm/sec**2" + ) + return await self.send_command( + module="H0", command="AA", yr=f"{self._head96_y_drive_mm_to_increment(acceleration):05}" + ) + + @_requires_head96 + async def _head96_set_z_speed(self, speed: float): + """Set the persistent 96-head Z-drive speed (mm/s) on the device without moving. + + On-device write for troubleshooting or specialized use, not day-to-day - set + `head96_z_drive_speed_default` for routine control. + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + z_speed_min, z_speed_max = self._head96_information.z_speed_range + assert z_speed_min <= speed <= z_speed_max, ( + f"speed must be between {z_speed_min} and {z_speed_max} mm/sec" + ) + return await self.send_command( + module="H0", command="AA", zv=f"{self._head96_z_drive_mm_to_increment(speed):05}" + ) + + @_requires_head96 + async def _head96_set_z_acceleration(self, acceleration: float): + """Set the persistent 96-head Z-drive acceleration (mm/s^2) on the device without moving. + + On-device write for troubleshooting or specialized use, not day-to-day - set + `head96_z_drive_acceleration_default` for routine control. Applies the same firmware-version + acceleration scaling as `head96_move_stop_disk_z` (pre-2010 x0.001). + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + z_accel_min, z_accel_max = self._head96_information.z_acceleration_range + assert z_accel_min <= acceleration <= z_accel_max, ( + f"acceleration must be between {z_accel_min} and {z_accel_max} mm/sec**2" + ) + acceleration_multiplier = 1 if self._head96_information.fw_version.year >= 2010 else 0.001 + acceleration_increment = round( + self._head96_z_drive_mm_to_increment(acceleration) * acceleration_multiplier + ) + return await self.send_command(module="H0", command="AA", zr=f"{acceleration_increment:06}") + + # Movement commands + + async def move_core_96_to_safe_position(self): + """Move CoRe 96 Head to Z safe position.""" + warnings.warn( + "move_core_96_to_safe_position is deprecated. Use head96_move_to_z_safety instead. " + "This method will be removed in 2026-04", # TODO: remove 2026-04 + DeprecationWarning, + stacklevel=2, + ) + return await self.head96_move_to_z_safety() + + @_requires_head96 + async def head96_move_to_z_safety( + self, speed: Optional[float] = None, acceleration: Optional[float] = None + ): + """Move the 96-head up to its Z-safety height: the top of the firmware/variant Z window + (the max of Head96Information.z_range), not a hardcoded value. speed and acceleration forward + to the underlying stop-disk move (None uses the head defaults).""" + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + z_max = self._head96_information.z_range[1] + return await self.head96_move_stop_disk_z( + z_max, speed=speed, acceleration=acceleration, retract_on_crash=False + ) + + @_requires_head96 + async def head96_park( + self, + ): + """Park the 96-head. + + Uses firmware default speeds and accelerations. + """ + + return await self.send_command(module="H0", command="MO") + + @_requires_head96 + async def head96_move_x( + self, + x: float, + acceleration_level: int = 3, + current_protection_limiter: int = 7, + ): + """Move the 96-head to a target channel-A1 X coordinate via the direct X-arm drive. + + Drives the X-arm carriage to ``x + head96_information.x_offset`` so channel A1 lands at + ``x``: A1 sits left of (below) the carriage center, so deck-A1 = carriage - offset and the + carriage target is therefore ``x + offset`` (inverse of the iSWAP rotation-drive derivation, + ``iswap_rotation_drive_request_x``). + Unlike the legacy EM coordinate move (all axes together, no per-axis motion control), this + is the single-axis X-arm drive command and exposes acceleration and current control, like + ``head96_move_y`` / ``head96_move_stop_disk_z``. + + Args: + x: Target A1 X coordinate in mm. Valid range [x_min, 974.0]; x_min is 0.0 with a left + side panel installed, else -271.0. + acceleration_level: X-arm acceleration index (1-5). Default 3. + current_protection_limiter: X-arm motor current limit (0-7). Default 7. + + Raises: + RuntimeError: If the 96-head is not installed. + ValueError: If the target A1 X is outside the legal 96-head X range. + """ + x_min = self.HEAD96_X_MIN_WITH_LEFT_SIDE_PANEL if self.left_side_panel_installed else -271.0 + if not (x_min <= x <= 974.0): + raise ValueError(f"96-head A1 x={x} out of range [{x_min}, 974.0]") + assert self._head96_information is not None, "96-head information not loaded; run setup()" + carriage_x = x + self._head96_information.x_offset + return await self.experimental_x_arm_move( + carriage_x, + acceleration_level=acceleration_level, + current_protection_limiter=current_protection_limiter, + ) + + @_requires_head96 + async def head96_move_y( + self, + y: float, + speed: Optional[float] = None, + acceleration: Optional[float] = None, + current_protection_limiter: int = 15, + ): + """Move the 96-head to a specified Y-axis coordinate. + + A YA move writes its speed/acceleration into the drive's volatile register, where later moves + would inherit them. This command snapshots whatever speed/acceleration are on the robot before + it runs and restores them afterwards, so it leaves the persistent machine state untouched (the + restore is skipped when the move's value already matches what was there). + + Args: + y: Target Y coordinate in mm. Valid range: [93.75, 562.5] + speed: Movement speed in mm/sec; None uses `head96_y_drive_speed_default`. The valid range is + firmware-dependent (resolved into `Head96Information.y_speed_range`): [0.78125, 390.625] + pre-2021, [0.78125, 625.0] on 2021+ firmware. + acceleration: Movement acceleration in mm/sec**2; None uses + `head96_y_drive_acceleration_default`. The valid range is firmware-dependent (resolved into + `Head96Information.y_acceleration_range`): [78.125, 500.0] pre-2010, [78.125, 781.25] on + 2013+ firmware. + current_protection_limiter: Motor current limit (0-15, hardware units). Default: 15 + + Returns: + Response from the hardware command. + + Raises: + RuntimeError: If 96-head is not installed. + AssertionError: If firmware info missing or parameters out of range. + + Note: + The maxima rose across firmware generations, and the speed and acceleration cutoffs differ: + + - Speed: 390.625 mm/sec pre-2021 (25,000 increments), 625.0 mm/sec on 2021+ (40,000 + increments). The exact firmware version introducing this change is undocumented. + - Acceleration: 500.0 mm/sec**2 pre-2010 (32,000 increments), 781.25 mm/sec**2 on 2013+ + (50,000 increments). + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + + if speed is None: + speed = self.head96_y_drive_speed_default + if acceleration is None: + acceleration = self.head96_y_drive_acceleration_default + + fw_version = self._head96_information.fw_version + y_min, y_max = self._head96_information.y_range + y_speed_min, y_speed_max = self._head96_information.y_speed_range + y_accel_min, y_accel_max = self._head96_information.y_acceleration_range + + # Validate parameters before hardware communication + assert y_min <= y <= y_max, f"y must be between {y_min} and {y_max} mm" + assert y_speed_min <= speed <= y_speed_max, ( + f"speed must be between {y_speed_min} and {y_speed_max} mm/sec for firmware version {fw_version}. " + f"Your firmware version: {fw_version}. " + "If this limit seems incorrect, please test cautiously with an empty deck and report " + "accurate limits + firmware to PyLabRobot: https://github.com/PyLabRobot/pylabrobot/issues" + ) + assert y_accel_min <= acceleration <= y_accel_max, ( + f"acceleration must be between {y_accel_min} and {y_accel_max} mm/sec**2" + ) + assert isinstance(current_protection_limiter, int) and ( + 0 <= current_protection_limiter <= 15 + ), "current_protection_limiter must be an integer between 0 and 15" + + # Convert mm-based parameters to hardware increments using conversion methods + y_increment = self._head96_y_drive_mm_to_increment(y) + speed_increment = self._head96_y_drive_mm_to_increment(speed) + acceleration_increment = self._head96_y_drive_mm_to_increment(acceleration) + + # Snapshot what is on the robot now (read from the device, not a tracked default, so an external + # AA edit is preserved) so the move can restore it afterwards and leave the register untouched. + prev_speed = await self.head96_request_y_speed() + prev_acceleration = await self.head96_request_y_acceleration() + prev_speed_increment = self._head96_y_drive_mm_to_increment(prev_speed) + prev_acceleration_increment = self._head96_y_drive_mm_to_increment(prev_acceleration) + + try: + return await self.send_command( + module="H0", + command="YA", + ya=f"{y_increment:05}", + yv=f"{speed_increment:05}", + yr=f"{acceleration_increment:05}", + yw=f"{current_protection_limiter:02}", + ) + finally: + # Restore the pre-command register values, skipping the AA write where the move's value + # already matched what was there (compared in increments, the unit actually stored). + if speed_increment != prev_speed_increment: + await self._head96_set_y_speed(prev_speed) + if acceleration_increment != prev_acceleration_increment: + await self._head96_set_y_acceleration(prev_acceleration) + + @_requires_head96 + async def head96_move_z( + self, + z: float, + speed: Optional[float] = None, + acceleration: Optional[float] = None, + current_protection_limiter: int = 15, + ): + """Move the 96-head Z drive (stop disk) to an absolute Z position in mm. + + .. deprecated:: + Use `head96_move_stop_disk_z` for moves without a tip attached (stop disk) or + `head96_move_tool_z` when a tip is attached (tip end). + """ + warnings.warn( + "head96_move_z is deprecated and will be removed in v1. Use head96_move_stop_disk_z() for " + "moves without a tip attached or head96_move_tool_z() when a tip is attached.", + DeprecationWarning, + stacklevel=2, + ) + return await self.head96_move_stop_disk_z( + z, + speed=speed, + acceleration=acceleration, + current_protection_limiter=current_protection_limiter, + ) + + @_requires_head96 + async def head96_move_stop_disk_z( + self, + z: float, + speed: Optional[float] = None, + acceleration: Optional[float] = None, + current_protection_limiter: int = 15, + retract_on_crash: bool = True, + ): + """Move the 96-head z-drive (stop disk) to an absolute Z position in mm. + + Stop-disk reference, mirroring the single-channel `move_channel_stop_disk_z`: use this for moves + without a tip; for the tip end with a tip on, use `head96_move_tool_z`. + + A ZA move writes its speed/acceleration into the drive's volatile register, where later + moves (and C0-level commands) would inherit them. This command snapshots whatever + speed/acceleration are on the robot before it runs and restores them afterwards, so it + leaves the persistent machine state untouched (the restore is skipped when the move's value + already matches what was there). On any firmware error during the move (e.g. the head crashing + into something) the head retracts to Z-safety before the error is re-raised. + + Args: + z: Target stop-disk Z in mm. Valid range: Head96Information.z_range (180.5-342.5 mm; FM-STAR + extends it). + speed: Movement speed in mm/sec, [0.25, 100.0]; None uses `head96_z_drive_speed_default` + (seeded to 85 mm/s; constant for the Z drive, not version-resolved like the Y-drive default). + acceleration: Movement acceleration in mm/sec^2, [25.0, 500.0]; None uses + `head96_z_drive_acceleration_default` (seeded to 400 mm/s^2; likewise constant for the Z drive). + current_protection_limiter: Motor current limit (0-15, hardware units). Default: 15 + retract_on_crash: If True (default), retract to Z-safety on any firmware error (e.g. a crash) + before re-raising. head96_move_to_z_safety passes False so its own retract cannot recurse. + + Returns: + Response from the hardware command. + + Raises: + RuntimeError: If 96-head is not installed. + AssertionError: If firmware info missing or parameters out of range. + + Note: + Firmware versions from 2021+ use 1:1 acceleration scaling, while pre-2021 versions use 100x + scaling. Both maintain a 100,000 increment upper limit. + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + if speed is None: + speed = self.head96_z_drive_speed_default + if acceleration is None: + acceleration = self.head96_z_drive_acceleration_default + + fw_version = self._head96_information.fw_version + + # Validate parameters before hardware communication. The Z window is firmware/variant-adaptive + # (FM-STAR extends it), so read it from Head96Information rather than hardcoding the legacy range. + z_min, z_max = self._head96_information.z_range + z_speed_min, z_speed_max = self._head96_information.z_speed_range + z_accel_min, z_accel_max = self._head96_information.z_acceleration_range + assert z_min <= z <= z_max, f"z must be between {z_min} and {z_max} mm" + assert z_speed_min <= speed <= z_speed_max, ( + f"speed must be between {z_speed_min} and {z_speed_max} mm/sec" + ) + assert z_accel_min <= acceleration <= z_accel_max, ( + f"acceleration must be between {z_accel_min} and {z_accel_max} mm/sec**2" + ) + assert isinstance(current_protection_limiter, int) and ( + 0 <= current_protection_limiter <= 15 + ), "current_protection_limiter must be an integer between 0 and 15" + + # Determine acceleration scaling based on firmware version + # Pre-2010 firmware: acceleration parameter is multiplied by 1000 + # 2010+ firmware: acceleration parameter is 1:1 with increment/sec**2 + # TODO: identify exact firmware version that introduced this change + acceleration_multiplier = 1 if fw_version.year >= 2010 else 0.001 + + # Convert mm-based parameters to hardware increments + z_increment = self._head96_z_drive_mm_to_increment(z) + speed_increment = self._head96_z_drive_mm_to_increment(speed) + acceleration_increment = round( + self._head96_z_drive_mm_to_increment(acceleration) * acceleration_multiplier + ) + + # Snapshot what is on the robot now (read from the device, not a tracked default, so an external + # AA edit is preserved) so the move can restore it afterwards and leave the register untouched. + prev_speed = await self.head96_request_z_speed() + prev_acceleration = await self.head96_request_z_acceleration() + prev_speed_increment = self._head96_z_drive_mm_to_increment(prev_speed) + prev_acceleration_increment = round( + self._head96_z_drive_mm_to_increment(prev_acceleration) * acceleration_multiplier + ) + + try: + return await self.send_command( + module="H0", + command="ZA", + za=f"{z_increment:05}", + zv=f"{speed_increment:05}", + zr=f"{acceleration_increment:06}", + zw=f"{current_protection_limiter:02}", + ) + except STARFirmwareError: + # Any firmware error here (most importantly a Z-drive crash) can leave the head against an + # obstacle, so retract to Z-safety before re-raising. head96_move_to_z_safety calls back into + # this method with retract_on_crash=False, so the retract cannot recurse into recovery. + if retract_on_crash: + try: + # retract slowly (quarter of max speed) - the head may be in liquid after a crash + await self.head96_move_to_z_safety(speed=self._head96_information.z_speed_range[1] * 0.25) + except STARFirmwareError: + pass # retract failed too; surface the original error below + raise + finally: + # Restore the pre-command register values, skipping the AA write where the move's value + # already matched what was there (compared in increments, the unit actually stored). + if speed_increment != prev_speed_increment: + await self._head96_set_z_speed(prev_speed) + if acceleration_increment != prev_acceleration_increment: + await self._head96_set_z_acceleration(prev_acceleration) + + async def head96_request_tip_length(self) -> float: + """Measures the length of the tips on the 96-head; the head's counterpart of + `request_tip_len_on_channel`. Raises if no tips are present. + + Returns: + The measured tip length in millimeters. + + Raises: + RuntimeError: If the 96-head holds no tips. + """ + if not await self.head96_request_tip_presence(): + raise RuntimeError("96-head has no tips (firmware reports none)") + stop_disk = await self.head96_request_stop_disk_z() + tip_bottom = (await self.head96_request_position()).z + return round(stop_disk - (tip_bottom - STARBackend.DEFAULT_TIP_FITTING_DEPTH), 1) + + @_requires_head96 + async def head96_move_tool_z(self, z: float, speed: Optional[float] = None): + """Move the 96-head tip bottom to an absolute Z position in mm. + + Requires tips. `head96_move_stop_disk_z` references the stop disk, so this reads the tip overhang + (stop disk minus tip bottom, measured move-free from `head96_request_stop_disk_z` vs + `head96_request_position`) and offsets the move so the tip end lands on `z`. Mirrors the + single-channel `move_channel_tool_z`: a tip-presence guard plus a tip-space range check. + + Args: + z: Target tip-bottom Z in mm. + speed: Movement speed in mm/sec; None uses the head default. + + Raises: + ValueError: if the 96-head holds no tips, or `z` is outside the reachable tip-bottom window. + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + + if not await self.head96_request_tip_presence(): + raise ValueError( + "96-head has no tips (firmware reports none); use head96_move_stop_disk_z for Z moves " + "without a tip attached." + ) + + tip_overhang = await self.head96_request_tip_length() - STARBackend.DEFAULT_TIP_FITTING_DEPTH + + # The move is in stop-disk space over z_range, so the reachable tip-bottom window is z_range + # shifted down by the overhang, floored at the deck surface. Validate in tip-bottom terms. + z_min, z_max = self._head96_information.z_range + deck = STARBackend.MINIMUM_CHANNEL_Z_POSITION + if not (max(z_min - tip_overhang, deck) <= z <= z_max - tip_overhang): + raise ValueError( + f"tip-bottom z={z} mm out of reach " + f"[{round(max(z_min - tip_overhang, deck), 1)}, {round(z_max - tip_overhang, 1)}] mm " + f"for overhang {round(tip_overhang, 1)} mm" + ) + + return await self.head96_move_stop_disk_z(z + tip_overhang, speed=speed) + + @need_iswap_parked + @_requires_head96 + async def head96_probe_z_using_clld( + self, + start_pos_search: Optional[float] = None, + tip_len: Optional[float] = None, + lowest_immers_pos: Optional[float] = None, + approach_speed: Optional[float] = None, + speed: float = 10.0, + acceleration: float = 300.0, + lld_sensor: Literal["A1 or B2", "G11 or H12", "any", "all"] = "any", + detection_edge: int = 10, + detection_drop: int = 2, + post_detection_dist: float = 2.0, + current_protection_limiter: int = 15, + move_to_z_safety_after: bool = False, + ) -> float: + """Probe the liquid-surface Z-height with the 96-head's capacitive LLD (cLLD). + + Runs a downward cLLD search on the 96-head stop-disk Z drive (H0 ZL), stopping at the detected + surface, and returns the tip-bottom (= liquid-surface) Z-height. The 96-head counterpart of the + single-channel cLLD probe; `lld_sensor` is head-specific (the head has two cLLD sensors, a + channel has one). + + Positions are tip-bottom referenced like `head96_move_tool_z`: the tip overhang (stop disk minus + tip bottom, a rigid constant for the mounted tip) maps them to the firmware's stop-disk zh / zc, + and the deck floor caps the deepest immersion. The head should be at Z-safety before calling - + `start_pos_search` defaults to the top and the firmware brings the head there before searching. + cLLD needs a conductive path, so tips must be loaded. + + The ZL wire format changed between the 2008 and 2013 firmware command sets, so the parameters are + formatted per the head's reported firmware date. `lld_sensor` other than "any" requires 2013+ + firmware, since the 2008 ZL has no sensor-selection field. + + Args: + tip_len: mounted tip length in mm, used to map tip-bottom positions to the stop disk. None + (default) measures it via `head96_request_tip_length`. + lowest_immers_pos: lowest tip-bottom the search may reach in mm; None is the deepest safe value. + start_pos_search: tip-bottom position the search starts from in mm; None is the highest safe. + speed: cLLD search speed in mm/sec. + acceleration: search acceleration in mm/sec**2. + approach_speed: fast descent speed in mm/sec for the upper section, before the slow search. + None uses `head96_z_drive_speed_default`. + current_protection_limiter: motor current limit (hardware units; 0-15 on 2013+, 0-7 on 2008). + lld_sensor: which head cLLD sensor(s) trigger detection. + detection_edge: edge steepness threshold for cLLD detection (0-1023). + detection_drop: offset applied after cLLD edge detection (0-1023). + post_detection_dist: signed distance to move after detection in mm; positive moves up / out of + liquid, negative moves down / deeper. + move_to_z_safety_after: if True, retract the head to Z-safety after reading the height. + + Returns: + The detected liquid-surface Z-height as a tip-bottom position in mm. + + Raises: + ValueError: if the head holds no tips, the chosen sensor's corner channel(s) hold no tip (when + tip tracking is on), a parameter is out of range, or `lld_sensor` other than "any" is + requested on pre-2013 firmware. + """ + assert self._head96_information is not None, ( + "requires 96-head firmware version information for safe operation" + ) + + lld_sensor_map = {"G11 or H12": 0, "A1 or B2": 1, "any": 2, "all": 3} + if lld_sensor not in lld_sensor_map: + raise ValueError(f"lld_sensor must be one of {list(lld_sensor_map)}, is {lld_sensor!r}") + + z_speed_min, z_speed_max = self._head96_information.z_speed_range + z_accel_min, z_accel_max = self._head96_information.z_acceleration_range + + if approach_speed is None: + approach_speed = self.head96_z_drive_speed_default + if not z_speed_min <= approach_speed <= z_speed_max: + raise ValueError( + f"approach_speed must be between {z_speed_min} - {z_speed_max} mm/sec, is {approach_speed}" + ) + if not z_speed_min <= speed <= z_speed_max: + raise ValueError(f"speed must be between {z_speed_min} - {z_speed_max} mm/sec, is {speed}") + if not z_accel_min <= acceleration <= z_accel_max: + raise ValueError( + f"acceleration must be between {z_accel_min} - {z_accel_max} mm/sec**2, is {acceleration}" + ) + if not 0 <= detection_edge <= 1023: + raise ValueError(f"detection_edge must be between 0 - 1023, is {detection_edge}") + if not 0 <= detection_drop <= 1023: + raise ValueError(f"detection_drop must be between 0 - 1023, is {detection_drop}") + + # First guard (firmware, always verifiable): some channel must hold a tip for the conductive path. + if not await self.head96_request_tip_presence(): + raise ValueError("96-head cLLD requires tips loaded (conductive path); none detected") + + # When tip tracking is on, also require a tip on the corner channel(s) that feed the chosen + # sensor - the firmware guard above only confirms *some* channel does. cLLD sensor 0 reads + # G11(86)/H12(95), sensor 1 reads A1(0)/B2(9) (column-major head96 indices). + if does_tip_tracking() and self.head96 is not None: + sensor_0 = self.head96[86].has_tip or self.head96[95].has_tip + sensor_1 = self.head96[0].has_tip or self.head96[9].has_tip + sensor_ready = { + "G11 or H12": sensor_0, + "A1 or B2": sensor_1, + "any": sensor_0 or sensor_1, + "all": sensor_0 and sensor_1, + }[lld_sensor] + if not sensor_ready: + raise ValueError( + f"lld_sensor={lld_sensor!r}: the tip tracker reports no tip on the corner channel(s) " + "that feed it" + ) + + # Tip length: measure unless the caller supplied it. + if tip_len is None: + tip_len = await self.head96_request_tip_length() + tip_overhang = tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH + + # Reachable tip-bottom window: z_range shifted down by the overhang, floored at the deck. + z_min, z_max = self._head96_information.z_range + deck = STARBackend.MINIMUM_CHANNEL_Z_POSITION + height_min = max(z_min - tip_overhang, deck) + height_max = z_max - tip_overhang + + if lowest_immers_pos is None: + lowest_immers_pos = height_min + if start_pos_search is None: + start_pos_search = height_max + if not (height_min <= lowest_immers_pos <= height_max): + raise ValueError( + f"lowest_immers_pos={lowest_immers_pos} mm out of reach " + f"[{round(height_min, 1)}, {round(height_max, 1)}] mm (tip-bottom)" + ) + if not (height_min <= start_pos_search <= height_max): + raise ValueError( + f"start_pos_search={start_pos_search} mm out of reach " + f"[{round(height_min, 1)}, {round(height_max, 1)}] mm (tip-bottom)" + ) + + # lm and the raw 6-digit zr arrived with the 2013 firmware; pre-2013 has no lm and scales zr. + uses_2013_structure = self._head96_information.fw_version >= datetime.date(2013, 1, 1) + if not uses_2013_structure and lld_sensor != "any": + raise ValueError( + f"lld_sensor={lld_sensor!r} requires 2013+ firmware; the 2008 command set has no " + "sensor-selection field" + ) + + # zw (current protection limiter) range narrows on pre-2013 firmware. + zw_max = 15 if uses_2013_structure else 7 + if not 0 <= current_protection_limiter <= zw_max: + raise ValueError( + f"current_protection_limiter must be between 0 - {zw_max}, is {current_protection_limiter}" + ) + + # Back to stop-disk space (zh / zc) via the overhang. + lowest_immers_pos_increments = self._head96_z_drive_mm_to_increment( + lowest_immers_pos + tip_overhang + ) + start_pos_search_increments = self._head96_z_drive_mm_to_increment( + start_pos_search + tip_overhang + ) + approach_speed_increments = self._head96_z_drive_mm_to_increment(approach_speed) + speed_increments = self._head96_z_drive_mm_to_increment(speed) + acceleration_increments = self._head96_z_drive_mm_to_increment(acceleration) + + # Signed post-detection move -> direction (zj) and magnitude (zi). + post_detection_direction = 1 if post_detection_dist >= 0 else 0 + post_detection_dist_increments = self._head96_z_drive_mm_to_increment(abs(post_detection_dist)) + if not 0 <= post_detection_dist_increments <= 9999: + raise ValueError( + f"abs(post_detection_dist) must be <= " + f"{self._head96_z_drive_increment_to_mm(9999)} mm, is {abs(post_detection_dist)}" + ) + + lm_field = {"lm": str(lld_sensor_map[lld_sensor])} if uses_2013_structure else {} + if uses_2013_structure: + zr_field = f"{acceleration_increments:06}" # raw [increment/second**2] + zw_field = f"{current_protection_limiter:02}" + else: + zr_field = f"{acceleration_increments // 1000:03}" # [1000 increment/second**2] + zw_field = f"{current_protection_limiter:01}" + zl_params: Dict[str, Any] = { + "zh": f"{lowest_immers_pos_increments:05}", # lowest immersion position [increment] + "zc": f"{start_pos_search_increments:05}", # start position of LLD search [increment] + "zi": f"{post_detection_dist_increments:04}", # immersion depth after LLD [increment] + "zj": f"{post_detection_direction}", # direction of immersion depth (0 down, 1 up) + **lm_field, # which cLLD sensor(s) trigger detection (2013+ only) + "gt": f"{detection_edge:04}", # edge steepness at cLLD detection + "gl": f"{detection_drop:04}", # offset after cLLD edge detection + "zv": f"{approach_speed_increments:05}", # upper-section (fast approach) speed + "zl": f"{speed_increments:05}", # cLLD search speed + "zr": zr_field, # acceleration + "zw": zw_field, # current protection limiter + } + try: + await self.send_command(module="H0", command="ZL", **zl_params) + except STARFirmwareError: + await self.head96_move_to_z_safety() + raise + + # RH returns the latched detected surface (stop-disk frame), unaffected by the post-detection + # move; map it to tip-bottom. TODO(hardware): confirm the RH response format against a capture. + detected_tip_bottom = round(await self.head96_request_last_lld_height() - tip_overhang, 2) + if move_to_z_safety_after: + await self.head96_move_to_z_safety() + return detected_tip_bottom + + # -------------- 3.10.2 Tip handling using CoRe 96 Head -------------- + + @need_iswap_parked + @_requires_head96 + async def pick_up_tips_core96( + self, + x_position: int, + x_direction: int, + y_position: int, + tip_type_idx: int, + tip_pickup_method: int = 2, + z_deposit_position: int = 3425, + minimum_traverse_height_at_beginning_of_a_command: int = 3425, + minimum_height_command_end: int = 3425, + ): + """Pick up tips with CoRe 96 head + + Args: + x_position: x position [0.1mm]. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: y position [0.1mm]. Must be between 1080 and 5600. Default 5600. + tip_size: Tip type. + tip_pickup_method: Tip pick up method. 0 = pick up from rack. 1 = pick up from C0Re 96 tip + wash station. 2 = pick up with " full volume blow out" + z_deposit_position: Z- deposit position [0.1mm] (collar bearing position) Must bet between + 0 and 3425. Default 3425. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning + of a command [0.1mm]. Must be between 0 and 3425. + minimum_height_command_end: Minimal height at command end [0.1 mm] Must be between 0 and 3425. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 1080 <= y_position <= 5600, "y_position must be between 1080 and 5600" + assert 0 <= z_deposit_position <= 3425, "z_deposit_position must be between 0 and 3425" + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3425, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3425" + ) + assert 0 <= minimum_height_command_end <= 3425, ( + "minimum_height_command_end must be between 0 and 3425" + ) + + return await self.send_command( + module="C0", + command="EP", + xs=f"{x_position:05}", + xd=x_direction, + yh=f"{y_position:04}", + tt=f"{tip_type_idx:02}", + wu=tip_pickup_method, + za=f"{z_deposit_position:04}", + zh=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + ze=f"{minimum_height_command_end:04}", + ) + + @need_iswap_parked + @_requires_head96 + async def discard_tips_core96( + self, + x_position: int, + x_direction: int, + y_position: int, + z_deposit_position: int = 3425, + minimum_traverse_height_at_beginning_of_a_command: int = 3425, + minimum_height_command_end: int = 3425, + ): + """Drop tips with CoRe 96 head + + Args: + x_position: x position [0.1mm]. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: y position [0.1mm]. Must be between 1080 and 5600. Default 5600. + tip_type: Tip type. + tip_pickup_method: Tip pick up method. 0 = pick up from rack. 1 = pick up from C0Re 96 + tip wash station. 2 = pick up with " full volume blow out" + z_deposit_position: Z- deposit position [0.1mm] (collar bearing position) Must bet between + 0 and 3425. Default 3425. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning + of a command [0.1mm]. Must be between 0 and 3425. + minimum_height_command_end: Minimal height at command end [0.1 mm] Must be between 0 and 3425 + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 1080 <= y_position <= 5600, "y_position must be between 1080 and 5600" + assert 0 <= z_deposit_position <= 3425, "z_deposit_position must be between 0 and 3425" + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3425, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3425" + ) + assert 0 <= minimum_height_command_end <= 3425, ( + "minimum_height_command_end must be between 0 and 3425" + ) + + return await self.send_command( + module="C0", + command="ER", + xs=f"{x_position:05}", + xd=x_direction, + yh=f"{y_position:04}", + za=f"{z_deposit_position:04}", + zh=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + ze=f"{minimum_height_command_end:04}", + ) + + # -------------- 3.10.3 Liquid handling using CoRe 96 Head -------------- + + @_requires_head96 + async def head96_experimental_aspirate( + self, + volume: float, + flow_rate: Optional[float] = None, + minimum_height: Optional[float] = None, + surface_following_distance: float = 0.0, + requires_tip: bool = True, + ): + """Aspirate on the 96-head with surface following (the firmware drives Z and the dispensing drive + in parallel). + + The direct, full-control counterpart to `aspirate96`: it takes the height / surface-following / + flow directly rather than a resource and liquid class, and computes no positions. Acts on the whole + head (rigid - no per-channel selection). Values are given in human units and converted to firmware + increments. This is a basic, thin command: it does not settle after aspirating (settling is a + separate basic command). + + Args: + volume: The piston (dispensing-drive) volume to aspirate per channel, uL; raw, not liquid-class + corrected. + flow_rate: The dispensing-drive speed, uL/s; None uses the head's default speed. + minimum_height: The lowest the tip end (tip-bottom) descends to, mm - the end of the stroke. + None defaults to the deepest safe target: the deck floor with a tip on, or the firmware Z + floor with none on (no tip overhang, so this is the stop-disk position directly). + surface_following_distance: The Z travel during aspiration, mm; 0 keeps the head in place so it + cannot drive into the container bottom. + requires_tip: If True, raise if the head holds no tips; if False, allow aspirating air. + + Raises: + RuntimeError: If 96-head is not installed, or requires_tip and the head holds no tips. + AssertionError: If firmware info missing or a parameter is out of range. + """ + assert self._head96_information is not None, "96-head information not loaded; run setup()" + info = self._head96_information + vol_min, vol_max = info.dispensing_drive_range + flow_min, flow_max = info.dispensing_drive_speed_range + z_min, z_max = info.z_range + surface_following_max = self._head96_z_drive_increment_to_mm(9999) + if flow_rate is None: + flow_rate = info.dispensing_drive_speed_default + + assert vol_min <= volume <= vol_max, f"volume must be between {vol_min} and {vol_max} uL" + assert flow_min <= flow_rate <= flow_max, ( + f"flow_rate must be between {flow_min} and {flow_max} uL/s" + ) + assert 0 <= surface_following_distance <= surface_following_max, ( + f"surface_following_distance must be between 0 and {surface_following_max} mm" + ) + + has_tips = bool(await self.head96_request_tip_presence()) + if requires_tip and not has_tips: + raise RuntimeError( + "96-head has no tips (firmware reports none); pick up tips before aspirating" + ) + + # minimum_height is a tip-bottom height: the tip overhang (stop disk - tip bottom) converts it to + # the firmware stop-disk zh, and the deck floor caps how deep it may go. With no tip there is no + # overhang, so minimum_height is the stop-disk position directly and is guarded against z_min. + overhang = 0.0 + if has_tips: + overhang = await self.head96_request_tip_length() - STARBackend.DEFAULT_TIP_FITTING_DEPTH + height_min = max(z_min - overhang, STARBackend.MINIMUM_CHANNEL_Z_POSITION) + height_max = z_max - overhang + if minimum_height is None: + minimum_height = height_min + assert height_min <= minimum_height <= height_max, ( + f"minimum_height must be between {height_min} and {height_max} mm" + ) + + volume_increment = self._head96_dispensing_drive_uL_to_increment(volume) + flow_rate_increment = self._head96_dispensing_drive_uL_to_increment(flow_rate) + surface_following_increment = self._head96_z_drive_mm_to_increment(surface_following_distance) + zh_increment = self._head96_z_drive_mm_to_increment(minimum_height + overhang) + return await self.send_command( + module="H0", + command="PA", + pm="F" * 24, # all 96 channels; the rigid head has no per-channel selection + dj="1", # minimum_height enforcement always on; the resolved minimum_height is the floor + da=f"{volume_increment:05}", + dv=f"{flow_rate_increment:05}", + dc="00000", # pre-wetting off; its interaction with surface following is unverified + zd=f"{surface_following_increment:04}", + zh=f"{zh_increment:05}", + to="000", # settling_time not exposed here (firmware allows it); it is its own basic command + ) + + @_requires_head96 + async def head96_experimental_dispense( + self, + volume: float, + flow_rate: Optional[float] = None, + minimum_height: Optional[float] = None, + stop_back_volume: float = 0.0, + surface_following_distance: float = 0.0, + stop_flow_rate: Optional[float] = None, + requires_tip: bool = True, + ): + """Dispense on the 96-head with surface following (the firmware drives Z and the dispensing drive + in parallel). + + The direct, full-control counterpart to `dispense96`. Acts on the whole head (rigid). This is a + basic, thin command: it does not settle after dispensing (settling is a separate basic command). + + Args: + volume: The piston (dispensing-drive) volume to dispense per channel, uL; raw, not liquid-class + corrected. + flow_rate: The dispensing-drive speed, uL/s; None uses the head's default speed. + minimum_height: The lowest the tip end (tip-bottom) descends to, mm - the end of the stroke. + None defaults to the deepest safe target: the deck floor with a tip on, or the firmware Z + floor with none on (no tip overhang, so this is the stop-disk position directly). + stop_back_volume: The volume drawn back at the end to stop dripping, uL. + surface_following_distance: The Z travel during dispense, mm. + stop_flow_rate: The dispensing-drive stop speed, uL/s; None uses the firmware default (0). + requires_tip: If True, raise if the head holds no tips; if False, allow dispensing air. + + Raises: + RuntimeError: If 96-head is not installed, or requires_tip and the head holds no tips. + AssertionError: If firmware info missing or a parameter is out of range. + """ + assert self._head96_information is not None, "96-head information not loaded; run setup()" + info = self._head96_information + vol_min, vol_max = info.dispensing_drive_range + flow_min, flow_max = info.dispensing_drive_speed_range + z_min, z_max = info.z_range + surface_following_max = self._head96_z_drive_increment_to_mm(9999) + stop_back_max = self._head96_dispensing_drive_increment_to_uL(9999) + if flow_rate is None: + flow_rate = info.dispensing_drive_speed_default + if stop_flow_rate is None: + stop_flow_rate = 0.0 # firmware stop-speed default + + assert vol_min <= volume <= vol_max, f"volume must be between {vol_min} and {vol_max} uL" + assert flow_min <= flow_rate <= flow_max, ( + f"flow_rate must be between {flow_min} and {flow_max} uL/s" + ) + assert 0 <= stop_back_volume <= stop_back_max, ( + f"stop_back_volume must be between 0 and {stop_back_max} uL" + ) + assert 0 <= surface_following_distance <= surface_following_max, ( + f"surface_following_distance must be between 0 and {surface_following_max} mm" + ) + assert 0 <= stop_flow_rate <= flow_max, f"stop_flow_rate must be between 0 and {flow_max} uL/s" + + has_tips = bool(await self.head96_request_tip_presence()) + if requires_tip and not has_tips: + raise RuntimeError( + "96-head has no tips (firmware reports none); pick up tips before dispensing" + ) + + # minimum_height is a tip-bottom height: the tip overhang (stop disk - tip bottom) converts it to + # the firmware stop-disk zh, and the deck floor caps how deep it may go. With no tip there is no + # overhang, so minimum_height is the stop-disk position directly and is guarded against z_min. + overhang = 0.0 + if has_tips: + overhang = await self.head96_request_tip_length() - STARBackend.DEFAULT_TIP_FITTING_DEPTH + height_min = max(z_min - overhang, STARBackend.MINIMUM_CHANNEL_Z_POSITION) + height_max = z_max - overhang + if minimum_height is None: + minimum_height = height_min + assert height_min <= minimum_height <= height_max, ( + f"minimum_height must be between {height_min} and {height_max} mm" + ) + + volume_increment = self._head96_dispensing_drive_uL_to_increment(volume) + flow_rate_increment = self._head96_dispensing_drive_uL_to_increment(flow_rate) + stop_back_increment = self._head96_dispensing_drive_uL_to_increment(stop_back_volume) + surface_following_increment = self._head96_z_drive_mm_to_increment(surface_following_distance) + zh_increment = self._head96_z_drive_mm_to_increment(minimum_height + overhang) + stop_flow_rate_increment = self._head96_dispensing_drive_uL_to_increment(stop_flow_rate) + return await self.send_command( + module="H0", + command="PB", + pm="F" * 24, # all 96 channels; the rigid head has no per-channel selection + db=f"{volume_increment:05}", + dv=f"{flow_rate_increment:05}", + dd=f"{stop_back_increment:04}", + ze=f"{surface_following_increment:04}", + zh=f"{zh_increment:05}", + du=f"{stop_flow_rate_increment:05}", + ) + + @_requires_head96 + @need_iswap_parked + async def mix96( + self, + mix: Mix, + resource: Union[Plate, Container, List[Well]], + offset: Coordinate = Coordinate.zero(), + minimum_traverse_height_start: Optional[float] = None, + blowout_air_volume: float = 5.0, + lld_mode: Optional[LLDMode] = None, + descent_speed: float = 80.0, + swap_speed: float = 5.0, + settling_time: float = 0.0, + minimum_traverse_height_end: Optional[float] = None, + ): + """Mix in place with the 96-head over a resource (aspirate96-style target). + + Thin convenience wrapper over :meth:`mix96_at_coordinate`: resolves the channel-A1 deck + target (and the container top, used for the swap-start clearance) from ``resource`` like + ``aspirate96`` does, applies ``offset``, and delegates. See that method for the mixing + behaviour and the meaning of the remaining arguments. + + Args: + mix: volume, repetitions, flow_rate and optional surface_following_distance. + resource: aspirate96-style target - a Plate (head A1 over well A1), a Container, or a list + of Wells. + offset: added to the resolved channel-A1 target position. + minimum_traverse_height_start: absolute tip-bottom Z before the X/Y move; None uses full Z + safety. + blowout_air_volume: air gap taken above the well before descent and expelled above it on + exit, to clear the tips of residual on the way out; 0 skips both. + lld_mode: liquid-level-detection mode; only ``LLDMode.OFF`` is supported (the default). + descent_speed: speed for the fast descent down to just above the well. + swap_speed: speed from there into the well to the target Z. + settling_time: seconds to wait after the last cycle, before retracting out of the well. + minimum_traverse_height_end: absolute tip-bottom Z after mixing; None uses full Z safety. + """ + anchor: Container + if isinstance(resource, Plate): + anchor = resource.get_item(0) # head A1 over well A1 (as aspirate96 resolves it) + elif isinstance(resource, list): + anchor = resource[0] + else: + anchor = resource + a1 = anchor.get_absolute_location(x="c", y="c", z="cavity_bottom") + offset + z_top = anchor.get_absolute_location(x="c", y="c", z="top").z + + return await self.mix96_at_coordinate( + mix, + a1_coordinate=a1, + z_top=z_top, + minimum_traverse_height_start=minimum_traverse_height_start, + blowout_air_volume=blowout_air_volume, + lld_mode=lld_mode, + descent_speed=descent_speed, + swap_speed=swap_speed, + settling_time=settling_time, + minimum_traverse_height_end=minimum_traverse_height_end, + ) + + async def mix96_at_coordinate( + self, + mix: Mix, + a1_coordinate: Coordinate, + z_top: Optional[float] = None, + minimum_traverse_height_start: Optional[float] = None, + blowout_air_volume: float = 5.0, + lld_mode: Optional[LLDMode] = None, + descent_speed: float = 80.0, + swap_speed: float = 5.0, + settling_time: float = 0.0, + minimum_traverse_height_end: Optional[float] = None, + ): + """Position the 96-head over an explicit channel-A1 deck coordinate and mix in place. + + Raises the single channels to safe Z, then moves X/Y over the target and descends into the + well, then runs ``mix.repetitions`` symmetric aspirate / dispense cycles: each aspirate + follows the surface down by ``surface_following_distance`` and each dispense follows it + back up, so the tip oscillates without drifting. Returns to a traverse height when done. + + Z targets are in tip-bottom space (the target Z is where the tip end goes, not the stop + disk). For a resource-relative target, use :meth:`mix96`. + + Args: + mix: volume, repetitions, flow_rate and optional surface_following_distance. + a1_coordinate: explicit channel-A1 deck target (tip-bottom space). + z_top: container top Z used for the swap-start clearance above the well; None descends + straight to just above the mix start. + minimum_traverse_height_start: absolute tip-bottom Z before the X/Y move; None uses full Z + safety. + blowout_air_volume: air gap taken above the well before descent and expelled above it on + exit, to clear the tips of residual on the way out; 0 skips both. + lld_mode: liquid-level-detection mode; only ``LLDMode.OFF`` is supported (the default). + descent_speed: speed for the fast descent down to just above the well. + swap_speed: speed from there into the well to the target Z. + settling_time: seconds to wait after the last cycle, before retracting out of the well. + minimum_traverse_height_end: absolute tip-bottom Z after mixing; None uses full Z safety. + + Raises: + ValueError: if ``lld_mode`` is not ``LLDMode.OFF`` or ``settling_time`` < 0. + RuntimeError: if the 96-head holds no tips. + """ + lld_mode = lld_mode if lld_mode is not None else self.LLDMode.OFF + if lld_mode is not self.LLDMode.OFF: + raise ValueError("mix96 currently supports only LLDMode.OFF") + + if settling_time < 0: + raise ValueError("settling_time must be >= 0") + + if await self.head96_request_tip_presence() == 0: + raise RuntimeError("96-head has no tips (firmware reports none); pick up tips first") + + # H0 direct-drive moves don't raise the single channels (the C0 core-96 commands do so at + # firmware level), so do it explicitly before the X/Y traverse. + await self.move_all_channels_in_z_safety() + + a1 = a1_coordinate + + # traverse to start height; None retracts to full (stop-disk) Z safety, a value is the + # tip-bottom height the rest of the method works in + if minimum_traverse_height_start is None: + await self.head96_move_to_z_safety() + else: + await self.head96_move_tool_z(minimum_traverse_height_start, speed=descent_speed) + + # move X, Y; X acceleration_level=1 below y=200 mm, the low-Y zone where the head is + # cantilevered forward off the X-drive and wobbles most + # TODO: replace head96_move_y with a primitive Y move to enable parallelised addressing of + # the X and Y drives. Its speed/acceleration request+set round-trip currently blocks + # parallelisation of the Y drive: the second read is trapped behind the in-flight X move on + # the single connection, so the Y move only starts once X has finished. + await asyncio.gather( + self.head96_move_x(a1.x, acceleration_level=1 if a1.y <= 200.0 else 3), + self.head96_move_y(a1.y), + ) + + # the tip oscillates between the floor (a1.z) and mix_start (floor + sf), starting at + # mix_start so the first aspirate can follow the surface down without hitting the bottom. + sf = 0.0 if mix.surface_following_distance is None else mix.surface_following_distance + mix_floor = a1.z + mix_start = a1.z + sf + + # 2-stage Z descent in tip-bottom space: descent_speed to the swap-start height just above + # the well, aspirate blowout_air_volume, then swap_speed down to mix_start; move_tool_z lands + # the tip end each move. + z_clearance = 5.0 + swap_start_z = (z_top if z_top is not None else mix_start) + z_clearance + await self.head96_move_tool_z(swap_start_z, speed=descent_speed) + if blowout_air_volume: + await self.head96_experimental_aspirate( + blowout_air_volume, + flow_rate=mix.flow_rate, + minimum_height=mix_floor, + surface_following_distance=0, + requires_tip=False, + ) + await self.head96_move_tool_z(mix_start, speed=swap_speed) + + # symmetric mix cycles (no per-cycle drift): each aspirate follows the surface down by sf + # to the floor, each dispense back up to mix_start. minimum_height is the tip-bottom floor; + # the experimental commands convert it to the stop-disk reference. + for _ in range(mix.repetitions): + await self.head96_experimental_aspirate( + mix.volume, + flow_rate=mix.flow_rate, + minimum_height=mix_floor, + surface_following_distance=sf, + requires_tip=False, + ) + await self.head96_experimental_dispense( + mix.volume, + flow_rate=mix.flow_rate, + minimum_height=mix_floor, + surface_following_distance=sf, + requires_tip=False, + ) + + # settle in place (tip still in the liquid) after the last cycle + if settling_time: + await asyncio.sleep(settling_time) + + # careful exit at swap_speed back up to the swap-start height (mirrors the descent), before + # the fast traverse out + await self.head96_move_tool_z(swap_start_z, speed=swap_speed) + + if blowout_air_volume: + await self.head96_experimental_dispense( + blowout_air_volume, + flow_rate=mix.flow_rate, + requires_tip=False, + ) + + # traverse to end height; None retracts to full (stop-disk) Z safety, a value is the + # tip-bottom height the rest of the method works in + if minimum_traverse_height_end is None: + await self.head96_move_to_z_safety() + else: + await self.head96_move_tool_z(minimum_traverse_height_end, speed=descent_speed) + + # # # Granular commands # # # + + async def head96_dispensing_drive_move_to_home_volume( + self, + ): + """Move the 96-head dispensing drive into its home position (vol=0.0 uL). + + .. warning:: + This firmware command is known to be broken: the 96-head dispensing drive cannot reach + vol=0.0 uL, which typically raises + ``STARFirmwareError: {'CoRe 96 Head': UnknownHamiltonError('Position out of permitted + area')}``. + """ + + logger.warning( + "head96_dispensing_drive_move_to_home_volume is a known broken firmware command: " + "the 96-head dispensing drive cannot reach vol=0.0 uL and will likely raise " + "STARFirmwareError: {'CoRe 96 Head': UnknownHamiltonError('Position out of permitted " + "area')}. Attempting to send the command anyway." + ) + + return await self.send_command( + module="H0", + command="DL", + ) + + # # # "Atomic" liquid handling commands # # # + + @need_iswap_parked + @_requires_head96 + async def aspirate_core_96( + self, + aspiration_type: int = 0, + x_position: int = 0, + x_direction: int = 0, + y_positions: int = 0, + minimum_traverse_height_at_beginning_of_a_command: int = 3425, + min_z_endpos: int = 3425, + lld_search_height: int = 3425, + liquid_surface_no_lld: int = 3425, + pull_out_distance_transport_air: int = 3425, + minimum_height: int = 3425, + second_section_height: int = 0, + second_section_ratio: int = 3425, + immersion_depth: int = 0, + immersion_depth_direction: int = 0, + surface_following_distance: float = 0, + aspiration_volumes: int = 0, + aspiration_speed: int = 1000, + transport_air_volume: int = 0, + blow_out_air_volume: int = 200, + pre_wetting_volume: int = 0, + lld_mode: int = 1, + gamma_lld_sensitivity: int = 1, + swap_speed: int = 100, + settling_time: int = 5, + mix_volume: int = 0, + mix_cycles: int = 0, + mix_position_from_liquid_surface: int = 250, + mix_surface_following_distance: int = 0, + speed_of_mix: int = 1000, + channel_pattern: List[bool] = [True] * 96, + limit_curve_index: int = 0, + tadm_algorithm: bool = False, + recording_mode: int = 0, + # Deprecated parameters, to be removed in future versions + # rm: >2026-01: + liquid_surface_sink_distance_at_the_end_of_aspiration: float = 0, + minimal_end_height: int = 3425, + liquid_surface_at_function_without_lld: int = 3425, + pull_out_distance_to_take_transport_air_in_function_without_lld: int = 50, + maximum_immersion_depth: int = 3425, + surface_following_distance_during_mix: int = 0, + tube_2nd_section_ratio: int = 3425, + tube_2nd_section_height_measured_from_zm: int = 0, + ): + """aspirate CoRe 96 + + Aspiration of liquid using CoRe 96 + + Args: + aspiration_type: Type of aspiration (0 = simple; 1 = sequence; 2 = cup emptied). Must be + between 0 and 2. Default 0. + x_position: X-Position [0.1mm] of well A1. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_positions: Y-Position [0.1mm] of well A1. Must be between 1080 and 5600. Default 0. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of + a command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). + Must be between 0 and 3425. Default 3425. + min_z_endpos: Minimal height at command end [0.1mm]. Must be between 0 and 3425. Default 3425. + lld_search_height: LLD search height [0.1mm]. Must be between 0 and 3425. Default 3425. + liquid_surface_no_lld: Liquid surface at function without LLD [0.1mm]. Must be between 0 and 3425. Default 3425. + pull_out_distance_transport_air: pull out distance to take transport air in function without LLD [0.1mm]. Must be between 0 and 3425. Default 50. + minimum_height: Minimum height (maximum immersion depth) [0.1mm]. Must be between 0 and 3425. Default 3425. + second_section_height: second ratio height. Must be between 0 and 3425. Default 0. + second_section_ratio: Tube 2nd section ratio (See Fig 2.). Must be between 0 and 10000. Default 3425. + immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0. + immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out of + liquid). Must be between 0 and 1. Default 0. + surface_following_distance_at_the_end_of_aspiration: Surface following distance during + aspiration [0.1mm]. Must be between 0 and 990. Default 0. (renamed for clarity from + 'liquid_surface_sink_distance_at_the_end_of_aspiration' in firmware docs) + aspiration_volumes: Aspiration volume [0.1ul]. Must be between 0 and 11500. Default 0. + aspiration_speed: Aspiration speed [0.1ul/s]. Must be between 3 and 5000. Default 1000. + transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0. + blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 11500. Default 200. + pre_wetting_volume: Pre-wetting volume. Must be between 0 and 11500. Default 0. + lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be between + 0 and 4. Default 1. + gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and 4. + Default 1. + swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1000. Default 100. + settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5. + mix_volume: mix volume [0.1ul]. Must be between 0 and 11500. Default 0. + mix_cycles: Number of mix cycles. Must be between 0 and 99. Default 0. + mix_position_from_liquid_surface: mix position in Z- direction from + liquid surface (LLD or absolute terms) [0.1mm]. Must be between 0 and 990. Default 250. + mix_surface_following_distance: surface following distance during + mix [0.1mm]. Must be between 0 and 990. Default 0. + speed_of_mix: Speed of mix [0.1ul/s]. Must be between 3 and 5000. + Default 1000. + todo: TODO: 24 hex chars. Must be between 4 and 5000. + limit_curve_index: limit curve index. Must be between 0 and 999. Default 0. + tadm_algorithm: TADM algorithm. Default False. + recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. + Must be between 0 and 2. Default 0. + """ + + # # # TODO: delete > 2026-01 # # # + # deprecated liquid_surface_sink_distance_at_the_end_of_aspiration: + if liquid_surface_sink_distance_at_the_end_of_aspiration != 0.0: + surface_following_distance = liquid_surface_sink_distance_at_the_end_of_aspiration + warnings.warn( + "The liquid_surface_sink_distance_at_the_end_of_aspiration parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard surface_following_distance parameter instead.\n" + "liquid_surface_sink_distance_at_the_end_of_aspiration currently superseding " + "surface_following_distance.", + DeprecationWarning, + ) + + if minimal_end_height != 3425: + min_z_endpos = minimal_end_height + warnings.warn( + "The minimal_end_height parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard min_z_endpos parameter instead.\n" + "minimal_end_height currently superseding min_z_endpos.", + DeprecationWarning, + ) + + if liquid_surface_at_function_without_lld != 3425: + liquid_surface_no_lld = liquid_surface_at_function_without_lld + warnings.warn( + "The liquid_surface_at_function_without_lld parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard liquid_surface_no_lld parameter instead.\n" + "liquid_surface_at_function_without_lld currently superseding liquid_surface_no_lld.", + DeprecationWarning, + ) + + if pull_out_distance_to_take_transport_air_in_function_without_lld != 50: + pull_out_distance_transport_air = ( + pull_out_distance_to_take_transport_air_in_function_without_lld + ) + warnings.warn( + "The pull_out_distance_to_take_transport_air_in_function_without_lld parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard pull_out_distance_transport_air parameter instead.\n" + "pull_out_distance_to_take_transport_air_in_function_without_lld currently superseding pull_out_distance_transport_air.", + DeprecationWarning, + ) + + if maximum_immersion_depth != 3425: + minimum_height = maximum_immersion_depth + warnings.warn( + "The maximum_immersion_depth parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard minimum_height parameter instead.\n" + "minimum_height currently superseding maximum_immersion_depth.", + DeprecationWarning, + ) + + if surface_following_distance_during_mix != 0: + mix_surface_following_distance = surface_following_distance_during_mix + warnings.warn( + "The surface_following_distance_during_mix parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard mix_surface_following_distance parameter instead.\n" + "surface_following_distance_during_mix currently superseding mix_surface_following_distance.", + DeprecationWarning, + ) + + if tube_2nd_section_ratio != 3425: + second_section_ratio = tube_2nd_section_ratio + warnings.warn( + "The tube_2nd_section_ratio parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard second_section_ratio parameter instead.\n" + "tube_2nd_section_ratio currently superseding second_section_ratio.", + DeprecationWarning, + ) + + if tube_2nd_section_height_measured_from_zm != 0: + second_section_height = tube_2nd_section_height_measured_from_zm + warnings.warn( + "The tube_2nd_section_height_measured_from_zm parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard tube_2nd_section_height_measured_from_zm parameter instead.\n" + "tube_2nd_section_height_measured_from_zm currently superseding tube_2nd_section_height_measured_from_zm.", + DeprecationWarning, + ) + # # # delete # # # + + assert 0 <= aspiration_type <= 2, "aspiration_type must be between 0 and 2" + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 1080 <= y_positions <= 5600, "y_positions must be between 1080 and 5600" + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3425, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3425" + ) + assert 0 <= min_z_endpos <= 3425, "min_z_endpos must be between 0 and 3425" + assert 0 <= lld_search_height <= 3425, "lld_search_height must be between 0 and 3425" + assert 0 <= liquid_surface_no_lld <= 3425, "liquid_surface_no_lld must be between 0 and 3425" + assert 0 <= pull_out_distance_transport_air <= 3425, ( + "pull_out_distance_transport_air must be between 0 and 3425" + ) + assert 0 <= minimum_height <= 3425, "minimum_height must be between 0 and 3425" + assert 0 <= second_section_height <= 3425, "second_section_height must be between 0 and 3425" + assert 0 <= second_section_ratio <= 10000, "second_section_ratio must be between 0 and 10000" + assert 0 <= immersion_depth <= 3600, "immersion_depth must be between 0 and 3600" + assert 0 <= immersion_depth_direction <= 1, "immersion_depth_direction must be between 0 and 1" + assert 0 <= surface_following_distance <= 990, ( + "surface_following_distance must be between 0 and 990" + ) + assert 0 <= aspiration_volumes <= 11500, "aspiration_volumes must be between 0 and 11500" + assert 3 <= aspiration_speed <= 5000, "aspiration_speed must be between 3 and 5000" + assert 0 <= transport_air_volume <= 500, "transport_air_volume must be between 0 and 500" + assert 0 <= blow_out_air_volume <= 11500, "blow_out_air_volume must be between 0 and 11500" + assert 0 <= pre_wetting_volume <= 11500, "pre_wetting_volume must be between 0 and 11500" + assert 0 <= lld_mode <= 4, "lld_mode must be between 0 and 4" + assert 1 <= gamma_lld_sensitivity <= 4, "gamma_lld_sensitivity must be between 1 and 4" + assert 3 <= swap_speed <= 1000, "swap_speed must be between 3 and 1000" + assert 0 <= settling_time <= 99, "settling_time must be between 0 and 99" + assert 0 <= mix_volume <= 11500, "mix_volume must be between 0 and 11500" + assert 0 <= mix_cycles <= 99, "mix_cycles must be between 0 and 99" + assert 0 <= mix_position_from_liquid_surface <= 990, ( + "mix_position_from_liquid_surface must be between 0 and 990" + ) + assert 0 <= mix_surface_following_distance <= 990, ( + "mix_surface_following_distance must be between 0 and 990" + ) + assert 3 <= speed_of_mix <= 5000, "speed_of_mix must be between 3 and 5000" + assert 0 <= limit_curve_index <= 999, "limit_curve_index must be between 0 and 999" + + assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" + + # Convert bool list to hex string + assert len(channel_pattern) == 96, "channel_pattern must be a list of 96 boolean values" + channel_pattern_bin_str = reversed(["1" if x else "0" for x in channel_pattern]) + channel_pattern_hex = hex(int("".join(channel_pattern_bin_str), 2)).upper()[2:] + + return await self.send_command( + module="C0", + command="EA", + read_timeout=max(300, self.read_timeout), + aa=aspiration_type, + xs=f"{x_position:05}", + xd=x_direction, + yh=f"{y_positions:04}", + zh=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + ze=f"{min_z_endpos:04}", + lz=f"{lld_search_height:04}", + zt=f"{liquid_surface_no_lld:04}", + pp=f"{pull_out_distance_transport_air:04}", + zm=f"{minimum_height:04}", + zv=f"{second_section_height:04}", + zq=f"{second_section_ratio:05}", + iw=f"{immersion_depth:03}", + ix=immersion_depth_direction, + fh=f"{surface_following_distance:03}", + af=f"{aspiration_volumes:05}", + ag=f"{aspiration_speed:04}", + vt=f"{transport_air_volume:03}", + bv=f"{blow_out_air_volume:05}", + wv=f"{pre_wetting_volume:05}", + cm=lld_mode, + cs=gamma_lld_sensitivity, + bs=f"{swap_speed:04}", + wh=f"{settling_time:02}", + hv=f"{mix_volume:05}", + hc=f"{mix_cycles:02}", + hp=f"{mix_position_from_liquid_surface:03}", + mj=f"{mix_surface_following_distance:03}", + hs=f"{speed_of_mix:04}", + cw=channel_pattern_hex, + cr=f"{limit_curve_index:03}", + cj=tadm_algorithm, + cx=recording_mode, + ) + + @need_iswap_parked + @_requires_head96 + async def dispense_core_96( + self, + dispensing_mode: int = 0, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + second_section_height: int = 0, + second_section_ratio: int = 3425, + lld_search_height: int = 3425, + liquid_surface_no_lld: int = 3425, + pull_out_distance_transport_air: int = 50, + minimum_height: int = 3425, + immersion_depth: int = 0, + immersion_depth_direction: int = 0, + surface_following_distance: float = 0, + minimum_traverse_height_at_beginning_of_a_command: int = 3425, + min_z_endpos: int = 3425, + dispense_volume: int = 0, + dispense_speed: int = 5000, + cut_off_speed: int = 250, + stop_back_volume: int = 0, + transport_air_volume: int = 0, + blow_out_air_volume: int = 200, + lld_mode: int = 1, + gamma_lld_sensitivity: int = 1, + side_touch_off_distance: int = 0, + swap_speed: int = 100, + settling_time: int = 5, + mixing_volume: int = 0, + mixing_cycles: int = 0, + mix_position_from_liquid_surface: int = 250, + mix_surface_following_distance: int = 0, + speed_of_mixing: int = 1000, + channel_pattern: List[bool] = [True] * 12 * 8, + limit_curve_index: int = 0, + tadm_algorithm: bool = False, + recording_mode: int = 0, + # Deprecated parameters, to be removed in future versions + # rm: >2026-01: + liquid_surface_sink_distance_at_the_end_of_dispense: float = 0, # surface_following_distance! + tube_2nd_section_ratio: int = 3425, + liquid_surface_at_function_without_lld: int = 3425, + maximum_immersion_depth: int = 3425, + minimal_end_height: int = 3425, + mixing_position_from_liquid_surface: int = 250, + surface_following_distance_during_mixing: int = 0, + pull_out_distance_to_take_transport_air_in_function_without_lld: int = 50, + tube_2nd_section_height_measured_from_zm: int = 0, + ): + """Dispensing of liquid using CoRe 96 + + Args: + dispensing_mode: Type of dispensing mode 0 = Partial volume in jet mode 1 = Blow out + in jet mode 2 = Partial volume at surface 3 = Blow out at surface 4 = Empty tip at fix + position. Must be between 0 and 4. Default 0. + x_position: X-Position [0.1mm] of well A1. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: Y-Position [0.1mm] of well A1. Must be between 1080 and 5600. Default 0. + minimum_height: Minimum height (maximum immersion depth) [0.1mm]. Must be between 0 and 3425. Default 3425. + second_section_height: Second ratio height. [0.1mm]. Must be between 0 and 3425. Default 0. + second_section_ratio: Tube 2nd section ratio (See Fig 2.). Must be between 0 and 10000. Default 3425. + lld_search_height: LLD search height [0.1mm]. Must be between 0 and 3425. Default 3425. + liquid_surface_no_lld: Liquid surface at function without LLD [0.1mm]. Must be between 0 and 3425. Default 3425. + pull_out_distance_transport_air: pull out distance to take transport air in function without LLD [0.1mm]. Must be between 0 and 3425. Default 50. + immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0. + immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out of + liquid). Must be between 0 and 1. Default 0. + surface_following_distance: Liquid surface following distance during dispense [0.1mm]. + Must be between 0 and 990. Default 0. (renamed for clarity from + 'liquid_surface_sink_distance_at_the_end_of_dispense' in firmware docs) + minimum_traverse_height_at_beginning_of_a_command: Minimal traverse height at begin of + command [0.1mm]. Must be between 0 and 3425. Default 3425. + min_z_endpos: Minimal height at command end [0.1mm]. Must be between 0 and 3425. Default 3425. + dispense_volume: Dispense volume [0.1ul]. Must be between 0 and 11500. Default 0. + dispense_speed: Dispense speed [0.1ul/s]. Must be between 3 and 5000. Default 5000. + cut_off_speed: Cut-off speed [0.1ul/s]. Must be between 3 and 5000. Default 250. + stop_back_volume: Stop back volume [0.1ul/s]. Must be between 0 and 999. Default 0. + transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0. + blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 11500. Default 200. + lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be + between 0 and 4. Default 1. + gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and 4. + Default 1. + side_touch_off_distance: side touch off distance [0.1 mm] 0 = OFF ( > 0 = ON & turns LLD off) + Must be between 0 and 45. Default 1. + swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1000. Default 100. + settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5. + mixing_volume: mix volume [0.1ul]. Must be between 0 and 11500. Default 0. + mixing_cycles: Number of mixing cycles. Must be between 0 and 99. Default 0. + mix_position_from_liquid_surface: mix position in Z- direction from liquid surface (LLD or absolute terms) [0.1mm]. Must be between 0 and 990. Default 250. + mix_surface_following_distance: surface following distance during mixing [0.1mm]. Must be between 0 and 990. Default 0. + speed_of_mixing: Speed of mixing [0.1ul/s]. Must be between 3 and 5000. Default 1000. + channel_pattern: list of 96 boolean values + limit_curve_index: limit curve index. Must be between 0 and 999. Default 0. + tadm_algorithm: TADM algorithm. Default False. + recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. Must + be between 0 and 2. Default 0. + """ + + # # # TODO: delete > 2026-01 # # # + # deprecated liquid_surface_sink_distance_at_the_end_of_aspiration: + if liquid_surface_sink_distance_at_the_end_of_dispense != 0.0: + surface_following_distance = liquid_surface_sink_distance_at_the_end_of_dispense + warnings.warn( + "The liquid_surface_sink_distance_at_the_end_of_dispense parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard surface_following_distance parameter instead.\n" + "liquid_surface_sink_distance_at_the_end_of_dispense currently superseding surface_following_distance.", + DeprecationWarning, + ) + + if tube_2nd_section_ratio != 3425: + second_section_ratio = tube_2nd_section_ratio + warnings.warn( + "The tube_2nd_section_ratio parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard second_section_ratio parameter instead.\n" + "second_section_ratio currently superseding tube_2nd_section_ratio.", + DeprecationWarning, + ) + + if maximum_immersion_depth != 3425: + minimum_height = maximum_immersion_depth + warnings.warn( + "The maximum_immersion_depth parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard minimum_height parameter instead.\n" + "minimum_height currently superseding maximum_immersion_depth.", + DeprecationWarning, + ) + + if liquid_surface_at_function_without_lld != 3425: + liquid_surface_no_lld = liquid_surface_at_function_without_lld + warnings.warn( + "The liquid_surface_at_function_without_lld parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard liquid_surface_no_lld parameter instead.\n" + "liquid_surface_at_function_without_lld currently superseding liquid_surface_no_lld.", + DeprecationWarning, + ) + + if minimal_end_height != 3425: + min_z_endpos = minimal_end_height + warnings.warn( + "The minimal_end_height parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard min_z_endpos parameter instead.\n" + "minimal_end_height currently superseding min_z_endpos.", + DeprecationWarning, + ) + + if mixing_position_from_liquid_surface != 250: + mix_position_from_liquid_surface = mixing_position_from_liquid_surface + warnings.warn( + "The mixing_position_from_liquid_surface parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard mix_position_from_liquid_surface parameter instead.\n" + "mixing_position_from_liquid_surface currently superseding mix_position_from_liquid_surface.", + DeprecationWarning, + ) + + if surface_following_distance_during_mixing != 0: + mix_surface_following_distance = surface_following_distance_during_mixing + warnings.warn( + "The surface_following_distance_during_mixing parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard mix_surface_following_distance parameter instead.\n" + "mix_surface_following_distance currently superseding surface_following_distance_during_mixing.", + DeprecationWarning, + ) + + if pull_out_distance_to_take_transport_air_in_function_without_lld != 50: + pull_out_distance_transport_air = ( + pull_out_distance_to_take_transport_air_in_function_without_lld + ) + warnings.warn( + "The pull_out_distance_to_take_transport_air_in_function_without_lld parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard pull_out_distance_transport_air parameter instead.\n" + "pull_out_distance_to_take_transport_air_in_function_without_lld currently superseding pull_out_distance_transport_air.", + DeprecationWarning, + ) + + if tube_2nd_section_height_measured_from_zm != 0: + second_section_height = tube_2nd_section_height_measured_from_zm + warnings.warn( + "The tube_2nd_section_height_measured_from_zm parameter is deprecated and will be removed in the future. " + "Use the Hamilton-standard second_section_height parameter instead.\n" + "tube_2nd_section_height_measured_from_zm currently superseding second_section_height.", + DeprecationWarning, + ) + # # # delete # # # + + assert 0 <= dispensing_mode <= 4, "dispensing_mode must be between 0 and 4" + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 1080 <= y_position <= 5600, "y_position must be between 1080 and 5600" + assert 0 <= minimum_height <= 3425, "minimum_height must be between 0 and 3425" + assert 0 <= second_section_height <= 3425, "second_section_height must be between 0 and 3425" + assert 0 <= second_section_ratio <= 10000, "second_section_ratio must be between 0 and 10000" + assert 0 <= lld_search_height <= 3425, "lld_search_height must be between 0 and 3425" + assert 0 <= liquid_surface_no_lld <= 3425, "liquid_surface_no_lld must be between 0 and 3425" + assert 0 <= pull_out_distance_transport_air <= 3425, ( + "pull_out_distance_transport_air must be between 0 and 3425" + ) + assert 0 <= immersion_depth <= 3600, "immersion_depth must be between 0 and 3600" + assert 0 <= immersion_depth_direction <= 1, "immersion_depth_direction must be between 0 and 1" + assert 0 <= surface_following_distance <= 990, ( + "surface_following_distance must be between 0 and 990" + ) + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3425, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3425" + ) + assert 0 <= min_z_endpos <= 3425, "min_z_endpos must be between 0 and 3425" + assert 0 <= dispense_volume <= 11500, "dispense_volume must be between 0 and 11500" + assert 3 <= dispense_speed <= 5000, "dispense_speed must be between 3 and 5000" + assert 3 <= cut_off_speed <= 5000, "cut_off_speed must be between 3 and 5000" + assert 0 <= stop_back_volume <= 999, "stop_back_volume must be between 0 and 999" + assert 0 <= transport_air_volume <= 500, "transport_air_volume must be between 0 and 500" + assert 0 <= blow_out_air_volume <= 11500, "blow_out_air_volume must be between 0 and 11500" + assert 0 <= lld_mode <= 4, "lld_mode must be between 0 and 4" + assert 1 <= gamma_lld_sensitivity <= 4, "gamma_lld_sensitivity must be between 1 and 4" + assert 0 <= side_touch_off_distance <= 45, "side_touch_off_distance must be between 0 and 45" + assert 3 <= swap_speed <= 1000, "swap_speed must be between 3 and 1000" + assert 0 <= settling_time <= 99, "settling_time must be between 0 and 99" + assert 0 <= mixing_volume <= 11500, "mixing_volume must be between 0 and 11500" + assert 0 <= mixing_cycles <= 99, "mixing_cycles must be between 0 and 99" + assert 0 <= mix_position_from_liquid_surface <= 990, ( + "mix_position_from_liquid_surface must be between 0 and 990" + ) + assert 0 <= mix_surface_following_distance <= 990, ( + "mix_surface_following_distance must be between 0 and 990" + ) + assert 3 <= speed_of_mixing <= 5000, "speed_of_mixing must be between 3 and 5000" + assert 0 <= limit_curve_index <= 999, "limit_curve_index must be between 0 and 999" + assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" + + # Convert bool list to hex string + assert len(channel_pattern) == 96, "channel_pattern must be a list of 96 boolean values" + channel_pattern_bin_str = reversed(["1" if x else "0" for x in channel_pattern]) + channel_pattern_hex = hex(int("".join(channel_pattern_bin_str), 2)).upper()[2:] + + return await self.send_command( + module="C0", + command="ED", + read_timeout=max(300, self.read_timeout), + da=dispensing_mode, + xs=f"{x_position:05}", + xd=x_direction, + yh=f"{y_position:04}", + zm=f"{minimum_height:04}", + zv=f"{second_section_height:04}", + zq=f"{second_section_ratio:05}", + lz=f"{lld_search_height:04}", + zt=f"{liquid_surface_no_lld:04}", + pp=f"{pull_out_distance_transport_air:04}", + iw=f"{immersion_depth:03}", + ix=immersion_depth_direction, + fh=f"{surface_following_distance:03}", + zh=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + ze=f"{min_z_endpos:04}", + df=f"{dispense_volume:05}", + dg=f"{dispense_speed:04}", + es=f"{cut_off_speed:04}", + ev=f"{stop_back_volume:03}", + vt=f"{transport_air_volume:03}", + bv=f"{blow_out_air_volume:05}", + cm=lld_mode, + cs=gamma_lld_sensitivity, + ej=f"{side_touch_off_distance:02}", + bs=f"{swap_speed:04}", + wh=f"{settling_time:02}", + hv=f"{mixing_volume:05}", + hc=f"{mixing_cycles:02}", + hp=f"{mix_position_from_liquid_surface:03}", + mj=f"{mix_surface_following_distance:03}", + hs=f"{speed_of_mixing:04}", + cw=channel_pattern_hex, + cr=f"{limit_curve_index:03}", + cj=tadm_algorithm, + cx=recording_mode, + ) + + # -------------- 3.10.4 Adjustment & movement commands -------------- + + @_requires_head96 + async def move_core_96_head_to_defined_position( + self, + x: float, + y: float, + z: float = 342.5, + minimum_height_at_beginning_of_a_command: float = 342.5, + ): + """Move CoRe 96 Head to defined position + + Args: + x: X-Position [1mm] of well A1. Must be between -300.0 and 300.0. Default 0. + y: Y-Position [1mm]. Must be between 108.0 and 560.0. Default 0. + z: Z-Position [1mm]. Must be between 0 and 560.0. Default 0. + minimum_height_at_beginning_of_a_command: Minimum height at beginning of a command [1mm] + (refers to all channels independent of tip pattern parameter 'tm'). Must be between 0 and + 342.5. Default 342.5. + """ + + warnings.warn( # TODO: remove 2025-02 + "`move_core_96_head_to_defined_position` is deprecated and will be " + "removed in 2025-02. Use `head96_move_to_coordinate` instead.", + DeprecationWarning, + stacklevel=2, + ) + + # TODO: these are values for a STARBackend. Find them for a STARlet. + self._check_96_position_legal(Coordinate(x, y, z)) + assert 0 <= minimum_height_at_beginning_of_a_command <= 342.5, ( + "minimum_height_at_beginning_of_a_command must be between 0 and 342.5" + ) + + return await self.send_command( + module="C0", + command="EM", + xs=f"{abs(round(x * 10)):05}", + xd=0 if x >= 0 else 1, + yh=f"{round(y * 10):04}", + za=f"{round(z * 10):04}", + zh=f"{round(minimum_height_at_beginning_of_a_command * 10):04}", + ) + + @_requires_head96 + async def head96_move_to_coordinate( + self, + coordinate: Coordinate, + minimum_height_at_beginning_of_a_command: float = 342.5, + ): + """Move STAR(let) 96-Head to defined Coordinate + + Args: + coordinate: Coordinate of A1 in mm + - if tip present refers to tip bottom, + - if not present refers to channel bottom + minimum_height_at_beginning_of_a_command: Minimum height at beginning of a command [1mm] + (refers to all channels independent of tip pattern parameter 'tm'). Must be between ? and + 342.5. Default 342.5. + """ + + self._check_96_position_legal(coordinate) + + assert 0 <= minimum_height_at_beginning_of_a_command <= 342.5, ( + "minimum_height_at_beginning_of_a_command must be between 0 and 342.5" + ) + + return await self.send_command( + module="C0", + command="EM", + xs=f"{abs(round(coordinate.x * 10)):05}", + xd="0" if coordinate.x >= 0 else "1", + yh=f"{round(coordinate.y * 10):04}", + za=f"{round(coordinate.z * 10):04}", + zh=f"{round(minimum_height_at_beginning_of_a_command * 10):04}", + ) + + HEAD96_DISPENSING_DRIVE_VOL_LIMIT_BOTTOM = 0 + HEAD96_DISPENSING_DRIVE_VOL_LIMIT_TOP = 1244.59 + + @_requires_head96 + async def head96_dispensing_drive_move_to_position( + self, + position, + speed: float = 261.1, + stop_speed: float = 0, + acceleration: float = 17406.84, + current_protection_limiter: int = 15, + ): + """Move dispensing drive to absolute position in uL + + Args: + position: Position in uL. Between 0, 1244.59. + speed: Speed in uL/s. Between 0.1, 1063.75. + stop_speed: Stop speed in uL/s. Between 0, 1063.75. + acceleration: Acceleration in uL/s^2. Between 96.7, 17406.84. + current_protection_limiter: Current protection limiter (0-15), default 15 + """ + + if not ( + self.HEAD96_DISPENSING_DRIVE_VOL_LIMIT_BOTTOM + <= position + <= self.HEAD96_DISPENSING_DRIVE_VOL_LIMIT_TOP + ): + raise ValueError("position must be between 0 and 1244.59") + if not (0.1 <= speed <= 1063.75): + raise ValueError("speed must be between 0.1 and 1063.75") + if not (0 <= stop_speed <= 1063.75): + raise ValueError("stop_speed must be between 0 and 1063.75") + if not (96.7 <= acceleration <= 17406.84): + raise ValueError("acceleration must be between 96.7 and 17406.84") + if not (0 <= current_protection_limiter <= 15): + raise ValueError("current_protection_limiter must be between 0 and 15") + + position_increments = self._head96_dispensing_drive_uL_to_increment(position) + speed_increments = self._head96_dispensing_drive_uL_to_increment(speed) + stop_speed_increments = self._head96_dispensing_drive_uL_to_increment(stop_speed) + acceleration_increments = self._head96_dispensing_drive_uL_to_increment(acceleration) + + await self.send_command( + module="H0", + command="DQ", + dq=f"{position_increments:05}", + dv=f"{speed_increments:05}", + du=f"{stop_speed_increments:05}", + dr=f"{acceleration_increments:06}", + dw=f"{current_protection_limiter:02}", + ) + + async def move_core_96_head_x(self, x_position: float): + """Move CoRe 96 Head X to absolute position + + .. deprecated:: + Use :meth:`head96_move_x` instead. Will be removed in 2026-06. + """ + warnings.warn( + "`move_core_96_head_x` is deprecated. Use `head96_move_x` instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.head96_move_x(x_position) + + async def move_core_96_head_y(self, y_position: float): + """Move CoRe 96 Head Y to absolute position + + .. deprecated:: + Use :meth:`head96_move_y` instead. Will be removed in 2026-06. + """ + warnings.warn( + "`move_core_96_head_y` is deprecated. Use `head96_move_y` instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.head96_move_y(y_position) + + async def move_core_96_head_z(self, z_position: float): + """Move CoRe 96 Head Z to absolute position + + .. deprecated:: + Use :meth:`head96_move_z` instead. Will be removed in 2026-06. + """ + warnings.warn( + "`move_core_96_head_z` is deprecated. Use `head96_move_z` instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.head96_move_stop_disk_z(z_position) + + async def move_96head_to_coordinate( + self, + coordinate: Coordinate, + minimum_height_at_beginning_of_a_command: float = 342.5, + ): + """Move STAR(let) 96-Head to defined Coordinate + + .. deprecated:: + Use :meth:`head96_move_to_coordinate` instead. Will be removed in 2026-06. + """ + warnings.warn( + "`move_96head_to_coordinate` is deprecated. Use `head96_move_to_coordinate` instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.head96_move_to_coordinate( + coordinate=coordinate, + minimum_height_at_beginning_of_a_command=minimum_height_at_beginning_of_a_command, + ) + + # -------------- 3.10.5 Wash procedure commands using CoRe 96 Head -------------- + + # TODO:(command:EG) Washing tips using CoRe 96 Head + # TODO:(command:EU) Empty washed tips (end of wash procedure only) + + # -------------- 3.10.6 Query CoRe 96 Head -------------- + + async def request_tip_presence_in_core_96_head(self): + """Deprecated - use `head96_request_tip_presence` instead. + + Returns: + dictionary with key qh: + qh: 0 = no tips, 1 = tips are picked up + """ + warnings.warn( # TODO: remove 2026-06 + "`request_tip_presence_in_core_96_head` is deprecated and will be " + "removed in 2026-06 use `head96_request_tip_presence` instead.", + DeprecationWarning, + stacklevel=2, + ) + + return await self.send_command(module="C0", command="QH", fmt="qh#") + + async def head96_request_tip_presence(self) -> int: + """Request Tip presence on the 96-Head + + Note: this command requests this information from the STAR(let)'s + internal memory. + It does not directly sense whether tips are present. + + Returns: + 0 = no tips + 1 = firmware believes tips are on the 96-head + """ + resp = await self.send_command(module="C0", command="QH", fmt="qh#") + + return int(resp["qh"]) + + async def request_position_of_core_96_head(self): + """Deprecated - use `head96_request_position` instead.""" + + warnings.warn( # TODO: remove 2026-02 + "`request_position_of_core_96_head` is deprecated and will be " + "removed in 2026-02 use `head96_request_position` instead.", + DeprecationWarning, + stacklevel=2, + ) + + return await self.head96_request_position() + + async def head96_request_position(self) -> Coordinate: + """Request position of CoRe 96 Head (A1 considered to tip length) + + Returns: + Coordinate: x, y, z in mm + """ + + resp = await self.send_command(module="C0", command="QI", fmt="xs#####xd#yh####za####") + + x_coordinate = resp["xs"] / 10 + y_coordinate = resp["yh"] / 10 + z_coordinate = resp["za"] / 10 + + x_coordinate = x_coordinate if resp["xd"] == 0 else -x_coordinate + + return Coordinate(x=x_coordinate, y=y_coordinate, z=z_coordinate) + + @_requires_head96 + async def head96_request_stop_disk_z(self) -> float: + """Request the 96-head z-drive (stop-disk) position in mm. + + Unlike `head96_request_position` (whose z is the tip bottom when a tip is mounted), this is the + raw z-drive position - the stop disk - regardless of tip state. + + Returns: + Stop-disk Z position in mm. + """ + resp = await self.send_command(module="H0", command="RZ", fmt="rz##### (n)") + return self._head96_z_drive_increment_to_mm(resp["rz"][1]) # [0] = FW counter, [1] = HW counter + + async def head96_request_last_lld_height(self) -> float: + """Request the liquid-surface position the last 96-head cLLD search found, in mm (H0 RH). + + Unlike `head96_request_stop_disk_z` (the head's current position), this is the latched surface + the last `ZL` search detected, so it is unaffected by any post-detection move - the head + counterpart of the channel `request_pip_height_last_lld`. + + Returns: + Detected liquid-surface Z position (stop-disk frame) in mm. + """ + resp = await self.send_command(module="H0", command="RH", fmt="rh#####") + return self._head96_z_drive_increment_to_mm(resp["rh"]) + + async def _head96_probe_z_max(self) -> float: + """Probe the reachable Z top (mm) for this unit: drive to the firmware Z-safety height (C0 EV) + and read the stop disk there. The generic command-range max can exceed what this unit actually + reaches, so the top is read off the hardware. + + Doubles as the firmware Z-safety retract (the EV), so it can stand in for an explicit safe-z + move. Needs no Head96Information, so it is usable before that record exists. + """ + await self.send_command(module="C0", command="EV", read_timeout=20) + return await self.head96_request_stop_disk_z() + + async def request_core_96_head_channel_tadm_status(self): + """Request CoRe 96 Head channel TADM Status + + Returns: + qx: TADM channel status 0 = off 1 = on + """ + + return await self.send_command(module="C0", command="VC", fmt="qx#") + + async def request_core_96_head_channel_tadm_error_status(self): + """Request CoRe 96 Head channel TADM error status + + Returns: + vb: error pattern 0 = no error + """ + + return await self.send_command(module="C0", command="VB", fmt="vb" + "&" * 24) + + async def head96_dispensing_drive_request_position_mm(self) -> float: + """Request 96 Head dispensing drive position in mm""" + resp = await self.send_command(module="H0", command="RD", fmt="rd######") + return self._head96_dispensing_drive_increment_to_mm(resp["rd"]) + + async def head96_dispensing_drive_request_position_uL(self) -> float: + """Request 96 Head dispensing drive position in uL""" + position_mm = await self.head96_dispensing_drive_request_position_mm() + return self._head96_dispensing_drive_mm_to_uL(position_mm) + + # -------------- 3.11 384 Head commands -------------- + + # -------------- 3.11.1 Initialization -------------- + + # -------------- 3.11.2 Tip handling using 384 Head -------------- + + # -------------- 3.11.3 Liquid handling using 384 Head -------------- + + # -------------- 3.11.4 Adjustment & movement commands -------------- + + # -------------- 3.11.5 Wash procedure commands using 384 Head -------------- + + # -------------- 3.11.6 Query 384 Head -------------- + + # -------------- 3.12 Nano pipettor commands -------------- + + # TODO: all nano pipettor commands + + # -------------- 3.12.1 Initialization -------------- + + # TODO:(command:NI) + # TODO:(command:NV) + # TODO:(command:NP) + + # -------------- 3.12.2 Nano pipettor liquid handling commands -------------- + + # TODO:(command:NA) + # TODO:(command:ND) + # TODO:(command:NF) + + # -------------- 3.12.3 Nano pipettor wash & clean commands -------------- + + # TODO:(command:NW) + # TODO:(command:NU) + + # -------------- 3.12.4 Nano pipettor adjustment & movements -------------- + + # TODO:(command:NM) + # TODO:(command:NT) + + # -------------- 3.12.5 Nano pipettor query -------------- + + # TODO:(command:QL) + # TODO:(command:QN) + # TODO:(command:RN) + # TODO:(command:QQ) + # TODO:(command:QR) + # TODO:(command:QO) + # TODO:(command:RR) + # TODO:(command:QU) + + # -------------- 3.13 Autoload commands -------------- + + # -------------- 3.13.1 Initialization -------------- + + async def initialize_auto_load(self): + """Deprecated - use `initialize_autoload` instead.""" + warnings.warn( # TODO: remove 2025-02 + "`initialize_auto_load` is deprecated and will be removed " + "in 2025-02 use `initialize_autoload` instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.initialize_autoload() + + async def initialize_autoload(self): + """Initialize Auto load module""" + + return await self.send_command(module="C0", command="II") + + async def move_auto_load_to_z_save_position(self): + """Deprecated - use `move_autoload_to_safe_z_position` instead.""" + + warnings.warn( # TODO: remove 2025-02 + "`move_auto_load_to_z_save_position` is deprecated and will be " + "removed in 2025-02 use `move_autoload_to_safe_z_position` instead.", + DeprecationWarning, + stacklevel=2, + ) + + return await self.move_autoload_to_safe_z_position() + + async def move_autoload_to_save_z_position(self): + """Deprecated - use `move_autoload_to_safe_z_position` instead.""" + warnings.warn( # TODO: remove 2025-02 + "`move_autoload_to_saVe_z_position` is deprecated and will be " + "removed in 2025-02 use `move_autoload_to_safe_z_position` instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.move_autoload_to_safe_z_position() + + async def move_autoload_to_safe_z_position(self): + """Move autoload carrier handling wheel to safe Z position""" + + return await self.send_command(module="C0", command="IV") + + async def request_auto_load_slot_position(self): + """Deprecated - use `request_autoload_track` instead.""" + warnings.warn( # TODO: remove 2025-02 + "`request_auto_load_slot_position` is deprecated and will be " + "removed in 2025-02 use `request_autoload_track` instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.request_autoload_track() + + async def request_autoload_track(self) -> int: + """Request current track of the autoload 'carrier handler'. + + Returns: + track (0..54) + """ + resp = await self.send_command(module="C0", command="QA", fmt="qa##") + return int(resp["qa"]) + + async def request_autoload_type(self) -> str: + """ + Query the autoload module type. + + This sends the `C0:QA` command, which returns a CQ-format response containing + the autoload identification fields, error/trace information, and the module + type code. The `cq` field specifies the autoload hardware type: + + 0 = ML-STAR with 1D Barcode Scanner + 1 = XRP Lite + 2 = ML-STAR with 2D Barcode Scanner + 3-9 = Reserved / other module variants + + Returns: + int: The autoload module type code (0-9). + """ + + autoload_type_dict = { + 0: "ML-STAR with 1D Barcode Scanner", + 1: "XRP Lite", + 2: "ML-STAR with 2D Barcode Scanner", + } + + resp = await self.send_command(module="C0", command="CQ", fmt="cq#") + resp = autoload_type_dict[resp["cq"]] if resp["cq"] in autoload_type_dict else resp["cq"] + + return str(resp) + + # -------------- 3.13.2 Carrier sensing -------------- + + def _decode_hex_bitmask_to_track_list(self, mask_hex: str) -> list[int]: + """ + Decode a hex occupancy bitmask of arbitrary length. + Each hex nibble = 4 slots. + Slot numbering starts at 1 from the rightmost nibble (LSB). + """ + mask_hex = mask_hex.strip() + + if not all(c in "0123456789abcdefABCDEF" for c in mask_hex): + raise ValueError(f"Invalid hex in mask: {mask_hex!r}") + + slots = [] + bit_index = 1 + + # Rightmost hex digit = slot 1 (LSB) + for nibble in reversed(mask_hex): + val = int(nibble, 16) + for bit in range(4): + if val & (1 << bit): + slots.append(bit_index) + bit_index += 1 + + return sorted(slots) + + async def request_presence_of_carriers_on_deck(self) -> list[int]: + """ + Read the deck carrier presence sensors and return the positions where carriers + are currently detected. + + This sends the `C0:RC` command to query the rear deck sensors. No autoload + movement is performed. The returned hex bitmask is decoded into a list of + track numbers (1-54), where each number corresponds to a deck rail position + that is occupied by a carrier. + + Returns: + list[int]: Sorted list of deck rail positions where carriers are present. + """ + resp = await self.send_command(module="C0", command="RC") + + ce_resp = resp.split("ce")[-1] + + return self._decode_hex_bitmask_to_track_list(ce_resp) + + async def request_presence_of_carriers_on_loading_tray(self) -> list[int]: + """ + Moves autoload sled across loading tray and reads its front-facing proximity sensors + to determine which tray positions contain carriers. + + This sends the `C0:CS` command, which provides a hex-encoded presence bitmask + for the loading tray. The bitmask is decoded into a list of track numbers (1-54) + representing tray positions that currently contain a carrier. + + Returns: + list[int]: Sorted list of loading-tray positions where carriers are present. + + Raises: + ValueError: If the response is missing the expected 'cd' field. + """ + resp = await self.send_command(module="C0", command="CS") + + if "cd" not in resp: + raise ValueError(f"CD field missing: {resp!r}") + + mask_hex = resp.split("cd", 1)[1].strip() + + return self._decode_hex_bitmask_to_track_list(mask_hex) + + async def request_presence_of_single_carrier_on_loading_tray(self, track: int) -> bool: + """ + Check whether a specific loading-tray track contains a carrier. + + This sends the `C0:CT` command, which instructs the autoload sled to move to + the specified tray track and read its front-facing proximity sensor. Unlike + `request_presence_of_carriers_on_loading_tray`, which scans all tray + positions and returns a bitmask, this method queries only a single track and + returns a boolean result. + + Args: + track (int): The loading-tray track number to query (1-54). + + Returns: + bool: True if a carrier is detected at the given track; False otherwise. + + Raises: + AssertionError: If `track` is outside the valid range (1-54). + """ + + assert 1 <= track <= 54, "track must be between 1 and 54" + + track_str = str(track).zfill(2) + + resp = await self.send_command( + module="C0", + command="CT", + fmt="ct#", + cp=track_str, + ) + assert resp is not None + + return int(resp["ct"]) == 1 + + async def request_single_carrier_presence(self, carrier_position: int): + """Request single carrier presence on the loading tray (not on deck)""" + warnings.warn( # TODO: remove 2025-02 + "`request_single_carrier_presence` is deprecated and will be " + "removed in 2025-02 use `is_carrier_present_on_loading_tray` instead.", + DeprecationWarning, + stacklevel=2, + ) + await self.request_presence_of_single_carrier_on_loading_tray(carrier_position) + + # -------------- 3.13.3 Autoload movement commands -------------- + + def _compute_end_rail_of_carrier(self, carrier: Carrier, track_width: float = 22.5) -> int: + """Compute end rail of carrier based on its location on the deck.""" + + carrier_width = carrier.get_location_wrt(self.deck).x - 100 + carrier.get_absolute_size_x() + carrier_end_rail = int(carrier_width / track_width) + + assert 1 <= carrier_end_rail <= 54, "carrier loading rail must be between 1 and 54" + + return carrier_end_rail + + async def move_autoload_to_slot(self, slot_number: int): + """deprecated - use `move_autoload_to_track` instead.""" + + warnings.warn( # TODO: remove 2025-02 + "`move_autoload_to_slot` is deprecated and will be " + "removed in 2025-02 use `move_autoload_to_track` instead.", + DeprecationWarning, + stacklevel=2, + ) + + return await self.move_autoload_to_track(track=slot_number) + + async def move_autoload_to_track(self, track: int): + """Move autoload to specific slot/track position""" + + assert 1 <= track <= 54, "track must be between 1 and 54" + + await self.move_autoload_to_safe_z_position() + + track_no_as_safe_str = str(track).zfill(2) + return await self.send_command(module="I0", command="XP", xp=track_no_as_safe_str) + + async def park_autoload(self): + """Park autoload""" + + # Identify max number of x positions for your liquid handler + max_x_pos = str(self.extended_conf.instrument_size_slots).zfill(2) + + await self.move_autoload_to_safe_z_position() + + # Park autoload to max x position available + return await self.send_command(module="I0", command="XP", xp=max_x_pos) + + async def take_carrier_out_to_autoload_belt(self, carrier: Carrier): + """Take carrier out to identification position for barcode reading. + Start: carrier is already on the deck + """ + + # Identify carrier end rail + carrier_end_rail = self._compute_end_rail_of_carrier(carrier) + + carrier_on_loading_tray = await self.request_single_carrier_presence(carrier_end_rail) + + if not carrier_on_loading_tray: + try: + await self.send_command( + module="C0", + command="CN", + cp=str(carrier_end_rail).zfill(2), + ) + except Exception as e: + await self.move_autoload_to_safe_z_position() + raise RuntimeError( + f"Failed to take carrier at rail {carrier_end_rail} out to autoload belt: {e}" + ) + else: + raise ValueError(f"Carrier is already on the loading tray at position {carrier_end_rail}.") + + # -------------- 3.13.4 Autoload barcode reading commands -------------- + + # 1D barcode symbology bitmask + # Each symbology corresponds to exactly one bit in the 8-bit barcode type field. + # Bit definitions from spec: + # Bit 0 = ISBT Standard + # Bit 1 = Code 128 (Subset B and C) + # Bit 2 = Code 39 + # Bit 3 = Codabar + # Bit 4 = Code 2of5 Interleaved + # Bit 5 = UPC A/E + # Bit 6 = YESN/EAN 8 + # Bit 7 = (unused / undocumented) + + barcode_1d_symbology_dict: dict[Barcode1DSymbology, str] = { + "ISBT Standard": "01", # bit 0 → 0b00000001 → 0x01 → 1 + "Code 128 (Subset B and C)": "02", # bit 1 → 0b00000010 → 0x02 → 2 + "Code 39": "04", # bit 2 → 0b00000100 → 0x04 → 4 + "Codebar": "08", # bit 3 → 0b00001000 → 0x08 → 8 + "Code 2of5 Interleaved": "10", # bit 4 → 0b00010000 → 0x10 → 16 + "UPC A/E": "20", # bit 5 → 0b00100000 → 0x20 → 32 + "YESN/EAN 8": "40", # bit 6 → 0b01000000 → 0x40 → 64 + # Bit 7 → 0b10000000 → 0x80 → 128 (not documented, so omitted) + "ANY 1D": "7F", # bits 0-6 → 0b01111111 → 0x7F → 127 + } + + async def set_1d_barcode_type( + self, + barcode_symbology: Optional[Barcode1DSymbology], + ) -> None: + """Set 1D barcode type for autoload barcode reading.""" + + # If none given, use the default + if barcode_symbology is None: + barcode_symbology = self._default_1d_symbology + + # Prove to mypy that barcode_symbology is no longer Optional + assert barcode_symbology is not None + + await self.send_command( + module="C0", + command="CB", + bt=self.barcode_1d_symbology_dict[barcode_symbology], + ) + + self._default_1d_symbology = barcode_symbology + + async def set_barcode_type( + self, + ISBT_Standard: bool = True, + code128: bool = True, + code39: bool = True, + codebar: bool = True, + code2_5: bool = True, + UPC_AE: bool = True, + EAN8: bool = True, + ): + """deprecated - use set_1d_barcode_type instead""" + + warnings.warn( # TODO: remove 2025-02 + "`set_barcode_type` is deprecated and will be " + "removed in 2025-02 use `set_1d_barcode_type` instead.", + DeprecationWarning, + stacklevel=2, + ) + + # Encode values into bit pattern. Last bit is always one. + bt = "" + for t in [ + ISBT_Standard, + code128, + code39, + codebar, + code2_5, + UPC_AE, + EAN8, + True, + ]: + bt += "1" if t else "0" + # Convert bit pattern to hex. + bt_hex = hex(int(bt, base=2)) + return await self.send_command(module="C0", command="CB", bt=bt_hex) + + # TODO:(command:CW) Unload carrier finally + + async def load_carrier_from_tray_and_scan_carrier_barcode( + self, + carrier: Carrier, + carrier_barcode_reading: bool = True, + barcode_symbology: Optional[Barcode1DSymbology] = None, + barcode_position: float = 4.3, # mm + barcode_reading_window_width: float = 38.0, # mm + reading_speed: float = 128.1, # mm/sec + ) -> Optional[Barcode]: + """Load carrier from loading tray and - optionally - scan 1D carrier barcode""" + + if barcode_symbology is None: + barcode_symbology = self._default_1d_symbology + + assert barcode_symbology is not None + + carrier_end_rail = self._compute_end_rail_of_carrier(carrier) + carrier_end_rail_str = str(carrier_end_rail).zfill(2) + + assert 1 <= int(carrier_end_rail_str) <= 54 + assert 0 <= barcode_position <= 470 + assert 0.1 <= barcode_reading_window_width <= 99.9 + assert 1.5 <= reading_speed <= 160.0 + + try: + resp = await self.send_command( + module="C0", + command="CI", + cp=carrier_end_rail_str, + bi=f"{round(barcode_position * 10):04}", + bw=f"{round(barcode_reading_window_width * 10):03}", + co="0960", # Distance between containers (pattern) [0.1 mm] + cv=f"{round(reading_speed * 10):04}", + ) + except Exception as e: + if carrier_barcode_reading: + await self.move_autoload_to_safe_z_position() + raise RuntimeError( + f"Failed to load carrier at rail {carrier_end_rail} and scan barcode: {e}" + ) + else: + pass + + if not carrier_barcode_reading: + return None + + barcode_str = resp.split("bb/")[-1] + + return Barcode(data=barcode_str, symbology=barcode_symbology, position_on_resource="right") + + async def unload_carrier_after_carrier_barcode_scanning(self): + """After scanning the barcode of the carrier currently engaged with + the autoload sled, unload the carrier back to the loading tray. + """ + try: + resp = await self.send_command( + module="C0", + command="CA", + ) + except Exception as e: + await self.move_autoload_to_safe_z_position() + raise RuntimeError(f"Failed to unload carrier after barcode scanning: {e}") + + return resp + + async def set_carrier_monitoring(self, should_monitor: bool = False): + """Set carrier monitoring + + Args: + should_monitor: whether carrier should be monitored. + + Returns: + True if present, False otherwise + """ + + return await self.send_command(module="C0", command="CU", cu=should_monitor) + + async def load_carrier_from_autoload_belt( + self, + barcode_reading: bool = False, + barcode_reading_direction: Literal["horizontal", "vertical"] = "horizontal", + barcode_symbology: Optional[Barcode1DSymbology] = None, + reading_position_of_first_barcode: float = 63.0, # mm + no_container_per_carrier: int = 5, + distance_between_containers: float = 96.0, # mm + width_of_reading_window: float = 38.0, # mm + reading_speed: float = 128.1, # mm/secs + park_autoload_after: bool = True, + ) -> dict[int, Optional[Barcode]]: + """Finishes loading the carrier that is currently engaged with the autoload sled, + i.e. is currently in the identification position. + """ + + assert barcode_reading_direction in ["horizontal", "vertical"] + assert 0 <= reading_position_of_first_barcode <= 470 + assert 0 <= no_container_per_carrier <= 32 + assert 0 <= distance_between_containers <= 470 + assert 0.1 <= width_of_reading_window <= 99.9 + assert 1.5 <= reading_speed <= 160.0 + + barcode_reading_direction_dict = { + "vertical": "0", + "horizontal": "1", + } + + if barcode_symbology is None: + barcode_symbology = self._default_1d_symbology + assert barcode_symbology is not None + + no_container_per_carrier_str = str(no_container_per_carrier).zfill(2) + reading_position_of_first_barcode_str = str( + round(reading_position_of_first_barcode * 10) + ).zfill(4) + distance_between_containers_str = str(round(distance_between_containers * 10)).zfill(4) + width_of_reading_window_str = str(round(width_of_reading_window * 10)).zfill(3) + reading_speed_str = str(round(reading_speed * 10)).zfill(4) + + if not barcode_reading: + barcode_reading_direction = "vertical" # no movement + no_container_per_carrier_str = "00" # no scanning + + else: + # Choose barcode symbology + await self.set_1d_barcode_type(barcode_symbology=barcode_symbology) + + self._default_1d_symbology = barcode_symbology + + try: + resp = await self.send_command( + module="C0", + command="CL", + bd=barcode_reading_direction_dict[barcode_reading_direction], + bp=reading_position_of_first_barcode_str, # Barcode reading position of first barcode [mm] + cn=no_container_per_carrier_str, + co=distance_between_containers_str, # Distance between containers (pattern) [mm] + cf=width_of_reading_window_str, # Width of reading window [mm] + cv=reading_speed_str, # Carrier reading speed [mm/sec]/ + ) + except Exception as e: + await self.move_autoload_to_safe_z_position() + raise RuntimeError(f"Failed to load carrier from autoload belt: {e}") + + if park_autoload_after: + await self.park_autoload() + + assert isinstance(resp, str), f"Response is not a string: {resp!r}" + + barcode_dict: dict[int, Optional[Barcode]] = {} + + if barcode_reading: + resp_list = resp.split("bb/")[-1].split("/") # remove header + + assert len(resp_list) == no_container_per_carrier, ( + f"Number of barcodes read ({len(resp_list)}) does not match " + f"expected number ({no_container_per_carrier})" + ) + for i in range(0, no_container_per_carrier): + if resp_list[i] == "00": + barcode_dict[i] = None + else: + barcode_dict[i] = Barcode( + data=resp_list[i], symbology=barcode_symbology, position_on_resource="right" + ) + + return barcode_dict + + # -------------- 3.13.5 Autoload carrier loading/unloading commands -------------- + + async def load_carrier( + self, + carrier: Carrier, + carrier_barcode_reading: bool = True, + barcode_reading: bool = False, + barcode_reading_direction: Literal["horizontal", "vertical"] = "horizontal", + barcode_symbology: Optional[Barcode1DSymbology] = None, + no_container_per_carrier: int = 5, + reading_position_of_first_barcode: float = 63.0, # mm + distance_between_containers: float = 96.0, # mm + width_of_reading_window: float = 38.0, # mm + reading_speed: float = 128.1, # mm/secs + park_autoload_after: bool = True, + ) -> dict: + """ + Use autoload to load carrier. + + Args: + carrier: Carrier to load + barcode_reading: Whether to read barcodes. Default False. + barcode_reading_direction: Barcode reading direction. Either "vertical" or "horizontal", + default "horizontal". + barcode_symbology: Barcode symbology. Default "Code 128 (Subset B and C)". + no_container_per_carrier: Number of containers per carrier. Default 5. + park_autoload_after: Whether to park autoload after loading. Default True. + """ + + if barcode_symbology is None: + barcode_symbology = self._default_1d_symbology + + # Identify carrier end rail + carrier_end_rail = self._compute_end_rail_of_carrier(carrier) + assert 1 <= int(carrier_end_rail) <= 54, "carrier loading rail must be between 1 and 54" + + # Determine presence of carrier at defined position + presence_check = await self.request_presence_of_single_carrier_on_loading_tray(carrier_end_rail) + + if presence_check != 1: + raise ValueError( + f"""No carrier found at position {carrier_end_rail}, + have you placed the carrier onto the correct autoload tray position?""" + ) + + # Set carrier type for identification purposes + carrier_barcode = await self.load_carrier_from_tray_and_scan_carrier_barcode( + carrier, carrier_barcode_reading=carrier_barcode_reading + ) + + # Load carrier + # with barcoding + if barcode_reading: + # Choose barcode symbology + await self.set_1d_barcode_type(barcode_symbology=barcode_symbology) + self._default_1d_symbology = barcode_symbology + + # Load and read out barcodes # TODO: swap with load_carrier_from_autoload_belt? + resp = await self.load_carrier_from_autoload_belt( + barcode_reading=barcode_reading, + barcode_reading_direction=barcode_reading_direction, + barcode_symbology=barcode_symbology, + reading_position_of_first_barcode=reading_position_of_first_barcode, + no_container_per_carrier=no_container_per_carrier, + distance_between_containers=distance_between_containers, + width_of_reading_window=width_of_reading_window, + reading_speed=reading_speed, + park_autoload_after=False, + ) + else: # without barcoding + resp = await self.load_carrier_from_autoload_belt( + barcode_reading=False, park_autoload_after=False + ) + + if park_autoload_after: + await self.park_autoload() + + # Parse response and create output dict + output = { + "carrier_barcode": carrier_barcode if carrier_barcode_reading else None, + "container_barcodes": resp if barcode_reading else None, + } + + return output + + async def set_loading_indicators(self, bit_pattern: List[bool], blink_pattern: List[bool]): + """Set loading indicators (LEDs) + + The docs here are a little weird because 2^54 < 7FFFFFFFFFFFFF. + + Args: + bit_pattern: On if True, off otherwise + blink_pattern: Blinking if True, steady otherwise + """ + + assert len(bit_pattern) == 54, "bit pattern must be length 54" + assert len(blink_pattern) == 54, "bit pattern must be length 54" + + def pattern2hex(pattern: List[bool]) -> str: + bit_string = "".join(["1" if x else "0" for x in pattern]) + return hex(int(bit_string, base=2))[2:].upper().zfill(14) + + bit_pattern_hex = pattern2hex(bit_pattern) + blink_pattern_hex = pattern2hex(blink_pattern) + + return await self.send_command( + module="C0", + command="CP", + cl=bit_pattern_hex, + cb=blink_pattern_hex, + ) + + async def verify_and_wait_for_carriers( + self, + check_interval: float = 1.0, + ): + """Verify that carriers have been loaded at expected rail positions. + + This function checks if carriers are physically present on the deck at the specified + rail positions using the deck's presence sensors. If any carriers are missing, it will: + 1. Prompt the user to load the missing carriers + 2. Flash LEDs at the missing positions using set_loading_indicators + 3. Continue checking until all carriers are detected + + Args: + check_interval: Interval in seconds between presence checks (default: 1.0) + + Raises: + ValueError: If no carriers are found on the deck. + """ + # Extract carriers from deck children with start and end rail positions + carrier_rails: List[Tuple[int, int]] = [] # List of (start_rail, end_rail) tuples + + for child in self.deck.children: + if isinstance(child, Carrier): + # Get x coordinate relative to deck + carrier_x = child.get_location_wrt(self.deck).x + carrier_start_rail = rails_for_x_coordinate(carrier_x) + carrier_end_rail = rails_for_x_coordinate(carrier_x - 100.0 + child.get_absolute_size_x()) + + # Verify rails are valid + carrier_start_rail = max(1, min(carrier_start_rail, 54)) + if 1 <= carrier_end_rail <= 54: + carrier_rails.append((carrier_start_rail, carrier_end_rail)) + + if len(carrier_rails) == 0: + raise ValueError("No carriers found on deck. Assign carriers to the deck.") + + # Extract end rails for comparison with detected rails + # The presence detection reports the end rail position + expected_end_rails = [end_rail for _, end_rail in carrier_rails] + + # Check initial presence + detected_rails = set(await self.request_presence_of_carriers_on_deck()) + missing_end_rails = sorted(set(expected_end_rails) - detected_rails) + + if len(missing_end_rails) == 0: + logger.info(f"All carriers detected at end rail positions: {expected_end_rails}") + # Turn off all indicators + await self.set_loading_indicators( + bit_pattern=[False] * 54, + blink_pattern=[False] * 54, + ) + print(f"\n✓ All carriers successfully detected at end rail positions: {expected_end_rails}\n") + return + + # Prompt user about missing carriers + print( + f"\n{'=' * 60}\n" + f"CARRIER LOADING REQUIRED\n" + f"{'=' * 60}\n" + f"Expected carriers at end rail positions: {expected_end_rails}\n" + f"Detected carriers at rail positions: {sorted(detected_rails)}\n" + f"Missing carriers at end rail positions: {missing_end_rails}\n" + f"{'=' * 60}\n" + f"Please load the missing carriers. LEDs will flash at the carrier positions.\n" + f"The system will automatically detect when all carriers are loaded.\n" + f"{'=' * 60}\n" + ) + + # Flash LEDs until all carriers are detected + while missing_end_rails: + # Create bit pattern for missing carriers + # Flash all LEDs from start_rail to end_rail (inclusive) for each missing carrier + bit_pattern = [False] * 54 + blink_pattern = [False] * 54 + + # For each missing carrier (identified by missing end rail), flash all its rails + for missing_end_rail in missing_end_rails: + # Find the carrier with this end rail + for start_rail, end_rail in carrier_rails: + if end_rail == missing_end_rail: + # Flash all LEDs from start_rail to end_rail (inclusive) + for rail in range(start_rail, end_rail + 1): + if 1 <= rail <= 54: + indicator_index = rail - 1 # Convert rail (1-54) to index (0-53) + bit_pattern[indicator_index] = True + blink_pattern[indicator_index] = True + break + + # Set loading indicators + await self.set_loading_indicators(bit_pattern[::-1], blink_pattern[::-1]) + + # Wait before checking again + await asyncio.sleep(check_interval) + + # Check for presence again + detected_rails = set(await self.request_presence_of_carriers_on_deck()) + missing_end_rails = sorted(set(expected_end_rails) - detected_rails) + + # All carriers detected, turn off all indicators + logger.info(f"All carriers successfully detected at end rail positions: {expected_end_rails}") + await self.set_loading_indicators( + bit_pattern=[False] * 54, + blink_pattern=[False] * 54, + ) + print("\n✓ All carriers successfully loaded and detected!\n") + + async def unload_carrier( + self, + carrier: Carrier, + park_autoload_after: bool = True, + ): + """Use autoload to unload carrier.""" + # Identify carrier end rail + track_width = 22.5 + carrier_width = carrier.get_location_wrt(self.deck).x - 100 + carrier.get_absolute_size_x() + carrier_end_rail = int(carrier_width / track_width) + + assert 1 <= carrier_end_rail <= 54, "carrier loading rail must be between 1 and 54" + + carrier_end_rail_str = str(carrier_end_rail).zfill(2) + + # Unload + resp = await self.send_command( + module="C0", + command="CR", + cp=carrier_end_rail_str, + ) + + if park_autoload_after: + await self.park_autoload() + + return resp + + # -------------- 3.14 G1-3/ CR Needle Washer commands -------------- + + # TODO: All needle washer commands + + # TODO:(command:WI) + # TODO:(command:WI) + # TODO:(command:WS) + # TODO:(command:WW) + # TODO:(command:WR) + # TODO:(command:WC) + # TODO:(command:QF) + + # -------------- 3.15 Pump unit commands -------------- + + async def request_pump_settings(self, pump_station: int = 1): + """Set carrier monitoring + + Args: + carrier_position: pump station number (1..3) + + Returns: + 0 = CoRe 96 wash station (single chamber) + 1 = DC wash station (single chamber rev 02 ) 2 = ReReRe (single chamber) + 3 = CoRe 96 wash station (dual chamber) + 4 = DC wash station (dual chamber) + 5 = ReReRe (dual chamber) + """ + + assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" + + return await self.send_command(module="C0", command="ET", fmt="et#", ep=pump_station) + + # -------------- 3.15.1 DC Wash commands (only for revision up to 01) -------------- + + # TODO:(command:FA) Start DC wash procedure + # TODO:(command:FB) Stop DC wash procedure + # TODO:(command:FP) Prime DC wash station + + # -------------- 3.15.2 Single chamber pump unit only -------------- + + # TODO:(command:EW) Start circulation (single chamber only) + # TODO:(command:EC) Check circulation (single chamber only) + # TODO:(command:ES) Stop circulation (single chamber only) + # TODO:(command:EF) Prime (single chamber only) + # TODO:(command:EE) Drain & refill (single chamber only) + # TODO:(command:EB) Fill (single chamber only) + # TODO:(command:QE) Request single chamber pump station prime status + + # -------------- 3.15.3 Dual chamber pump unit only -------------- + + async def initialize_dual_pump_station_valves(self, pump_station: int = 1): + """Initialize pump station valves (dual chamber only) + + Args: + carrier_position: pump station number (1..3) + """ + + assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" + + return await self.send_command(module="C0", command="EJ", ep=pump_station) + + async def fill_selected_dual_chamber( + self, + pump_station: int = 1, + drain_before_refill: bool = False, + wash_fluid: int = 1, + chamber: int = 2, + waste_chamber_suck_time_after_sensor_change: int = 0, + ): + """Initialize pump station valves (dual chamber only) + + Args: + carrier_position: pump station number (1..3) + drain_before_refill: drain chamber before refill. Default False. + wash_fluid: wash fluid (1 or 2) + chamber: chamber (1 or 2) + drain_before_refill: waste chamber suck time after sensor change [s] (for error handling only) + """ + + assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" + assert 1 <= wash_fluid <= 2, "wash_fluid must be between 1 and 2" + assert 1 <= chamber <= 2, "chamber must be between 1 and 2" + + # wash fluid <-> chamber connection + # 0 = wash fluid 1 <-> chamber 2 + # 1 = wash fluid 1 <-> chamber 1 + # 2 = wash fluid 2 <-> chamber 1 + # 3 = wash fluid 2 <-> chamber 2 + connection = {(1, 2): 0, (1, 1): 1, (2, 1): 2, (2, 2): 3}[wash_fluid, chamber] + + return await self.send_command( + module="C0", + command="EH", + ep=pump_station, + ed=drain_before_refill, + ek=connection, + eu=f"{waste_chamber_suck_time_after_sensor_change:02}", + wait=False, + ) + + # TODO:(command:EK) Drain selected chamber + + async def drain_dual_chamber_system(self, pump_station: int = 1): + """Drain system (dual chamber only) + + Args: + carrier_position: pump station number (1..3) + """ + + assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" + + return await self.send_command(module="C0", command="EL", ep=pump_station) + + # TODO:(command:QD) Request dual chamber pump station prime status + + # -------------- 3.16 Incubator commands -------------- + + # TODO: all incubator commands + # TODO:(command:HC) + # TODO:(command:HI) + # TODO:(command:HF) + # TODO:(command:RP) + + # -------------- 3.17 iSWAP commands -------------- + + # -------------- 3.17.1 Pre & Initialization commands -------------- + + async def initialize_iswap(self): + """Initialize iSWAP (for standalone configuration only)""" + + return await self.send_command(module="C0", command="FI") + + async def position_components_for_free_iswap_y_range(self): + """Position all components so that there is maximum free Y range for iSWAP""" + + return await self.send_command(module="C0", command="FY") + + async def move_iswap_x_relative(self, step_size: float, allow_splitting: bool = False): + """ + Args: + step_size: X Step size [1mm] Between -99.9 and 99.9 if allow_splitting is False. + allow_splitting: Allow splitting of the movement into multiple steps. Default False. + """ + + direction = 0 if step_size >= 0 else 1 + max_step_size = 99.9 + if abs(step_size) > max_step_size: + if not allow_splitting: + raise ValueError("step_size must be less than 99.9") + await self.move_iswap_x_relative( + step_size=max_step_size if step_size > 0 else -max_step_size, allow_splitting=True + ) + remaining_steps = step_size - max_step_size if step_size > 0 else step_size + max_step_size + return await self.move_iswap_x_relative(remaining_steps, allow_splitting) + + return await self.send_command( + module="C0", command="GX", gx=str(round(abs(step_size) * 10)).zfill(3), xd=direction + ) + + async def move_iswap_y_relative(self, step_size: float, allow_splitting: bool = False): + """ + Args: + step_size: Y Step size [1mm] Between -99.9 and 99.9 if allow_splitting is False. + allow_splitting: Allow splitting of the movement into multiple steps. Default False. + """ + + # check if iswap will hit the first (backmost) channel + # we only need to check for positive step sizes because the iswap is always behind the first channel + if step_size < 0: + y_pos_channel_0 = await self.request_y_pos_channel_n(0) + current_y_pos_iswap = await self.iswap_rotation_drive_request_y() + if current_y_pos_iswap + step_size < y_pos_channel_0: + raise ValueError( + f"iSWAP will hit the first (backmost) channel. Current iSWAP Y position: {current_y_pos_iswap} mm, " + f"first channel Y position: {y_pos_channel_0} mm, requested step size: {step_size} mm" + ) + + direction = 0 if step_size >= 0 else 1 + max_step_size = 99.9 + if abs(step_size) > max_step_size: + if not allow_splitting: + raise ValueError("step_size must be less than 99.9") + await self.move_iswap_y_relative( + step_size=max_step_size if step_size > 0 else -max_step_size, allow_splitting=True + ) + remaining_steps = step_size - max_step_size if step_size > 0 else step_size + max_step_size + return await self.move_iswap_y_relative(remaining_steps, allow_splitting) + + return await self.send_command( + module="C0", command="GY", gy=str(round(abs(step_size) * 10)).zfill(3), yd=direction + ) + + async def move_iswap_z_relative(self, step_size: float, allow_splitting: bool = False): + """ + Args: + step_size: Z Step size [1mm] Between -99.9 and 99.9 if allow_splitting is False. + allow_splitting: Allow splitting of the movement into multiple steps. Default False. + """ + + direction = 0 if step_size >= 0 else 1 + max_step_size = 99.9 + if abs(step_size) > max_step_size: + if not allow_splitting: + raise ValueError("step_size must be less than 99.9") + await self.move_iswap_z_relative( + step_size=max_step_size if step_size > 0 else -max_step_size, allow_splitting=True + ) + remaining_steps = step_size - max_step_size if step_size > 0 else step_size + max_step_size + return await self.move_iswap_z_relative(remaining_steps, allow_splitting) + + return await self.send_command( + module="C0", command="GZ", gz=str(round(abs(step_size) * 10)).zfill(3), zd=direction + ) + + async def move_iswap_x( + self, + x_position: float, # TODO: by convention should be just 'x' in v1 + acceleration_level: int = 3, + current_protection_limiter: int = 7, + ): + """Move the iSWAP gripper center to absolute X position (deck coordinates). + + The X-arm carriage and the gripper translate rigidly together in X, so + the gripper-X delta equals the rotation-drive-X delta. Read the current + gripper X from FK, translate the X-arm by the delta, and the gripper + lands at `x_position` via a single smooth motion plan. + + Args: + x_position [mm]: target gripper X in deck coordinates. + acceleration_level: X-arm acceleration index, 1..5. + current_protection_limiter: motor current limit, 0..7. + + Raises: + RuntimeError: if iSWAP is not installed, or if `setup()` has not + populated `iswap_information`. + ValueError: if the resolved rotation drive X is outside the X-arm + hardware range, or if any arg is outside its valid range. + """ + current_gripper_x = (await self.iswap_request_pose()).location.x + current_rotation_drive_x = await self.iswap_rotation_drive_request_x() + target_rotation_drive_x = current_rotation_drive_x + (x_position - current_gripper_x) + try: + await self.experimental_iswap_rotation_drive_move_x( + x=target_rotation_drive_x, + acceleration_level=acceleration_level, + current_protection_limiter=current_protection_limiter, + ) + except ValueError as e: + raise ValueError( + f"move_iswap_x(x_position={x_position}) resolved to rotation drive " + f"target x={target_rotation_drive_x:.2f}: {e}" + ) from e + + async def move_iswap_y( + self, + y_position: float, # TODO: by convention should be just 'y' in v1 + speed: float = 220.0, + acceleration_level: int = 2, + current_protection_limiter: int = 7, + make_space: bool = False, + ): + """Move the iSWAP gripper center to absolute Y position (deck coordinates). + + The rotation drive Y carriage and the gripper translate rigidly together + in Y, so the gripper-Y delta equals the rotation-drive-Y delta. Read the + current gripper Y from FK, translate the rotation drive by the delta, + and the gripper lands at `y_position` via a single smooth `R0 YA` move. + + Args: + y_position [mm]: target gripper Y in deck coordinates. The achievable + range depends on channel configuration and current arm pose; in + unobstructed conditions the absolute envelope is approximately + -270..+648 mm at factory link lengths. + speed [mm/sec]: max linear velocity, 2.4..370. + acceleration_level: acceleration index, 1 or 2. + current_protection_limiter: motor current limit, 0..7. + make_space: if True, reposition pipetting channels when channel 0 is + in the way and can be cleared. If False, raise so the caller decides. + + Raises: + RuntimeError: if iSWAP is not installed, or if `setup()` has not + populated `iswap_information`. + ValueError: if `y_position` is outside the Y hardware range; if the + target requires channel 0 to be moved and `make_space=False`; if + the move is unreachable even with channel repositioning; or if + `speed`, `acceleration_level`, or `current_protection_limiter` is + outside its valid range. + """ + current_gripper_y = (await self.iswap_request_pose()).location.y + current_rotation_drive_y = await self.iswap_rotation_drive_request_y() + target_rotation_drive_y = current_rotation_drive_y + (y_position - current_gripper_y) + try: + await self.iswap_rotation_drive_move_y( + y=target_rotation_drive_y, + speed=speed, + acceleration_level=acceleration_level, + current_protection_limiter=current_protection_limiter, + make_space=make_space, + ) + except ValueError as e: + raise ValueError( + f"move_iswap_y(y_position={y_position}) resolved to rotation drive " + f"target y={target_rotation_drive_y:.2f}: {e}" + ) from e + + async def move_iswap_z( + self, + z_position: float, # TODO: by convention should be just 'z' in v1 + speed: float = 118.0, + acceleration: float = 643.66, + current_protection_limiter: int = 6, + ): + """Move the iSWAP gripper finger plane to absolute Z position (deck coordinates). + + The Z carriage and the gripper translate rigidly together in Z (with a + fixed 13 mm offset between the rotation-drive-bottom Z and the + finger-plane Z), so the gripper-Z delta equals the rotation-drive-Z + delta. Read the current gripper finger-plane Z from FK, translate the + rotation drive by the delta, and the gripper lands at `z_position` via + a single smooth `R0 ZA` move. + + Args: + z_position [mm]: target gripper finger-plane Z in deck coordinates. + speed [mm/sec]: max linear velocity. + acceleration [mm/sec^2]: max linear acceleration. + current_protection_limiter: motor current limit, 0..7. + + Raises: + RuntimeError: if iSWAP is not installed, or if `setup()` has not + populated `iswap_information`. + ValueError: if the resolved rotation drive Z is outside the Z + hardware range, or if any arg is outside its valid range. + """ + current_gripper_z = (await self.iswap_request_pose()).location.z + current_rotation_drive_z = await self.iswap_rotation_drive_request_z() + target_rotation_drive_z = current_rotation_drive_z + (z_position - current_gripper_z) + try: + await self.iswap_rotation_drive_move_z( + z=target_rotation_drive_z, + speed=speed, + acceleration=acceleration, + current_protection_limiter=current_protection_limiter, + ) + except ValueError as e: + raise ValueError( + f"move_iswap_z(z_position={z_position}) resolved to rotation drive " + f"target z={target_rotation_drive_z:.2f}: {e}" + ) from e + + # ----------------------------------------------------------------------- + # iSWAP: SCARA Geometry + # ----------------------------------------------------------------------- + + async def iswap_request_link_1_length(self) -> float: + """Read iSWAP link 1 length (rotation joint -> wrist joint) in mm. + + Default factory value 138.0 mm. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RA", ra="pw", fmt="pw##### (n)") + pw = cast(List[int], resp["pw"]) + return round(pw[9] / 10, 1) + + async def iswap_request_link_2_length(self) -> float: + """Read iSWAP link 2 length (wrist joint -> gripper finger center) in mm. + + Default factory value 138.0 mm. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RA", ra="pt", fmt="pt##### (n)") + pt = cast(List[int], resp["pt"]) + return round(pt[9] / 10, 1) + + # ======================================================================= + # TODO: remove in v1 - iSWAP drive constants superseded by `iSWAPInformation`. + # + # Every value below now lives canonically in `iSWAPInformation` (per-drive + # area-of-operation ranges + encoder resolutions); all internal usage reads + # from there. Kept only for backward compatibility with external code and as + # an audit anchor for the prior literals. + # Ordered to match iSWAPInformation: Y, Z, rotation drive, wrist drive, + # gripper (no X-axis constant is vestigial). + # ======================================================================= + # Y carriage (commanded via the rotation drive) + iswap_y_drive_mm_per_increment = 0.046302083 + iswap_rotation_drive_y_speed_increment_range = (50, 8_000) + + # Z carriage (increments in finger-plane coords, the R0 ZA reference) + iswap_rotation_drive_z_min_increment = -187 + iswap_rotation_drive_z_max_increment = 26_661 + iswap_z_drive_mm_per_increment = 0.01072765 + iswap_rotation_drive_z_speed_increment_range = (50, 15_000) + iswap_rotation_drive_z_acceleration_increment_range = (5, 999) + + # rotation drive (W) + iswap_rotation_drive_min_increment = -30032 # ~ -93 deg + iswap_rotation_drive_max_increment = 30032 # ~ +93 deg + iswap_rotation_drive_deg_per_increment = 0.00309619077 + + # wrist drive (T) + iswap_wrist_drive_min_increment = -30000 # ~ -152 deg + iswap_wrist_drive_max_increment = 30000 # ~ +152 deg + iswap_wrist_drive_deg_per_increment = 0.00507968798 + + # gripper (G) + iswap_gripper_drive_min_increment = 12780 # ~ 71 mm + iswap_gripper_drive_max_increment = 24120 # ~ 134 mm + iswap_gripper_drive_mm_per_increment = 0.00554337 + # ======================================================================= + + # ----------------------------------------------------------------------- + # iSWAP: "Rotation Drive" (Joint 1) + # ----------------------------------------------------------------------- + + iswap_rotation_drive_diameter = 30.5 + iswap_rotation_drive_safety_radius = 90.0 + + @staticmethod + def iswap_y_drive_mm_to_increment( + value_mm: float, mm_per_increment: float = iSWAPInformation.y_mm_per_increment + ) -> int: + return round(value_mm / mm_per_increment) + + @staticmethod + def iswap_y_drive_increment_to_mm( + value_increments: int, mm_per_increment: float = iSWAPInformation.y_mm_per_increment + ) -> float: + return round(value_increments * mm_per_increment, 2) + + @staticmethod + def iswap_z_drive_mm_to_increment( + value_mm: float, mm_per_increment: float = iSWAPInformation.z_mm_per_increment + ) -> int: + return round(value_mm / mm_per_increment) + + @staticmethod + def iswap_z_drive_increment_to_mm( + value_increments: int, mm_per_increment: float = iSWAPInformation.z_mm_per_increment + ) -> float: + return round(value_increments * mm_per_increment, 2) + + class RotationDriveOrientation(enum.Enum): + LEFT = 1 + FRONT = 2 + RIGHT = 3 + PARKED_RIGHT = None + + async def _iswap_rotation_drive_request_x_offset(self) -> float: + """Read the X-offset i.e. X-axis center <-> iSWAP rotation drive, in mm. + + Stored in the master EEPROM as parameter `kg`. + Default: 34.0 mm, but typically tuned per machine during service calibration. + Required for deriving the iSWAP rotation drive's deck X coordinate from + the X-arm carriage center. + Cached on the backend as `iswap_information.rotation_drive_x_offset` during setup. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="C0", command="RA", ra="kg", fmt="kg###") + return cast(int, resp["kg"]) / 10.0 + + async def _iswap_rotation_drive_request_y_max(self) -> float: + """Read the iSWAP Y-axis upper bound (parking pose) from EEPROM, in mm. + + Parking sits at the back of the usable Y travel; anything past it is in + the mechanical-stop region. + """ + py = await self.iswap_rotation_drive_request_predefined_y_positions() + return py["parking"] + + async def iswap_rotation_drive_request_x(self) -> float: + """Request iSWAP rotation drive X position (deck coordinates), in mm. + + Computed as `request_left_x_arm_position() - kg` (cached at setup). + """ + x_arm_center = await self.request_left_x_arm_position() + return x_arm_center - self.iswap_information.rotation_drive_x_offset + + async def iswap_rotation_drive_request_y(self) -> float: + """Request iSWAP rotation drive Y position (deck coordinates), in mm. + + Reads the linear Y carriage that the rotation joint is mounted on. This is + NOT the gripper finger's Y - the finger position depends on the rotation + drive (W) and wrist (T) angles. Use `iswap_rotation_drive_request_position` + for the rotation drive's full XYZ. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RY", fmt="ry##### (n)") + iswap_y_pos = resp["ry"][1] # 0 = FW counter, 1 = HW counter + return round( + STARBackend.iswap_y_drive_increment_to_mm( + iswap_y_pos, self.iswap_information.y_mm_per_increment + ), + 1, + ) + + # Vertical offset between the rotation drive's bottom (its lowest physical + # point) and the gripper finger plane. R0 RZ is calibrated to the finger + # plane; the rotation drive's bottom sits 13 mm above it. + iswap_rotation_drive_z_offset_above_finger_mm = 13.0 + + async def iswap_rotation_drive_request_z(self) -> float: + """Request iSWAP rotation-drive-bottom Z (deck coordinates), in mm. + + Returns the Z of the rotation drive's lowest physical point, which sits + `iswap_rotation_drive_z_offset_above_finger_mm` above the gripper finger + plane that R0 RZ reports. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RZ", fmt="rz##### (n)") + iswap_z_pos_increments = resp["rz"][1] # 0 = FW counter, 1 = HW counter + finger_plane_z = STARBackend.iswap_z_drive_increment_to_mm( + iswap_z_pos_increments, self.iswap_information.z_mm_per_increment + ) + return round(finger_plane_z + STARBackend.iswap_rotation_drive_z_offset_above_finger_mm, 1) + + async def iswap_rotation_drive_request_position(self) -> Coordinate: + """Position of the iSWAP rotation drive (joint 1) in deck coordinates, mm.""" + return Coordinate( + x=await self.iswap_rotation_drive_request_x(), + y=await self.iswap_rotation_drive_request_y(), + z=await self.iswap_rotation_drive_request_z(), + ) + + async def experimental_iswap_rotation_drive_move_x( + self, + x: float, + acceleration_level: int = 3, + current_protection_limiter: int = 7, + ): + """Move the iSWAP rotation drive to an absolute X position (deck coordinates). + + Thin wrapper around `x_arm_move` that translates rotation-drive X into + X-arm carriage X using the cached `kg` offset. + + Args: + x: Target rotation-drive X coordinate in mm. + acceleration_level: Acceleration index (hardware units), 1-5. Default 3. + current_protection_limiter: Motor current limit (hardware units), 0-7. Default 7. + """ + # TODO: remove "experimental_" prefix once x_arm_move has been optimised + + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + kg = self.iswap_information.rotation_drive_x_offset + + x_min = 90.0 - kg + x_max = 1350.0 - kg + if not (x_min <= x <= x_max): + raise ValueError(f"x must be between {x_min} and {x_max} mm, is {x}") + + return await self.experimental_x_arm_move( + x=x + kg, + acceleration_level=acceleration_level, + current_protection_limiter=current_protection_limiter, + ) + + async def iswap_rotation_drive_move_y( + self, + y: float, + speed: float = 220.0, + acceleration_level: int = 2, + current_protection_limiter: int = 7, + make_space: bool = False, + ): + """Move the iSWAP rotation drive to an absolute Y position. + + To stay clear of channel 0 regardless of the current W-axis angle, the + iSWAP envelope is treated as a circle of radius + `iswap_rotation_drive_diameter / 2 + iswap_rotation_drive_safety_radius`. + The safety radius bounds the link-1 and protrusion sweep across all + rotation poses. + + Args: + y: Target Y coordinate in mm. + speed: Max velocity in mm/sec. Default 220.0. + acceleration_level: Acceleration index, 1 or 2. Default 2. + current_protection_limiter: Motor current limit, 0-7. Default 7. + make_space: If True, reposition pipetting channels in a single + synchronous JY move when channel 0 is in the way and can be cleared. + If False, raise so the caller decides. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + + iswap_radius = ( + STARBackend.iswap_rotation_drive_diameter / 2 + STARBackend.iswap_rotation_drive_safety_radius + ) + channel_0_radius = self._channels_minimum_y_spacing[0] / 2 + channel_0_y = await self.request_y_pos_channel_n(0) + + compressed_channel_0_y = self.extended_conf.left_arm_min_y_position + sum( + self._channels_minimum_y_spacing[1:] + ) + + max_y = self.iswap_information.rotation_drive_y_max + absolute_min_y = self.extended_conf.left_arm_min_y_position + if not (absolute_min_y <= y <= max_y): + raise ValueError(f"y must be between {absolute_min_y} and {max_y} mm, is {y}") + + target_channel_0_y = y - channel_0_radius - iswap_radius + if channel_0_y > target_channel_0_y: + if target_channel_0_y < compressed_channel_0_y: + raise ValueError( + f"y={y} mm is unreachable: would require channel 0 at " + f"{target_channel_0_y} mm, below the compressed floor " + f"{compressed_channel_0_y} mm" + ) + if not make_space: + raise ValueError( + f"y={y} mm requires channel 0 at <= {target_channel_0_y} mm " + f"(currently {channel_0_y} mm); pass make_space=True to " + f"reposition channels" + ) + await self.move_all_channels_in_z_safety() + await self.position_channels_in_y_direction({0: target_channel_0_y}, make_space=True) + + y_mm_per_increment = self.iswap_information.y_mm_per_increment + speed_increments = STARBackend.iswap_y_drive_mm_to_increment(speed, y_mm_per_increment) + speed_min, speed_max = self.iswap_information.y_speed_increment_range + if not (speed_min <= speed_increments <= speed_max): + raise ValueError( + f"speed must be between " + f"{STARBackend.iswap_y_drive_increment_to_mm(speed_min, y_mm_per_increment)} and " + f"{STARBackend.iswap_y_drive_increment_to_mm(speed_max, y_mm_per_increment)} mm/sec, " + f"got {speed} mm/sec" + ) + + if not (1 <= acceleration_level <= 2): + raise ValueError(f"acceleration_level must be between 1 and 2, got {acceleration_level}") + + if not (0 <= current_protection_limiter <= 7): + raise ValueError( + f"current_protection_limiter must be between 0 and 7, got {current_protection_limiter}" + ) + + await self.send_command( + module="R0", + command="YA", + ya=f"{round(STARBackend.iswap_y_drive_mm_to_increment(y, y_mm_per_increment)):05}", + yv=f"{round(speed_increments):04}", + yr=f"{int(acceleration_level)}", + yw=f"{int(current_protection_limiter)}", + ) + + async def iswap_rotation_drive_request_predefined_y_positions( + self, y_mm_per_increment: float = iSWAPInformation.y_mm_per_increment + ) -> Dict[str, float]: + """Read iSWAP rotation-drive Y predefined-position table from EEPROM, in mm. + + Sends R0 RA ra=py. Firmware returns 10 signed-integer slots; all 10 are + positions (no length slot, unlike pw/pt). Slots beyond the documented + semantic roles are extra slots addressable via R0 YP yp5..yp9. + + ``y_mm_per_increment`` defaults to the class constant rather than + ``self.iswap_information`` because this method runs during ``setup()`` to + compute ``rotation_drive_y_max`` -- i.e. before ``iswap_information`` exists. + + Keys (mm): + "home" py[0] - home position + "lower_limit" py[1] - lower travel limit + "upper_limit" py[2] - upper travel limit + "parking" py[3] - parking pose (back of travel) + "pre_parking" py[4] - pre-parking pose (firmware requires py[4] < py[3] - 430) + "extra_1" py[5] - extra slot, address via R0 YP yp5 + "extra_2" py[6] - extra slot, address via R0 YP yp6 + "extra_3" py[7] - extra slot, address via R0 YP yp7 + "extra_4" py[8] - extra slot, address via R0 YP yp8 + "extra_5" py[9] - extra slot, address via R0 YP yp9 + + Raises: + RuntimeError: if the iSWAP module is not installed. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RA", ra="py", fmt="py##### (n)") + py = cast(List[int], resp["py"]) + keys = ( + "home", + "lower_limit", + "upper_limit", + "parking", + "pre_parking", + "extra_1", + "extra_2", + "extra_3", + "extra_4", + "extra_5", + ) + return { + k: STARBackend.iswap_y_drive_increment_to_mm(py[i], y_mm_per_increment) + for i, k in enumerate(keys) + } + + async def iswap_rotation_drive_move_z( + self, + z: float, + speed: float = 118.0, + acceleration: float = 643.66, + current_protection_limiter: int = 6, + ): + """Move the iSWAP rotation-drive bottom to an absolute Z (deck coordinates). + + `z` is the rotation-drive bottom Z (lowest physical point of the drive), + matching what `iswap_rotation_drive_request_z` returns. The 13 mm offset + to the finger plane (R0 ZA reference) is applied internally. + + Args: + z: Target rotation-drive-bottom Z coordinate in mm. + speed: Max velocity in mm/sec. Default 118.0 (firmware default). + acceleration: Acceleration in mm/sec^2. Default 643.66 + (firmware default 60 in 1000 incr/sec^2 units). + current_protection_limiter: Motor current limit, 0-7. Default 6 + (firmware default). + + Raises: + RuntimeError: if the iSWAP module is not installed. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + + z_mm_per_increment = self.iswap_information.z_mm_per_increment + z_min_incr, z_max_incr = self.iswap_information.z_increment_range + absolute_min_z = ( + STARBackend.iswap_z_drive_increment_to_mm(z_min_incr, z_mm_per_increment) + + STARBackend.iswap_rotation_drive_z_offset_above_finger_mm + ) + absolute_max_z = ( + STARBackend.iswap_z_drive_increment_to_mm(z_max_incr, z_mm_per_increment) + + STARBackend.iswap_rotation_drive_z_offset_above_finger_mm + ) + if not (absolute_min_z <= z <= absolute_max_z): + raise ValueError(f"z must be between {absolute_min_z} and {absolute_max_z} mm, is {z}") + + finger_plane_z = z - STARBackend.iswap_rotation_drive_z_offset_above_finger_mm + z_increments = STARBackend.iswap_z_drive_mm_to_increment(finger_plane_z, z_mm_per_increment) + + speed_increments = STARBackend.iswap_z_drive_mm_to_increment(speed, z_mm_per_increment) + speed_min, speed_max = self.iswap_information.z_speed_increment_range + if not (speed_min <= speed_increments <= speed_max): + raise ValueError( + f"speed must be between " + f"{STARBackend.iswap_z_drive_increment_to_mm(speed_min, z_mm_per_increment)} and " + f"{STARBackend.iswap_z_drive_increment_to_mm(speed_max, z_mm_per_increment)} mm/sec, " + f"is {speed}" + ) + + acceleration_increments = STARBackend.iswap_z_drive_mm_to_increment( + acceleration / 1000, z_mm_per_increment + ) + accel_min, accel_max = self.iswap_information.z_acceleration_increment_range + if not (accel_min <= acceleration_increments <= accel_max): + raise ValueError( + f"acceleration must be between " + f"{STARBackend.iswap_z_drive_increment_to_mm(accel_min * 1000, z_mm_per_increment)} and " + f"{STARBackend.iswap_z_drive_increment_to_mm(accel_max * 1000, z_mm_per_increment)} mm/sec^2, " + f"is {acceleration}" + ) + + if not (0 <= current_protection_limiter <= 7): + raise ValueError( + f"current_protection_limiter must be between 0 and 7, is {current_protection_limiter}" + ) + + await self.send_command( + module="R0", + command="ZA", + za=f"{round(z_increments):+06}", + zv=f"{round(speed_increments):05}", + zr=f"{round(acceleration_increments):03}", + zw=f"{int(current_protection_limiter)}", + ) + + async def iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, float]: + """Read iSWAP rotation-drive Z predefined-position table from EEPROM, in mm. + + Sends R0 RA ra=pz. Firmware returns 10 signed-integer slots; all 10 are + positions (no length slot, unlike pw/pt). Slots beyond home/parking are + extra slots addressable via R0 ZP zp2..zp9. + + Returns rotation-drive-bottom Z (matching `iswap_rotation_drive_request_z` + and `iswap_rotation_drive_move_z`): each EEPROM finger-plane increment is + converted via `iswap_z_drive_increment_to_mm` then offset by + `iswap_rotation_drive_z_offset_above_finger_mm`. + + Keys (mm): + "home" pz[0] - home position + "parking" pz[1] - parking pose + "extra_1" pz[2] - extra slot, address via R0 ZP zp2 + "extra_2" pz[3] - extra slot, address via R0 ZP zp3 + "extra_3" pz[4] - extra slot, address via R0 ZP zp4 + "extra_4" pz[5] - extra slot, address via R0 ZP zp5 + "extra_5" pz[6] - extra slot, address via R0 ZP zp6 + "extra_6" pz[7] - extra slot, address via R0 ZP zp7 + "extra_7" pz[8] - extra slot, address via R0 ZP zp8 + "extra_8" pz[9] - extra slot, address via R0 ZP zp9 + + Raises: + RuntimeError: if the iSWAP module is not installed. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RA", ra="pz", fmt="pz##### (n)") + pz = cast(List[int], resp["pz"]) + offset = STARBackend.iswap_rotation_drive_z_offset_above_finger_mm + keys = ( + "home", + "parking", + "extra_1", + "extra_2", + "extra_3", + "extra_4", + "extra_5", + "extra_6", + "extra_7", + "extra_8", + ) + z_mm_per_increment = self.iswap_information.z_mm_per_increment + return { + k: STARBackend.iswap_z_drive_increment_to_mm(pz[i], z_mm_per_increment) + offset + for i, k in enumerate(keys) + } + + async def _iswap_rotation_drive_request_predefined_increments(self) -> Dict[str, int]: + """Read the iSWAP rotation drive (W) predefined-position table from EEPROM. + + Sends R0 RA ra=pw. Firmware returns 10 signed-integer slots; the 9 position + slots are returned here. Slot pw[9] (arm length) is exposed separately via + `iswap_request_link_1_length`. Undocumented slots are returned as + "extra_1".."extra_4" and addressable via R0 WP wp5..wp8. + + Keys (motor increments; W-drive resolution 0.00310 deg/incr): + "home" pw[0] - home position + "left" pw[1] - LEFT deck position (~ -90 deg) + "front" pw[2] - FRONT deck position (~ 0 deg) + "right" pw[3] - RIGHT deck position (~ +90 deg) + "parking" pw[4] - past-W3 parking pose (firmware requires > iw + 50) + "extra_1" pw[5] - extra slot, address via R0 WP wp5 + "extra_2" pw[6] - extra slot, address via R0 WP wp6 + "extra_3" pw[7] - extra slot, address via R0 WP wp7 + "extra_4" pw[8] - extra slot, address via R0 WP wp8 + + Raises: + RuntimeError: if the iSWAP module is not installed. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RA", ra="pw", fmt="pw##### (n)") + pw = cast(List[int], resp["pw"]) + return { + "home": pw[0], + "left": pw[1], + "front": pw[2], + "right": pw[3], + "parking": pw[4], + "extra_1": pw[5], + "extra_2": pw[6], + "extra_3": pw[7], + "extra_4": pw[8], + } + + async def _request_iswap_rotation_drive_position_increments(self) -> int: + """Query the iSWAP rotation drive position (units: increments) from the firmware.""" + response = await self.send_command(module="R0", command="RW", fmt="rw######") + return cast(int, response["rw"]) + + @staticmethod + def _iswap_rotation_drive_increments_to_angle( + increments: int, + predefined_increments: Dict["STARBackend.RotationDriveOrientation", int], + ) -> float: + """Piecewise-linear map encoder increments -> degrees. + + [LEFT, FRONT] -> [-90, 0] and [FRONT, RIGHT] -> [0, +90]. Increments outside + [LEFT, RIGHT] extrapolate using each segment's slope (e.g. PARKED_RIGHT ~+91 + deg). Anchored on the calibrated stops, so they report exactly -90/0/+90. + """ + front = predefined_increments[STARBackend.RotationDriveOrientation.FRONT] + if increments < front: + left = predefined_increments[STARBackend.RotationDriveOrientation.LEFT] + return -90.0 * (front - increments) / (front - left) + else: + right = predefined_increments[STARBackend.RotationDriveOrientation.RIGHT] + return 90.0 * (increments - front) / (right - front) + + @staticmethod + def _iswap_rotation_drive_angle_to_increments( + angle: float, + predefined_increments: Dict["STARBackend.RotationDriveOrientation", int], + ) -> int: + """Inverse of `_iswap_rotation_drive_increments_to_angle`; rounds to the nearest + integer increment. The named stop angles (-90 / 0 / +90) return exactly the + EEPROM stop increments (rounding is a no-op for the stop values themselves).""" + front = predefined_increments[STARBackend.RotationDriveOrientation.FRONT] + if angle < 0: + left = predefined_increments[STARBackend.RotationDriveOrientation.LEFT] + return round(front - (front - left) * (-angle / 90.0)) + else: + right = predefined_increments[STARBackend.RotationDriveOrientation.RIGHT] + return round(front + (right - front) * (angle / 90.0)) + + @staticmethod + def _iswap_rotation_drive_resolve_to_increments( + angle: Union["STARBackend.RotationDriveOrientation", float], + predefined_increments: Dict["STARBackend.RotationDriveOrientation", int], + deg_per_increment: float = iSWAPInformation.rotation_deg_per_increment, + ) -> int: + """Resolve a rotation-drive target (enum or float deg) to motor increments. + + Enum stops return the EEPROM increment directly. Floats use the calibrated + piecewise-linear conversion, but snap to a stop's exact stored increment + when the float matches that stop's reported deg-form within one increment + of precision - so a value read via `iswap_rotation_drive_request_angle` + round-trips back to the same motor increment, including for PARKED_RIGHT + where the extrapolated formula is FP-vulnerable. + """ + if isinstance(angle, STARBackend.RotationDriveOrientation): + return predefined_increments[angle] + for stop_incr in predefined_increments.values(): + stop_angle = STARBackend._iswap_rotation_drive_increments_to_angle( + stop_incr, predefined_increments + ) + if abs(angle - stop_angle) <= deg_per_increment: + return stop_incr + return STARBackend._iswap_rotation_drive_angle_to_increments(angle, predefined_increments) + + async def iswap_rotation_drive_request_angle(self) -> float: + """Query the iSWAP rotation drive angle in degrees (signed, 0 deg = calibrated FRONT). + + See `_iswap_rotation_drive_increments_to_angle` for the conversion. On a + calibrated STAR the FRONT stop can sit ~+/-300 incr (~1 deg) off the + Hamilton-default zero. + + Raises: + RuntimeError: if `setup()` has not populated the predefined positions. + """ + increments = await self._request_iswap_rotation_drive_position_increments() + return STARBackend._iswap_rotation_drive_increments_to_angle( + increments, self.iswap_information.rotation_drive_predefined_increments + ) + + async def request_iswap_rotation_drive_orientation(self) -> "RotationDriveOrientation": + """Request the iSWAP rotation drive orientation. + + Uses nearest-neighbour classification against the per-machine EEPROM `pw` + values populated at setup. An earlier implementation used +/-50 windows + around the Hamilton factory defaults and faulted on machines calibrated + outside that band; we now pick whichever predefined stop is closest and + only raise if the drive is more than 5 deg from any predefined stop. + + Hamilton factory-default values shown below for reference only; the + per-machine EEPROM table is queried at setup and used at runtime in place + of these (W-drive resolution = 0.00310 deg/incr):: + + LEFT W1 -29068 incr (-90 deg) + FRONT W2 +0 incr ( +0 deg) + RIGHT W3 +29068 incr (+90 deg) + PARKED_RIGHT park +29500 incr (+91 deg, beyond W3 at the stop) + + Returns: + RotationDriveOrientation: The interpreted rotation orientation + (LEFT, FRONT, RIGHT, or PARKED_RIGHT). + + Raises: + RuntimeError: if `setup()` has not populated the predefined positions. + ValueError: if the measured position is more than 5 deg from any + predefined stop (drive is in transit or drifted). + """ + # PARKED_RIGHT is kept as a distinct neighbour so we can report "parked" + # explicitly when the drive sits at the parking stop rather than the W3 + # work stop. + # TODO: add PARKED_LEFT reference for STAR(let)s that park on the left. + predefined_increments = self.iswap_information.rotation_drive_predefined_increments + front = predefined_increments[STARBackend.RotationDriveOrientation.FRONT] + # Compute the per-machine increment delta for 5 deg via the same piecewise mapping the + # angle methods use, so the tolerance reflects the calibrated FRONT->RIGHT slope. + tolerance_incr = ( + STARBackend._iswap_rotation_drive_angle_to_increments(5.0, predefined_increments) - front + ) + + motor_position_increments = await self._request_iswap_rotation_drive_position_increments() + + orientation, offset = min( + ((o, abs(p - motor_position_increments)) for o, p in predefined_increments.items()), + key=lambda pair: pair[1], + ) + if offset > tolerance_incr: + raise ValueError( + f"Unknown rotation orientation: {motor_position_increments} incr is " + f"{offset} incr (~{offset * self.iswap_information.rotation_deg_per_increment:.2f} deg) " + f"from the nearest predefined " + f"stop ({orientation.name} at {predefined_increments[orientation]}). " + "Is the rotation drive in transit or mis-calibrated?" + ) + return orientation + + async def rotate_iswap_rotation_drive(self, orientation: RotationDriveOrientation): + """Rotate the iSWAP rotation drive to a predefined working stop (R0 WP). + + Args: + orientation: must be LEFT, FRONT, or RIGHT. PARKED_RIGHT is not + accepted; use `park_iswap()` for parking. + + Raises: + ValueError: if orientation is not LEFT, FRONT, or RIGHT. + """ + if orientation in { + STARBackend.RotationDriveOrientation.RIGHT, + STARBackend.RotationDriveOrientation.FRONT, + STARBackend.RotationDriveOrientation.LEFT, + }: + return await self.send_command( + module="R0", + command="WP", + auto_id=False, + wp=orientation.value, + ) + else: + raise ValueError(f"Invalid rotation drive orientation: {orientation}") + + async def _iswap_rotation_drive_rotate_to_angle( + self, + angle: Union[RotationDriveOrientation, float], + speed: int = 25_000, + acceleration: int = 170, + current_limit: int = 5, + ) -> None: + """Rotate only the iSWAP rotation drive (Joint 1) to an absolute angle. + + Internal single-axis variant kept for troubleshooting direct one-joint + motion. The public entry point is `iswap_rotate_to_angles`, which covers + this case via `rotation_angle=X, wrist_angle=None` and also drives both + joints together when both are supplied. + + Passing a `RotationDriveOrientation` (LEFT / FRONT / RIGHT / PARKED_RIGHT) + sends the drive to the exact EEPROM-stored increment for that stop. Passing + a float interprets it as degrees signed from the calibrated FRONT (0 deg), + using piecewise-linear interpolation between the three working stops (LEFT + -> FRONT for negative angles, FRONT -> RIGHT for non-negative); the named + stop angles (-90 / 0 / +90) thus round-trip exactly. Angles beyond +/-90 + extrapolate using each segment's slope. The wrist drive is held at its + current position. + + Note on PARKED_RIGHT: this method moves only the rotation drive joint to + the parking increment via an absolute-position command - it does NOT invoke + the full gripper-park procedure provided by `park_iswap()` (which also + closes the gripper, applies a traverse-height constraint, and sets the + internal `_iswap_parked` state). Use `rotate_to_angle(PARKED_RIGHT)` when + you want only the joint motion; use `park_iswap()` when you want the full + safe-park. + + Args: + angle: either a `RotationDriveOrientation` member, or a float in degrees + signed from the calibrated FRONT (~+/-93 deg achievable window; + per-machine, depends on the EEPROM-stored stops populated at setup). + speed: max velocity in increments/sec, range 20..75000. + acceleration: in 1000 increments/sec^2, range 5..200. + current_limit: motor current protection limiter, range 0..7. + + Raises: + RuntimeError: if `setup()` has not populated the predefined positions. + ValueError: if the resulting target increment is outside the hardware range. + """ + predefined_increments = self.iswap_information.rotation_drive_predefined_increments + rotation_min, rotation_max = self.iswap_information.rotation_increment_range + rotation_position_increments = STARBackend._iswap_rotation_drive_resolve_to_increments( + angle, predefined_increments, self.iswap_information.rotation_deg_per_increment + ) + if not (rotation_min <= rotation_position_increments <= rotation_max): + rotation_position_deg = STARBackend._iswap_rotation_drive_increments_to_angle( + rotation_position_increments, predefined_increments + ) + raise ValueError( + f"angle {angle} maps to {rotation_position_increments} incr ({rotation_position_deg:.2f} deg) " + f"(stops LEFT/FRONT/RIGHT=" + f"{predefined_increments[STARBackend.RotationDriveOrientation.LEFT]}/" + f"{predefined_increments[STARBackend.RotationDriveOrientation.FRONT]}/" + f"{predefined_increments[STARBackend.RotationDriveOrientation.RIGHT]}), " + f"outside hardware range [{rotation_min}, " + f"{rotation_max}]" + ) + wrist_position_increments = await self._request_iswap_wrist_drive_position_increments() + + await self._iswap_rotate_increments( + rotation_position_increments=rotation_position_increments, + wrist_position_increments=wrist_position_increments, + rotation_speed=speed, + rotation_acceleration=acceleration, + rotation_current_limit=current_limit, + ) + + # ----------------------------------------------------------------------- + # iSWAP: "Wrist Drive" (Joint 2) + # ----------------------------------------------------------------------- + + class WristDriveOrientation(enum.Enum): + RIGHT = 1 + STRAIGHT = 2 + LEFT = 3 + REVERSE = 4 + + async def _request_iswap_wrist_drive_position_increments(self) -> int: + """Query the iSWAP wrist drive position (units: increments) from the firmware.""" + response = await self.send_command(module="R0", command="RT", fmt="rt######") + return cast(int, response["rt"]) + + async def _iswap_wrist_drive_request_predefined_increments(self) -> Dict[str, int]: + """Read the iSWAP wrist twist drive (T) predefined-position table from EEPROM. + + Sends R0 RA ra=pt. Firmware returns 10 signed-integer slots; the 9 position + slots are returned here. Slot pt[9] (arm length) is exposed separately via + `iswap_request_link_2_length`. Undocumented slots are returned as + "extra_1".."extra_3" and addressable via R0 TP tp6..tp8. + + Keys (motor increments; T-drive resolution 0.00508 deg/incr): + "home" pt[0] - home position + "right" pt[1] - wrist twisted right relative to arm (~ -135 deg) + "straight" pt[2] - wrist aligned with arm (~ -45 deg) + "left" pt[3] - wrist twisted left relative to arm (~ +45 deg) + "reverse" pt[4] - wrist twisted 180 deg from straight (~ +135 deg) + "parking" pt[5] - free pip channel + parking pose (firmware requires < it - 50) + "extra_1" pt[6] - extra slot, address via R0 TP tp6 + "extra_2" pt[7] - extra slot, address via R0 TP tp7 + "extra_3" pt[8] - extra slot, address via R0 TP tp8 + + Raises: + RuntimeError: if the iSWAP module is not installed. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RA", ra="pt", fmt="pt##### (n)") + pt = cast(List[int], resp["pt"]) + return { + "home": pt[0], + "right": pt[1], + "straight": pt[2], + "left": pt[3], + "reverse": pt[4], + "parking": pt[5], + "extra_1": pt[6], + "extra_2": pt[7], + "extra_3": pt[8], + } + + @staticmethod + def _iswap_wrist_drive_increments_to_angle( + increments: int, deg_per_increment: float = iSWAPInformation.wrist_deg_per_increment + ) -> float: + """Linear map encoder increments -> degrees, anchored on motor zero (0 incr = 0 deg). + + Symmetric around the motor's raw zero, so the achievable range is + ~+/-152 deg (the +/-30000 incr hardware limits). Named EEPROM stops + report approximately: + + RIGHT ~ -135 deg + STRAIGHT ~ -45 deg + LEFT ~ +45 deg + REVERSE ~ +135 deg + + with small per-machine drift from the factory defaults. Callers that + need an exact named-stop landing should pass the `WristDriveOrientation` + member to `iswap_rotate_to_angles` rather than a float -- the enum path + uses the calibrated EEPROM increment directly. + """ + return increments * deg_per_increment + + @staticmethod + def _iswap_wrist_drive_angle_to_increments( + angle: float, deg_per_increment: float = iSWAPInformation.wrist_deg_per_increment + ) -> int: + """Inverse of `_iswap_wrist_drive_increments_to_angle`; rounds to nearest int.""" + return round(angle / deg_per_increment) + + @staticmethod + def _iswap_wrist_drive_resolve_to_increments( + angle: Union["STARBackend.WristDriveOrientation", float], + predefined_increments: Dict["STARBackend.WristDriveOrientation", int], + deg_per_increment: float = iSWAPInformation.wrist_deg_per_increment, + ) -> int: + """Resolve a wrist-drive target (enum or float deg) to motor increments. + + Snap-to-stop rule mirrors `_iswap_rotation_drive_resolve_to_increments`; + here the snap is the only mechanism that lands the per-machine stops + bit-exact, since the standard linear conversion is anchored on motor zero + rather than the calibrated stops. + """ + if isinstance(angle, STARBackend.WristDriveOrientation): + return predefined_increments[angle] + for stop_incr in predefined_increments.values(): + stop_angle = STARBackend._iswap_wrist_drive_increments_to_angle(stop_incr, deg_per_increment) + if abs(angle - stop_angle) <= deg_per_increment: + return stop_incr + return STARBackend._iswap_wrist_drive_angle_to_increments(angle, deg_per_increment) + + async def iswap_wrist_drive_request_angle(self) -> float: + """Query the iSWAP wrist drive angle in degrees (signed, 0 deg = motor zero). + + See `_iswap_wrist_drive_increments_to_angle` for the conversion. The + motor's raw zero sits between STRAIGHT and LEFT; this convention keeps + the achievable range symmetric (~+/-152 deg). + """ + increments = await self._request_iswap_wrist_drive_position_increments() + return STARBackend._iswap_wrist_drive_increments_to_angle( + increments, self.iswap_information.wrist_deg_per_increment + ) + + async def request_iswap_wrist_drive_orientation(self) -> "WristDriveOrientation": + """Request the iSWAP wrist drive orientation (relative to the rotation drive). + + e.g.: + + 1) RotationDriveOrientation.FRONT + WristDriveOrientation.STRAIGHT + => wrist also points to the front of the machine. + + 2) RotationDriveOrientation.LEFT + WristDriveOrientation.STRAIGHT + => wrist also points to the left of the machine. + + 3) RotationDriveOrientation.FRONT + WristDriveOrientation.RIGHT + => wrist points to the left of the machine. + + Uses nearest-neighbour classification against the per-machine EEPROM `pt` + values populated at setup. An earlier implementation used +/-50 windows + around the Hamilton factory defaults and faulted on machines calibrated + outside that band; we now pick whichever predefined stop is closest and + only raise if the wrist is more than 5 deg from any predefined stop. + + Hamilton factory-default values shown below for reference only; the + per-machine EEPROM table is queried at setup and used at runtime in place + of these (T-drive resolution = 0.00508 deg/incr):: + + RIGHT T1 -26577 incr (-135 deg) + STRAIGHT T2 -8859 incr ( -45 deg) + LEFT T3 +8859 incr ( +45 deg) + REVERSE T4 +26577 incr (+135 deg) + + Returns: + WristDriveOrientation: The interpreted wrist orientation + (RIGHT, STRAIGHT, LEFT, or REVERSE). + + Raises: + RuntimeError: if `setup()` has not populated the predefined positions. + ValueError: if the measured position is more than 5 deg from any + predefined stop (drive is in transit or drifted). + """ + predefined_increments = self.iswap_information.wrist_drive_predefined_increments + wrist_deg_per_increment = self.iswap_information.wrist_deg_per_increment + tolerance_incr = round(5.0 / wrist_deg_per_increment) # ~1000 + + motor_position_increments = await self._request_iswap_wrist_drive_position_increments() + + orientation, offset = min( + ((o, abs(p - motor_position_increments)) for o, p in predefined_increments.items()), + key=lambda pair: pair[1], + ) + if offset > tolerance_incr: + raise ValueError( + f"Unknown wrist orientation: {motor_position_increments} incr is " + f"{offset} incr (~{offset * wrist_deg_per_increment:.2f} deg) " + f"from the nearest predefined " + f"stop ({orientation.name} at {predefined_increments[orientation]}). " + "Is the wrist drive in transit or mis-calibrated?" + ) + return orientation + + async def rotate_iswap_wrist(self, orientation: WristDriveOrientation): + """Rotate the iSWAP wrist drive to a predefined orientation.""" + return await self.send_command( + module="R0", + command="TP", + auto_id=False, + tp=orientation.value, + ) + + async def _iswap_wrist_drive_rotate_to_angle( + self, + angle: Union[WristDriveOrientation, float], + speed: int = 20_000, + acceleration: int = 145, + current_limit: int = 5, + ) -> None: + """Rotate only the iSWAP wrist drive (Joint 2) to an absolute angle. + + Internal single-axis variant kept for troubleshooting direct one-joint + motion. The public entry point is `iswap_rotate_to_angles`, which covers + this case via `wrist_angle=X, rotation_angle=None` and also drives both + joints together when both are supplied. + + Passing a `WristDriveOrientation` (RIGHT / STRAIGHT / LEFT / REVERSE) sends + the drive to the exact EEPROM-stored increment for that stop. Passing a + float interprets it as degrees signed from motor zero (0 deg = 0 incr), + via the linear `deg_per_increment` conversion. Achievable range is + ~+/-152 deg (the +/-30000 incr hardware limits). The rotation drive is + held at its current position. + + Args: + angle: either a `WristDriveOrientation` member (uses EEPROM stop), or + a float in degrees signed from motor zero. + speed: max velocity in increments/sec, range 20..65000. + acceleration: in 1000 increments/sec^2, range 5..200. + current_limit: motor current protection limiter, range 0..7. + + Raises: + RuntimeError: when an orientation is passed and `setup()` has not + populated the predefined positions yet. + ValueError: if the resulting target increment is outside the hardware range. + """ + predefined_increments = self.iswap_information.wrist_drive_predefined_increments + wrist_deg_per_increment = self.iswap_information.wrist_deg_per_increment + wrist_min, wrist_max = self.iswap_information.wrist_increment_range + wrist_position_increments = STARBackend._iswap_wrist_drive_resolve_to_increments( + angle, predefined_increments, wrist_deg_per_increment + ) + if not (wrist_min <= wrist_position_increments <= wrist_max): + wrist_position_deg = STARBackend._iswap_wrist_drive_increments_to_angle( + wrist_position_increments, wrist_deg_per_increment + ) + min_deg = STARBackend._iswap_wrist_drive_increments_to_angle( + wrist_min, wrist_deg_per_increment + ) + max_deg = STARBackend._iswap_wrist_drive_increments_to_angle( + wrist_max, wrist_deg_per_increment + ) + raise ValueError( + f"angle {angle} ({wrist_position_deg:+.2f} deg) is outside the hardware " + f"range [{min_deg:+.2f}, {max_deg:+.2f}] deg" + ) + rotation_position_increments = await self._request_iswap_rotation_drive_position_increments() + + await self._iswap_rotate_increments( + rotation_position_increments=rotation_position_increments, + wrist_position_increments=wrist_position_increments, + wrist_speed=speed, + wrist_acceleration=acceleration, + wrist_current_limit=current_limit, + ) + + # ----------------------------------------------------------------------- + # iSWAP: Forward Kinematics + # ----------------------------------------------------------------------- + + @staticmethod + def _iswap_fk( + joints: Dict["STARBackend.iSWAPAxis", float], + link_1_length: float, + link_2_length: float, + wrist_straight_angle: float, + ) -> CartesianCoords: + """Pure forward-kinematics map: joint state -> gripper pose (no I/O). + + Takes an `iSWAPAxis`-keyed joint dict and the calibrated link lengths / + STRAIGHT angle, returns just the gripper-frame pose (grip-center location + + yaw). No intermediate frames in the return; callers that need the wrist + XY can recompute trivially from joints + L1. + + Sign convention follows right-hand rule about +Z (CCW positive looking + down). Yaw is the deck-frame direction of link 2: + `alpha_2 = (W - 90) + (T - T_STRAIGHT)`, with 0 deg = +x deck-right. + Z: rotation-drive-bottom Z minus `iswap_rotation_drive_z_offset_above_finger_mm` + (13 mm). + """ + rotation_drive_angle = joints[STARBackend.iSWAPAxis.ROTATION] + wrist_drive_angle = joints[STARBackend.iSWAPAxis.WRIST] + base_x = joints[STARBackend.iSWAPAxis.X] + base_y = joints[STARBackend.iSWAPAxis.Y] + base_z = joints[STARBackend.iSWAPAxis.Z] + + link_1_deck_angle = rotation_drive_angle - 90.0 + link_2_deck_angle = link_1_deck_angle + (wrist_drive_angle - wrist_straight_angle) + + alpha_1_rad = math.radians(link_1_deck_angle) + alpha_2_rad = math.radians(link_2_deck_angle) + + grip_x = base_x + link_1_length * math.cos(alpha_1_rad) + link_2_length * math.cos(alpha_2_rad) + grip_y = base_y + link_1_length * math.sin(alpha_1_rad) + link_2_length * math.sin(alpha_2_rad) + grip_z = base_z - STARBackend.iswap_rotation_drive_z_offset_above_finger_mm + + return CartesianCoords( + location=Coordinate(x=grip_x, y=grip_y, z=grip_z), + rotation=Rotation(z=link_2_deck_angle), + ) + + async def iswap_request_joint_state(self) -> Dict[int, float]: + """Snapshot of the iSWAP's current joint state, keyed by `iSWAPAxis`. + + Composes the per-axis request methods into a single read-through dict. + Units are axis-implicit (see `iSWAPAxis`): X/Y/Z/GRIPPER in mm, ROTATION + and WRIST in degrees. + + Raises: + RuntimeError: if iSWAP is not installed or if `setup()` has not run + (the rotation-drive angle reader needs the predefined-stop table). + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + + return { + STARBackend.iSWAPAxis.X: await self.iswap_rotation_drive_request_x(), + STARBackend.iSWAPAxis.Y: await self.iswap_rotation_drive_request_y(), + STARBackend.iSWAPAxis.Z: await self.iswap_rotation_drive_request_z(), + STARBackend.iSWAPAxis.ROTATION: await self.iswap_rotation_drive_request_angle(), + STARBackend.iSWAPAxis.WRIST: await self.iswap_wrist_drive_request_angle(), + STARBackend.iSWAPAxis.GRIPPER: await self.iswap_gripper_request_width(), + } + + async def iswap_request_pose(self) -> CartesianCoords: + """Compute the iSWAP gripper pose via forward kinematics. + + FK-based alternative to `request_iswap_position` (C0 QG), which is + firmware-state-dependent and only returns correct values after certain + preceding commands. Reads the joint state directly via + `iswap_request_joint_state` and runs `_iswap_fk` against the link lengths + cached during `setup()`. + + Returns: + `CartesianCoords` with `location` = grip-center deck coordinates (mm) and + `rotation.z` = gripper yaw (deg, deck-frame, 0 = +x; `rotation.x`/`.y` = 0 + since the gripper plane stays parallel to the deck). + + Raises: + RuntimeError: if iSWAP is not installed or if `setup()` has not populated + the wrist STRAIGHT calibration / cached link lengths. + """ + wrist_straight_increments = self.iswap_information.wrist_drive_predefined_increments[ + STARBackend.WristDriveOrientation.STRAIGHT + ] + wrist_straight_angle = STARBackend._iswap_wrist_drive_increments_to_angle( + wrist_straight_increments, self.iswap_information.wrist_deg_per_increment + ) + + joints = { + STARBackend.iSWAPAxis(k): v for k, v in (await self.iswap_request_joint_state()).items() + } + + return STARBackend._iswap_fk( + joints=joints, + link_1_length=self.iswap_information.link_1_length, + link_2_length=self.iswap_information.link_2_length, + wrist_straight_angle=wrist_straight_angle, + ) + + # ----------------------------------------------------------------------- + # iSWAP: Combined Rotation-Wrist Moves + # ----------------------------------------------------------------------- + + async def _iswap_rotate_increments( + self, + rotation_position_increments: int, # units: increments + wrist_position_increments: int, # units: increments + rotation_speed: int = 25_000, # units: increments/sec + wrist_speed: int = 20_000, # units: increments/sec + rotation_acceleration: int = 170, # units: 1000 increments/sec^2 + wrist_acceleration: int = 145, # units: 1000 increments/sec^2 + rotation_current_limit: int = 5, + wrist_current_limit: int = 5, + ) -> None: + """Absolute parallel move of rotation (Joint 1) + wrist (Joint 2) drives. + + Args: + rotation_position_increments: signed destination, range -30032..+30032. + wrist_position_increments: signed destination, range -30000..+30000. + rotation_speed [increments/sec]: max velocity, range 20..75000. + wrist_speed [increments/sec]: max velocity, range 20..65000. + rotation_acceleration [1000 increments/sec^2]: range 5..200. + wrist_acceleration [1000 increments/sec^2]: range 5..200. + rotation_current_limit: current protection limiter, range 0..7. + wrist_current_limit: current protection limiter, range 0..7. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + + rotation_min, rotation_max = self.iswap_information.rotation_increment_range + wrist_min, wrist_max = self.iswap_information.wrist_increment_range + if not (rotation_min <= rotation_position_increments <= rotation_max): + raise ValueError( + f"rotation_position_increments must be between " + f"{rotation_min} and " + f"{rotation_max}; got {rotation_position_increments}" + ) + if not (wrist_min <= wrist_position_increments <= wrist_max): + raise ValueError( + f"wrist_position_increments must be between " + f"{wrist_min} and " + f"{wrist_max}; got {wrist_position_increments}" + ) + + if not 20 <= rotation_speed <= 75000: + raise ValueError(f"rotation_speed must be between 20 and 75000; got {rotation_speed}") + if not 20 <= wrist_speed <= 65000: + raise ValueError(f"wrist_speed must be between 20 and 65000; got {wrist_speed}") + if not 5 <= rotation_acceleration <= 200: + raise ValueError( + f"rotation_acceleration must be between 5 and 200; got {rotation_acceleration}" + ) + if not 5 <= wrist_acceleration <= 200: + raise ValueError(f"wrist_acceleration must be between 5 and 200; got {wrist_acceleration}") + if not 0 <= rotation_current_limit <= 7: + raise ValueError( + f"rotation_current_limit must be between 0 and 7; got {rotation_current_limit}" + ) + if not 0 <= wrist_current_limit <= 7: + raise ValueError(f"wrist_current_limit must be between 0 and 7; got {wrist_current_limit}") + + await self.send_command( + module="R0", + command="PA", + wa=f"{rotation_position_increments:+06}", + wv=f"{rotation_speed:05}", + wr=f"{rotation_acceleration:03}", + ww=f"{rotation_current_limit}", + ta=f"{wrist_position_increments:+06}", + tv=f"{wrist_speed:05}", + tr=f"{wrist_acceleration:03}", + tw=f"{wrist_current_limit}", + ) + + async def iswap_rotate_to_angles( + self, + rotation_angle: Optional[Union[RotationDriveOrientation, float]] = None, + wrist_angle: Optional[Union[WristDriveOrientation, float]] = None, + rotation_speed: float = 75.0, + rotation_acceleration: float = 500.0, + rotation_current_limit: int = 5, + wrist_speed: float = 100.0, + wrist_acceleration: float = 725.0, + wrist_current_limit: int = 5, + ) -> None: + """Rotate one or both iSWAP joints to absolute angles in a single motion. + + Public deg-based wrapper around `_iswap_rotate_increments`. When both + angles are supplied, both joints arrive together under a single motion + plan so the gripper sweeps a straight joint-space path; enables IK-driven + trajectory execution. + + When only one angle is supplied, the other drive is requested from device + (i.e. single-axis rotation is covered as well). + At least one of `rotation_angle` or `wrist_angle` must be provided. + + Each angle is either the enum stop (lands on the EEPROM increment) or a + float in degrees: rotation floats interpolate piecewise-linearly between + the LEFT / FRONT / RIGHT EEPROM stops, wrist floats are linear from motor + zero. Speed and acceleration convert linearly for both drives. + + Snap-to-stop: float angles within one motor increment of a calibrated + stop's deg-form land on the exact stored increment, so values read via + `iswap_*_drive_request_angle` round-trip bit-exact. + + Args: + rotation_angle [deg]: predefined `RotationDriveOrientation` enum, or float + signed from FRONT (+/-90), or None to hold current. + wrist_angle [deg]: predefined `WristDriveOrientation` enum, or float signed + from motor zero (+/-152), or None to hold current. + rotation_speed [deg/sec]: max angular velocity, 0.1..230. + rotation_acceleration [deg/sec^2]: max angular acceleration, 16..619. + rotation_current_limit: motor current protection limiter, 0..7. + wrist_speed [deg/sec]: max angular velocity, 0.2..330. + wrist_acceleration [deg/sec^2]: max angular acceleration, 26..1015. + wrist_current_limit: motor current protection limiter, 0..7. + + Raises: + RuntimeError: if iSWAP is not installed or if `setup()` has not populated + the predefined-stop tables. + ValueError: if neither angle is provided, or if either resolved target + increment is outside the hardware range. + """ + if rotation_angle is None and wrist_angle is None: + raise ValueError( + "iswap_rotate_to_angles requires at least one of `rotation_angle` or " + "`wrist_angle` to be provided; both are None" + ) + + if rotation_angle is None: + rotation_position_increments = await self._request_iswap_rotation_drive_position_increments() + else: + rot_predefined = self.iswap_information.rotation_drive_predefined_increments + rotation_min, rotation_max = self.iswap_information.rotation_increment_range + rotation_position_increments = STARBackend._iswap_rotation_drive_resolve_to_increments( + rotation_angle, rot_predefined, self.iswap_information.rotation_deg_per_increment + ) + if not (rotation_min <= rotation_position_increments <= rotation_max): + rotation_position_deg = STARBackend._iswap_rotation_drive_increments_to_angle( + rotation_position_increments, rot_predefined + ) + raise ValueError( + f"rotation_angle {rotation_angle} maps to {rotation_position_increments} incr " + f"({rotation_position_deg:.2f} deg) (stops LEFT/FRONT/RIGHT=" + f"{rot_predefined[STARBackend.RotationDriveOrientation.LEFT]}/" + f"{rot_predefined[STARBackend.RotationDriveOrientation.FRONT]}/" + f"{rot_predefined[STARBackend.RotationDriveOrientation.RIGHT]}), " + f"outside hardware range [{rotation_min}, " + f"{rotation_max}]" + ) + + if wrist_angle is None: + wrist_position_increments = await self._request_iswap_wrist_drive_position_increments() + else: + wrist_predefined = self.iswap_information.wrist_drive_predefined_increments + wrist_deg_per_increment = self.iswap_information.wrist_deg_per_increment + wrist_min, wrist_max = self.iswap_information.wrist_increment_range + wrist_position_increments = STARBackend._iswap_wrist_drive_resolve_to_increments( + wrist_angle, wrist_predefined, wrist_deg_per_increment + ) + if not (wrist_min <= wrist_position_increments <= wrist_max): + wrist_position_deg = STARBackend._iswap_wrist_drive_increments_to_angle( + wrist_position_increments, wrist_deg_per_increment + ) + min_deg = STARBackend._iswap_wrist_drive_increments_to_angle( + wrist_min, wrist_deg_per_increment + ) + max_deg = STARBackend._iswap_wrist_drive_increments_to_angle( + wrist_max, wrist_deg_per_increment + ) + raise ValueError( + f"wrist_angle {wrist_angle} ({wrist_position_deg:+.2f} deg) is outside " + f"the hardware range [{min_deg:+.2f}, {max_deg:+.2f}] deg" + ) + + await self._iswap_rotate_increments( + rotation_position_increments=rotation_position_increments, + wrist_position_increments=wrist_position_increments, + rotation_speed=round(rotation_speed / self.iswap_information.rotation_deg_per_increment), + rotation_acceleration=round( + rotation_acceleration / self.iswap_information.rotation_deg_per_increment / 1000 + ), + rotation_current_limit=rotation_current_limit, + wrist_speed=round(wrist_speed / self.iswap_information.wrist_deg_per_increment), + wrist_acceleration=round( + wrist_acceleration / self.iswap_information.wrist_deg_per_increment / 1000 + ), + wrist_current_limit=wrist_current_limit, + ) + + # ----------------------------------------------------------------------- + # iSWAP: Gripper + # ----------------------------------------------------------------------- + + @staticmethod + def iswap_gripper_drive_increment_to_mm( + value_increments: int, mm_per_increment: float = iSWAPInformation.gripper_mm_per_increment + ) -> float: + return round(value_increments * mm_per_increment, 1) + + @staticmethod + def iswap_gripper_drive_mm_to_increment( + value_mm: float, mm_per_increment: float = iSWAPInformation.gripper_mm_per_increment + ) -> int: + return round(value_mm / mm_per_increment) + + async def iswap_gripper_request_width(self) -> float: + """Request the current iSWAP gripper jaw opening width, in mm. + + RG is always available and reads the raw drive encoder. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + + resp = await self.send_command(module="R0", command="RG", fmt="rg##### (n)") + actual_increments = resp["rg"][1] # rg returns [target, actual]; we want actual + + return STARBackend.iswap_gripper_drive_increment_to_mm( + actual_increments, self.iswap_information.gripper_mm_per_increment + ) + + async def iswap_gripper_request_predefined_positions(self) -> Dict[str, int]: + """Read the iSWAP gripper drive (G) predefined-position table. + + Keys (motor increments; G-drive resolution 0.00554 mm/incr): + "home" pg[0] - home & parking + "fully_open" pg[1] - default 24120 = max jaw width + "closed" pg[2] - gripper closed + "plate_type_1" pg[3] - grip plate type 1 + "plate_type_2" pg[4] - grip plate type 2 + "plate_type_3" pg[5] - grip plate type 3 + "plate_type_4" pg[6] - grip plate type 4 + "plate_type_5" pg[7] - grip plate type 5 + "plate_type_6" pg[8] - grip plate type 6 + "plate_type_7" pg[9] - grip plate type 7 + + Raises: + RuntimeError: if the iSWAP module is not installed. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RA", ra="pg", fmt="pg##### (n)") + pg = cast(List[int], resp["pg"]) + return { + "home": pg[0], + "fully_open": pg[1], + "closed": pg[2], + "plate_type_1": pg[3], + "plate_type_2": pg[4], + "plate_type_3": pg[5], + "plate_type_4": pg[6], + "plate_type_5": pg[7], + "plate_type_6": pg[8], + "plate_type_7": pg[9], + } + + async def request_plate_in_iswap(self) -> bool: + """Request plate in iSWAP + + Returns: + True if holding a plate, False otherwise. + """ + + resp = await self.send_command(module="C0", command="QP", fmt="ph#") + return resp is not None and resp["ph"] == 1 + + async def open_not_initialized_gripper(self): + """Initialize the iSWAP gripper drive (C0 GI). + + Required if the gripper drive hasn't been initialized yet. After init, + the drive sits in a known position from which subsequent open/close + commands can operate. + """ + return await self.send_command(module="C0", command="GI") + + async def iswap_open_gripper(self, open_position: Optional[float] = None): + """Open gripper + + Args: + open_position: Open position [mm] (0.1 mm = 16 increments) The gripper moves to pos + 20. + Must be between 0 and 9999. Default 1320 for iSWAP 4.0 (landscape). Default to + 910 for iSWAP 3 (portrait). + """ + + if open_position is None: + open_position = 91.0 if (await self.get_iswap_version()).startswith("3") else 132.0 + + gripper_mm_per_increment = self.iswap_information.gripper_mm_per_increment + min_incr, max_incr = self.iswap_information.gripper_increment_range + min_width = STARBackend.iswap_gripper_drive_increment_to_mm(min_incr, gripper_mm_per_increment) + max_width = STARBackend.iswap_gripper_drive_increment_to_mm(max_incr, gripper_mm_per_increment) + if not (min_width <= open_position <= max_width): + raise ValueError( + f"open_position must be between {min_width} and {max_width} mm, got {open_position}" + ) + + return await self.send_command(module="C0", command="GF", go=f"{round(open_position * 10):04}") + + async def iswap_close_gripper( + self, + grip_strength: int = 5, + plate_width: float = 86.0, + plate_width_tolerance: float = 2.0, + ): + """Close gripper + + The gripper should be at the position plate_width+plate_width_tolerance+2.0mm before sending this command. + + Args: + grip_strength: Grip strength. 0 = low . 9 = high. Default 5. + plate_width: Plate width [mm] (gb should be > min. Pos. + stop ramp + gt -> gb > 760 + 5 + g ). + Default 86.0 (SBS short side, matching `iswap_get_plate`'s 860 in 0.1 mm units). + plate_width_tolerance: Plate width tolerance [mm]. Must be between 0.5 and 9.9. Default 2.0. + """ + + assert 0 <= grip_strength <= 9, "grip_strength must be between 0 and 9" + # Lower bound 76.0 is the firmware minimum (min position + stop ramp + grip + # tolerance, per the docstring), stricter than the physical jaw minimum. Upper + # bound is the physical jaw maximum derived from gripper_increment_range. + max_incr = self.iswap_information.gripper_increment_range[1] + max_width = STARBackend.iswap_gripper_drive_increment_to_mm( + max_incr, self.iswap_information.gripper_mm_per_increment + ) + if not (76.0 < plate_width <= max_width): + raise ValueError(f"plate_width must be between 76.0 and {max_width} mm, got {plate_width}") + assert 0.5 <= plate_width_tolerance <= 9.9, "plate_width_tolerance must be between 0.5 and 9.9" + + return await self.send_command( + module="C0", + command="GC", + gw=grip_strength, + gb=f"{round(plate_width * 10):04}", + gt=f"{round(plate_width_tolerance * 10):02}", + ) + + # -------------- 3.17.2 Stack handling commands CP -------------- + + async def park_iswap( + self, + minimum_traverse_height_at_beginning_of_a_command: int = 2840, + ): + """Close gripper + + The gripper should be at the position gb+gt+20 before sending this command. + + Args: + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning + of a command [0.1mm]. Must be between 0 and 3600. Default 3600. + """ + + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + ) + + command_output = await self.send_command( + module="C0", + command="PG", + th=minimum_traverse_height_at_beginning_of_a_command, + ) + + # Once the command has completed successfully, set _iswap_parked to True + self._iswap_parked = True + return command_output + + async def iswap_get_plate( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + y_direction: int = 0, + z_position: int = 0, + z_direction: int = 0, + grip_direction: int = 1, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + z_position_at_the_command_end: int = 3600, + grip_strength: int = 5, + open_gripper_position: int = 860, + plate_width: int = 860, + plate_width_tolerance: int = 860, + collision_control_level: int = 1, + acceleration_index_high_acc: int = 4, + acceleration_index_low_acc: int = 1, + iswap_fold_up_sequence_at_the_end_of_process: bool = False, + ): + """Get plate using iswap. + + Args: + x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. + y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. + z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y, + 4 =negative X. Must be between 1 and 4. Default 1. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of + a command 0.1mm]. Must be between 0 and 3600. Default 3600. + z_position_at_the_command_end: Z-Position at the command end [0.1mm]. Must be between 0 + and 3600. Default 3600. + grip_strength: Grip strength 0 = low .. 9 = high. Must be between 1 and 9. Default 5. + open_gripper_position: Open gripper position [0.1mm]. Must be between 0 and 9999. + Default 860. + plate_width: plate width [0.1mm]. Must be between 0 and 9999. Default 860. + plate_width_tolerance: plate width tolerance [0.1mm]. Must be between 0 and 99. Default 860. + collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. + Default 1. + acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4. Default 4. + acceleration_index_low_acc: acceleration index high acc. Must be between 0 and 4. Default 1. + iswap_fold_up_sequence_at_the_end_of_process: fold up sequence at the end of process. Default False. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" + assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" + assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" + assert 1 <= grip_direction <= 4, "grip_direction must be between 1 and 4" + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + ) + assert 0 <= z_position_at_the_command_end <= 3600, ( + "z_position_at_the_command_end must be between 0 and 3600" + ) + assert 1 <= grip_strength <= 9, "grip_strength must be between 1 and 9" + assert 0 <= open_gripper_position <= 9999, "open_gripper_position must be between 0 and 9999" + assert 0 <= plate_width <= 9999, "plate_width must be between 0 and 9999" + assert 0 <= plate_width_tolerance <= 99, "plate_width_tolerance must be between 0 and 99" + assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" + assert 0 <= acceleration_index_high_acc <= 4, ( + "acceleration_index_high_acc must be between 0 and 4" + ) + assert 0 <= acceleration_index_low_acc <= 4, ( + "acceleration_index_low_acc must be between 0 and 4" + ) + + command_output = await self.send_command( + module="C0", + command="PP", + xs=f"{x_position:05}", + xd=x_direction, + yj=f"{y_position:04}", + yd=y_direction, + zj=f"{z_position:04}", + zd=z_direction, + gr=grip_direction, + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + te=f"{z_position_at_the_command_end:04}", + gw=grip_strength, + go=f"{open_gripper_position:04}", + gb=f"{plate_width:04}", + gt=f"{plate_width_tolerance:02}", + ga=collision_control_level, + # xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}", + gc=iswap_fold_up_sequence_at_the_end_of_process, + ) + + # Once the command has completed successfully, set _iswap_parked to false + self._iswap_parked = False + return command_output + + async def iswap_put_plate( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + y_direction: int = 0, + z_position: int = 0, + z_direction: int = 0, + grip_direction: int = 1, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + z_position_at_the_command_end: int = 3600, + open_gripper_position: int = 860, + collision_control_level: int = 1, + acceleration_index_high_acc: int = 4, + acceleration_index_low_acc: int = 1, + iswap_fold_up_sequence_at_the_end_of_process: bool = False, + ): + """put plate + + Args: + x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. + y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. + z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y, 4 = negative + X. Must be between 1 and 4. Default 1. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command 0.1mm]. Must be between 0 and 3600. Default 3600. + z_position_at_the_command_end: Z-Position at the command end [0.1mm]. Must be between 0 and + 3600. Default 3600. + open_gripper_position: Open gripper position [0.1mm]. Must be between 0 and 9999. Default + 860. + collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. + Default 1. + acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4. + Default 4. + acceleration_index_low_acc: acceleration index high acc. Must be between 0 and 4. + Default 1. + iswap_fold_up_sequence_at_the_end_of_process: fold up sequence at the end of process. Default False. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" + assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" + assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" + assert 1 <= grip_direction <= 4, "grip_direction must be between 1 and 4" + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + ) + assert 0 <= z_position_at_the_command_end <= 3600, ( + "z_position_at_the_command_end must be between 0 and 3600" + ) + assert 0 <= open_gripper_position <= 9999, "open_gripper_position must be between 0 and 9999" + assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" + assert 0 <= acceleration_index_high_acc <= 4, ( + "acceleration_index_high_acc must be between 0 and 4" + ) + assert 0 <= acceleration_index_low_acc <= 4, ( + "acceleration_index_low_acc must be between 0 and 4" + ) + + command_output = await self.send_command( + module="C0", + command="PR", + xs=f"{x_position:05}", + xd=x_direction, + yj=f"{y_position:04}", + yd=y_direction, + zj=f"{z_position:04}", + zd=z_direction, + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + te=f"{z_position_at_the_command_end:04}", + gr=grip_direction, + go=f"{open_gripper_position:04}", + ga=collision_control_level, + # xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}" + gc=iswap_fold_up_sequence_at_the_end_of_process, + ) + + # Once the command has completed successfully, set _iswap_parked to false + self._iswap_parked = False + return command_output + + async def iswap_rotate( + self, + rotation_drive: "RotationDriveOrientation", + grip_direction: GripDirection, + gripper_velocity: int = 55_000, + gripper_acceleration: int = 170, + gripper_protection: Literal[0, 1, 2, 3, 4, 5, 6, 7] = 5, + wrist_velocity: int = 48_000, + wrist_acceleration: int = 145, + wrist_protection: Literal[0, 1, 2, 3, 4, 5, 6, 7] = 5, + ): + """ + Rotate the iswap to a predefined position. + Velocity units are "incr/sec" + Acceleration units are "1_000 incr/sec**2" + For a list of the possible positions see the pylabrobot documentation on the R0 module. + """ + assert 20 <= gripper_velocity <= 75_000 + assert 5 <= gripper_acceleration <= 200 + assert 20 <= wrist_velocity <= 65_000 + assert 20 <= wrist_acceleration <= 200 + + position = 0 + + if rotation_drive == STARBackend.RotationDriveOrientation.LEFT: + position += 10 + elif rotation_drive == STARBackend.RotationDriveOrientation.FRONT: + position += 20 + elif rotation_drive == STARBackend.RotationDriveOrientation.RIGHT: + position += 30 + else: + raise ValueError(f"Invalid rotation drive orientation: {rotation_drive}") + + if grip_direction == GripDirection.FRONT: + position += 1 + elif grip_direction == GripDirection.RIGHT: + position += 2 + elif grip_direction == GripDirection.BACK: + position += 3 + elif grip_direction == GripDirection.LEFT: + position += 4 + else: + raise ValueError("Invalid grip direction") + + return await self.send_command( + module="R0", + command="PD", + pd=position, + wv=f"{gripper_velocity:05}", + wr=f"{gripper_acceleration:03}", + ww=gripper_protection, + tv=f"{wrist_velocity:05}", + tr=f"{wrist_acceleration:03}", + tw=wrist_protection, + ) + + async def iswap_dangerous_release_break(self): + return await self.send_command(module="R0", command="BA") + + async def iswap_reengage_break(self): + return await self.send_command(module="R0", command="BO") + + async def iswap_initialize_z_axis(self): + return await self.send_command(module="R0", command="ZI") + + async def move_plate_to_position( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + y_direction: int = 0, + z_position: int = 0, + z_direction: int = 0, + grip_direction: int = 1, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + collision_control_level: int = 1, + acceleration_index_high_acc: int = 4, + acceleration_index_low_acc: int = 1, + ): + """Move plate to position. + + Args: + x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. + y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. + z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y, 4 = negative + X. Must be between 1 and 4. Default 1. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command 0.1mm]. Must be between 0 and 3600. Default 3600. + collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. + Default 1. + acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4. Default 4. + acceleration_index_low_acc: acceleration index low acc. Must be between 0 and 4. Default 1. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" + assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" + assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" + assert 1 <= grip_direction <= 4, "grip_direction must be between 1 and 4" + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + ) + assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" + assert 0 <= acceleration_index_high_acc <= 4, ( + "acceleration_index_high_acc must be between 0 and 4" + ) + assert 0 <= acceleration_index_low_acc <= 4, ( + "acceleration_index_low_acc must be between 0 and 4" + ) + + command_output = await self.send_command( + module="C0", + command="PM", + xs=f"{x_position:05}", + xd=x_direction, + yj=f"{y_position:04}", + yd=y_direction, + zj=f"{z_position:04}", + zd=z_direction, + gr=grip_direction, + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + ga=collision_control_level, + xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}", + ) + # Once the command has completed successfully, set _iswap_parked to false + self._iswap_parked = False + return command_output + + async def collapse_gripper_arm( + self, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + iswap_fold_up_sequence_at_the_end_of_process: bool = False, + ): + """Collapse gripper arm + + Args: + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command 0.1mm]. Must be between 0 and 3600. + Default 3600. + iswap_fold_up_sequence_at_the_end_of_process: fold up sequence at the end of process. Default False. + """ + + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + ) + + return await self.send_command( + module="C0", + command="PN", + th=minimum_traverse_height_at_beginning_of_a_command, + gc=iswap_fold_up_sequence_at_the_end_of_process, + ) + + # -------------- 3.17.3 Hotel handling commands -------------- + + # implemented in UnSafe class + + # -------------- 3.17.4 Barcode commands -------------- + + # TODO:(command:PB) Read barcode using iSWAP + + # -------------- 3.17.5 Teach in commands -------------- + + async def prepare_iswap_teaching( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + y_direction: int = 0, + z_position: int = 0, + z_direction: int = 0, + location: int = 0, + hotel_depth: int = 1300, + grip_direction: int = 1, + minimum_traverse_height_at_beginning_of_a_command: int = 3600, + collision_control_level: int = 1, + acceleration_index_high_acc: int = 4, + acceleration_index_low_acc: int = 1, + ): + """Prepare iSWAP teaching + + Prepare for teaching with iSWAP + + Args: + x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. + y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. + z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + location: location. 0 = Stack 1 = Hotel. Must be between 0 and 1. Default 0. + hotel_depth: Hotel depth [0.1mm]. Must be between 0 and 3000. Default 1300. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of + a command 0.1mm]. Must be between 0 and 3600. Default 3600. + collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. + Default 1. + acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4. Default 4. + acceleration_index_low_acc: acceleration index high acc. Must be between 0 and 4. Default 1. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" + assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" + assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" + assert 0 <= location <= 1, "location must be between 0 and 1" + assert 0 <= hotel_depth <= 3000, "hotel_depth must be between 0 and 3000" + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( + "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" + ) + assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" + assert 0 <= acceleration_index_high_acc <= 4, ( + "acceleration_index_high_acc must be between 0 and 4" + ) + assert 0 <= acceleration_index_low_acc <= 4, ( + "acceleration_index_low_acc must be between 0 and 4" + ) + + return await self.send_command( + module="C0", + command="PT", + xs=f"{x_position:05}", + xd=x_direction, + yj=f"{y_position:04}", + yd=y_direction, + zj=f"{z_position:04}", + zd=z_direction, + hh=location, + hd=f"{hotel_depth:04}", + gr=grip_direction, + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + ga=collision_control_level, + xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}", + ) + + async def get_logic_iswap_position( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + y_direction: int = 0, + z_position: int = 0, + z_direction: int = 0, + location: int = 0, + hotel_depth: int = 1300, + grip_direction: int = 1, + collision_control_level: int = 1, + ): + """Get logic iSWAP position + + Args: + x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. + x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. + y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. + z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. + location: location. 0 = Stack 1 = Hotel. Must be between 0 and 1. Default 0. + hotel_depth: Hotel depth [0.1mm]. Must be between 0 and 3000. Default 1300. + grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y, + 4 = negative X. Must be between 1 and 4. Default 1. + collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. + Default 1. + """ + + assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" + assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" + assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" + assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" + assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" + assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" + assert 0 <= location <= 1, "location must be between 0 and 1" + assert 0 <= hotel_depth <= 3000, "hotel_depth must be between 0 and 3000" + assert 1 <= grip_direction <= 4, "grip_direction must be between 1 and 4" + assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" + + return await self.send_command( + module="C0", + command="PC", + xs=x_position, + xd=x_direction, + yj=y_position, + yd=y_direction, + zj=z_position, + zd=z_direction, + hh=location, + hd=hotel_depth, + gr=grip_direction, + ga=collision_control_level, + ) + + # -------------- 3.17.6 iSWAP query -------------- + + async def request_iswap_in_parking_position(self): + """Request iSWAP in parking position + + Returns: + 0 = gripper is not in parking position + 1 = gripper is in parking position + """ + + return await self.send_command(module="C0", command="RG", fmt="rg#") + + async def request_iswap_position(self) -> Coordinate: + """Request iSWAP gripper finger center position. + + Returns: + xs: Gripper finger center in X direction [1mm] + xd: X direction 0 = positive 1 = negative + yj: Gripper finger center in Y direction [1mm] + yd: Y direction 0 = positive 1 = negative + zj: Gripper finger center Z height [1mm] + zd: Z direction 0 = positive 1 = negative + """ + + resp = await self.send_command(module="C0", command="QG", fmt="xs#####xd#yj####yd#zj####zd#") + return Coordinate( + x=(resp["xs"] / 10) * (1 if resp["xd"] == 0 else -1), + y=(resp["yj"] / 10) * (1 if resp["yd"] == 0 else -1), + z=(resp["zj"] / 10) * (1 if resp["zd"] == 0 else -1), + ) + + async def request_iswap_initialization_status(self) -> bool: + """Request iSWAP initialization status + + Returns: + True if iSWAP is fully initialized + """ + + resp = await self.send_command(module="R0", command="QW", fmt="qw#") + return cast(int, resp["qw"]) == 1 + + async def request_iswap_version(self) -> str: + """Firmware command for getting iswap version""" + return cast(str, (await self.send_command("R0", "RF", fmt="rf" + "&" * 15))["rf"]) + + async def measure_iswap_gripper_force(self) -> float: + """Measure the force currently exerted by the iSWAP gripper, in Newtons. + + Sends R0 RH (request gripper current and force sensor). The firmware + returns 5 fields; the last is the calibrated force in mN, which this + method converts to N. Useful for closed-loop grip verification, + grip-slip detection, and adaptive grip-strength tuning. + """ + if not self.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.send_command(module="R0", command="RH") + # Response: rh#### #### #### #### ##### + # Fields: max drive current, max force during movement, idle offset, + # last measured (all AD values), and force in mN (firmware-calibrated). + match = re.search(r"rh\s*-?\d+\s+-?\d+\s+-?\d+\s+-?\d+\s+(-?\d+)", resp or "") + if match is None: + raise RuntimeError(f"unexpected RH response: {resp!r}") + return round(int(match.group(1)) / 1000.0, 3) + + # -------------- 3.18 Cover and port control -------------- + + async def lock_cover(self): + """Lock cover""" + + return await self.send_command(module="C0", command="CO") + + async def unlock_cover(self): + """Unlock cover""" + + return await self.send_command(module="C0", command="HO") + + async def disable_cover_control(self): + """Disable cover control""" + + return await self.send_command(module="C0", command="CD") + + async def enable_cover_control(self): + """Enable cover control""" + + return await self.send_command(module="C0", command="CE") + + async def set_cover_output(self, output: int = 0): + """Set cover output + + Args: + output: 1 = cover lock; 2 = reserve out; 3 = reserve out. + """ + + assert 1 <= output <= 3, "output must be between 1 and 3" + return await self.send_command(module="C0", command="OS", on=output) + + async def reset_output(self, output: int = 0): + """Reset output + + Returns: + output: 1 = cover lock; 2 = reserve out; 3 = reserve out. + """ + + assert 1 <= output <= 3, "output must be between 1 and 3" + return await self.send_command(module="C0", command="QS", on=output, fmt="#") + + async def request_cover_open(self) -> bool: + """Request cover open + + Returns: True if the cover is open + """ + + resp = await self.send_command(module="C0", command="QC", fmt="qc#") + return bool(resp["qc"]) + + # -------------- Extra - Probing labware with STAR - making STAR into a CMM -------------- + + y_drive_mm_per_increment = 0.046302083 + z_drive_mm_per_increment = 0.01072765 + + dispensing_drive_vol_per_increment = 0.046876 # uL / increment + dispensing_drive_mm_per_increment = 0.002734375 + + @staticmethod + def mm_to_y_drive_increment(value_mm: float) -> int: + return round(value_mm / STARBackend.y_drive_mm_per_increment) + + @staticmethod + def y_drive_increment_to_mm(value_mm: int) -> float: + return round(value_mm * STARBackend.y_drive_mm_per_increment, 2) + + @staticmethod + def mm_to_z_drive_increment(value_mm: float) -> int: + return round(value_mm / STARBackend.z_drive_mm_per_increment) + + @staticmethod + def z_drive_increment_to_mm(value_increments: int) -> float: + return round(value_increments * STARBackend.z_drive_mm_per_increment, 2) + + # Dispensing drive conversions + # --- uL <-> increments --- + @staticmethod + def dispensing_drive_vol_to_increment(volume: float) -> int: + return round(volume / STARBackend.dispensing_drive_vol_per_increment) + + @staticmethod + def dispensing_drive_increment_to_volume(position_increment: int) -> float: + return round(position_increment * STARBackend.dispensing_drive_vol_per_increment, 1) + + # --- mm <-> increments --- + @staticmethod + def dispensing_drive_mm_to_increment(position_mm: float) -> int: + return round(position_mm / STARBackend.dispensing_drive_mm_per_increment) + + @staticmethod + def dispensing_drive_increment_to_mm(position_increment: int) -> float: + return round(position_increment * STARBackend.dispensing_drive_mm_per_increment, 3) + + # --- uL <-> mm --- + @staticmethod + def dispensing_drive_vol_to_mm(vol: float) -> float: + inc = STARBackend.dispensing_drive_vol_to_increment(vol) + return STARBackend.dispensing_drive_increment_to_mm(inc) + + @staticmethod + def dispensing_drive_mm_to_vol(position_mm: float) -> float: + inc = STARBackend.dispensing_drive_mm_to_increment(position_mm) + return STARBackend.dispensing_drive_increment_to_volume(inc) + + async def clld_probe_x_position_using_channel( + self, + channel_idx: int, # 0-based indexing of channels! + probing_direction: Literal["right", "left"], + end_pos_search: Optional[float] = None, # mm + post_detection_dist: float = 2.0, # mm, + tip_bottom_diameter: float = 1.2, # mm + read_timeout=240.0, # seconds + ) -> float: + """ + Probe the x-position of a conductive material using a channel's capacitive liquid + level detection (cLLD) via a lateral X scan. + + Starting from the channel's current X position, the channel is moved laterally in + the specified direction using the XL command until cLLD triggers or the configured + end position is reached. After the scan, the channel is retracted inward by + `post_detection_dist`. + + The returned value is a first-order geometric estimate of the material boundary, + corrected by half the tip bottom diameter assuming cylindrical tip contact. + + Notes: + - The XL command does not report whether cLLD triggered; reaching the end position is indistinguishable from a successful detection. + - This function assumes cLLD triggers before `end_pos_search`. + + Preconditions: + - The channel must already be at a Z height safe for lateral X motion. + - The current X position must be consistent with `probing_direction`. + + Side effects: + - Moves the specified channel in X. + - Leaves the channel retracted from the detected object. + + Returns: + Estimated x-position of the detected material boundary in millimeters. + """ + + assert channel_idx in range(self.num_channels), ( + f"Channel index must be between 0 and {self.num_channels - 1}, is {channel_idx}." + ) + assert probing_direction in [ + "right", + "left", + ], f"Probing direction must be either 'right' or 'left', is {probing_direction}." + assert post_detection_dist >= 0.0, ( + f"Post-detection distance must be non-negative, is {post_detection_dist} mm." + "(always marks a movement away from the detected material)." + ) + + # TODO: Anti-channel-crash feature -> use self.deck with recursive logic + current_x_position = await self.request_x_pos_channel_n(channel_idx) + # y_position = await self.request_y_pos_channel_n(channel_idx) + # current_z_position = await self.request_z_pos_channel_n(channel_idx) + + # Use identified rail number to calculate possible upper limit: + # STAR = 95 - 1415 mm, STARlet = 95 - 800mm + num_rails = self.extended_conf.instrument_size_slots + track_width = 22.5 # mm + reachable_dist_to_last_rail = 125.0 + + max_safe_upper_x_pos = num_rails * track_width + reachable_dist_to_last_rail + max_safe_lower_x_pos = 95.0 # unit: mm + + if end_pos_search is None: + if probing_direction == "right": + end_pos_search = max_safe_upper_x_pos + else: # probing_direction == "left" + end_pos_search = max_safe_lower_x_pos + else: + assert max_safe_lower_x_pos <= end_pos_search <= max_safe_upper_x_pos, ( + f"End position for x search must be between " + f"{max_safe_lower_x_pos} and {max_safe_upper_x_pos} mm, " + f"is {end_pos_search} mm." + ) + + # Assert probing direction matches start and end positions + if probing_direction == "right": + assert current_x_position < end_pos_search, ( + f"Current position ({current_x_position} mm) must be less than " + + f"end position ({end_pos_search} mm) when probing right." + ) + else: # probing_direction == "left" + assert current_x_position > end_pos_search, ( + f"Current position ({current_x_position} mm) must be greater than " + + f"end position ({end_pos_search} mm) when probing left." + ) + + # Move channel in x until cLLD (Note: does not return detected x-position!) + await self.send_command( + module="C0", + command="XL", + xs=f"{int(round(end_pos_search * 10)):05}", + read_timeout=read_timeout, + ) + + sensor_triggered_x_pos = await self.request_x_pos_channel_n(channel_idx) + + # Move channel post-detection + if probing_direction == "left": + final_x_pos = sensor_triggered_x_pos + post_detection_dist + + # tip_bottom_diameter geometric correction assuming cylindrical tip contact + material_x_pos = sensor_triggered_x_pos - tip_bottom_diameter / 2 + + else: # probing_direction == "right" + final_x_pos = sensor_triggered_x_pos - post_detection_dist + + material_x_pos = sensor_triggered_x_pos + tip_bottom_diameter / 2 + + # Move away from detected object to avoid mechanical interference + # e.g. touch carrier, then carrier moves -> friction on channel! + await self.move_channel_x(x=final_x_pos, channel=channel_idx) + + return round(material_x_pos, 1) + + async def clld_probe_y_position_using_channel( + self, + channel_idx: int, # 0-based indexing of channels! + probing_direction: Literal["forward", "backward"], + start_pos_search: Optional[float] = None, # mm + end_pos_search: Optional[float] = None, # mm + channel_speed: float = 10.0, # mm/sec + channel_acceleration_int: Literal[1, 2, 3, 4] = 4, # * 5_000 steps/sec**2 == 926 mm/sec**2 + detection_edge: int = 10, + current_limit_int: Literal[1, 2, 3, 4, 5, 6, 7] = 7, + post_detection_dist: float = 2.0, # mm, + tip_bottom_diameter: float = 1.2, # mm + ) -> float: + """ + Probe the y-position of a conductive material using the channel's capacitive Liquid Level + Detection (cLLD). + + This method carefully moves a specified STAR channel along the y-axis to detect the presence + of a conductive surface. It uses STAR's built-in capacitive sensing to measure where the + needle tip first encounters the material, applying safety checks to prevent channel collisions + with adjacent channels. After detection, the channel is retracted by a configurable safe + distance (`post_detection_dist`) to avoid mechanical interference. + + By default, the parameter `tip_bottom_diameter` assumes STAR's **integrated teaching needles**, + which feature an extended, straight bottom section. The correction accounts for the needle's + geometry by adjusting the final reported material y-position to represent the material center + rather than the conductive detection edge. If you are using different tips or needle designs + (e.g., conical tips or third-party teaching needles), you should adapt the + `tip_bottom_diameter` value to reflect their actual geometry. + + Args: + channel_idx: Index of the channel to probe (0-based). The backmost channel is 0. + probing_direction: Direction of probing: + - "forward" decreases y-position, + - "backward" increases y-position. + start_pos_search: Initial y-position for the search (in mm). If not set, defaults to the current channel y-position. + end_pos_search: Final y-position for the search (in mm). If not set, defaults to the maximum safe travel range. + channel_speed: Channel movement speed during probing (mm/sec). Defaults to 10.0 mm/sec. + channel_acceleration_int: Acceleration ramp setting [1-4], where the physical acceleration is `value * 5,000 steps/sec**2`. Defaults to 4. + detection_edge: Edge steepness for capacitive detection [0-1024]. Defaults to 10. + current_limit_int: Current limit setting [1-7]. Defaults to 7. + post_detection_dist: Retraction distance after detection (in mm). Defaults to 2.0 mm. + tip_bottom_diameter: Effective diameter of the needle/tip bottom (in mm). Defaults to 1.2 mm, corresponding to STAR's integrated teaching needles. + + Returns: + The corrected y-position (in mm) of the detected conductive material, adjusted for the specified `tip_bottom_diameter`. + + Raises: + ValueError: + - If `probing_direction` is invalid. + - If `start_pos_search` or `end_pos_search` is outside the safe range. + - If the configured end position conflicts with the probing direction. + - If no conductive material is detected. + """ + + assert probing_direction in [ + "forward", + "backward", + ], f"Probing direction must be either 'forward' or 'backward', is {probing_direction}." + + # Anti-channel-crash feature + if channel_idx > 0: + adj_upper_y = await self.request_y_pos_channel_n(channel_idx - 1) + max_safe_upper_y_pos = adj_upper_y - self._min_spacing_between(channel_idx, channel_idx - 1) + else: + max_safe_upper_y_pos = self.extended_conf.pip_maximal_y_position + + if channel_idx < (self.num_channels - 1): + adj_lower_y = await self.request_y_pos_channel_n(channel_idx + 1) + max_safe_lower_y_pos = adj_lower_y + self._min_spacing_between(channel_idx, channel_idx + 1) + else: + max_safe_lower_y_pos = self.extended_conf.left_arm_min_y_position + + # Enable safe start and end positions + if start_pos_search: + assert max_safe_lower_y_pos <= start_pos_search <= max_safe_upper_y_pos, ( + f"Start position for y search must be between \n{max_safe_lower_y_pos} and " + + f"{max_safe_upper_y_pos} mm, is {end_pos_search} mm. Otherwise channel will crash." + ) + await self.move_channel_y(y=start_pos_search, channel=channel_idx) + + if end_pos_search: + assert max_safe_lower_y_pos <= end_pos_search <= max_safe_upper_y_pos, ( + f"End position for y search must be between \n{max_safe_lower_y_pos} and " + + f"{max_safe_upper_y_pos} mm, is {end_pos_search} mm. Otherwise channel will crash." + ) + + # Set safe y-search end position based on the probing direction + current_channel_y_pos = await self.request_y_pos_channel_n(channel_idx) + if probing_direction == "backward": + max_y_search_pos = end_pos_search or max_safe_upper_y_pos + if max_y_search_pos < current_channel_y_pos: + raise ValueError( + f"Channel {channel_idx} cannot move forward: " + f"End position = {max_y_search_pos} < current position = {current_channel_y_pos}" + f"\nDid you mean to move forward?" + ) + else: # probing_direction == "forward" + max_y_search_pos = end_pos_search or max_safe_lower_y_pos + if max_y_search_pos > current_channel_y_pos: + raise ValueError( + f"Channel {channel_idx} cannot move forward: " + f"End position = {max_y_search_pos} > current position = {current_channel_y_pos}" + f"\nDid you mean to move backward?" + ) + + # Convert mm to increments + max_y_search_pos_increments = STAR.mm_to_y_drive_increment(max_y_search_pos) + channel_speed_increments = STAR.mm_to_y_drive_increment(channel_speed) + + # Machine-compatibility check of calculated parameters + assert 0 <= max_y_search_pos_increments <= 13_714, ( + "Maximum y search position must be between 0 and " + + f"{STARBackend.y_drive_increment_to_mm(13_714):.1f} mm, " + + f"is {max_y_search_pos:.1f} mm" + ) + assert 20 <= channel_speed_increments <= 8_000, ( + f"LLD search speed must be between \n{STARBackend.y_drive_increment_to_mm(20)}" + + f"and {STARBackend.y_drive_increment_to_mm(8_000)} mm/sec, is {channel_speed} mm/sec" + ) + assert channel_acceleration_int in [1, 2, 3, 4], ( + "Channel speed must be in [1, 2, 3, 4] (* 5_000 steps/sec**2)" + + f", is {channel_speed} mm/sec" + ) + assert 0 <= detection_edge <= 1_023, ( + "Edge steepness at capacitive LLD detection must be between 0 and 1023" + ) + assert 0 <= current_limit_int <= 7, ( + f"Current limit must be in [0, 1, 2, 3, 4, 5, 6, 7], is {channel_speed} mm/sec" + ) + + # Move channel for cLLD (Note: does not return detected y-position!) + await self.send_command( + module=STARBackend.channel_id(channel_idx), + command="YL", + ya=f"{max_y_search_pos_increments:05}", # Maximum search position [steps] + gt=f"{detection_edge:04}", # Edge steepness at capacitive LLD detection + gl=f"{0:04}", # Offset after edge detection -> always 0 to measure y-pos! + yv=f"{channel_speed_increments:04}", # Max speed [steps/second] + yr=f"{channel_acceleration_int}", # Acceleration ramp [yr * 5_000 steps/second**2] + yw=f"{current_limit_int}", # Current limit + read_timeout=120, # default 30 seconds is often not enough + ) + + detected_material_y_pos = await self.request_y_pos_channel_n(channel_idx) + + # Dynamically evaluate post-detection distance to avoid crashes + if probing_direction == "backward": + if channel_idx < self.num_channels - 1: + min_y = await self.request_y_pos_channel_n(channel_idx + 1) + self._min_spacing_between( + channel_idx, channel_idx + 1 + ) + else: + min_y = self.extended_conf.left_arm_min_y_position + + max_safe_dist = detected_material_y_pos - min_y + move_target = detected_material_y_pos - min(post_detection_dist, max_safe_dist) + + else: # probing_direction == "forward" + if channel_idx > 0: + max_y = await self.request_y_pos_channel_n(channel_idx - 1) - self._min_spacing_between( + channel_idx, channel_idx - 1 + ) + else: + max_y = self.extended_conf.pip_maximal_y_position + + max_safe_dist = max_y - detected_material_y_pos + move_target = detected_material_y_pos + min(post_detection_dist, max_safe_dist) + + await self.move_channel_y(y=move_target, channel=channel_idx) + + # Correct for tip_bottom_diameter + if probing_direction == "backward": + material_y_pos = detected_material_y_pos + tip_bottom_diameter / 2 + else: # probing_direction == "forward" + material_y_pos = detected_material_y_pos - tip_bottom_diameter / 2 + + return round(material_y_pos, 1) + + async def _move_z_drive_to_liquid_surface_using_clld( + self, + channel_idx: int, # 0-based indexing of channels! + lowest_immers_pos: float = 99.98, # mm + start_pos_search: float = 334.7, # mm + channel_speed: float = 10.0, # mm + channel_acceleration: float = 800.0, # mm/sec**2 + detection_edge: int = 10, + detection_drop: int = 2, + post_detection_trajectory: Literal[0, 1] = 1, + post_detection_dist: float = 2.0, # mm + ): + """Move the tip on a channel to the liquid surface using capacitive LLD (cLLD). + + Runs a downward capacitive liquid-level detection (cLLD) search on the specified + 0-indexed channel. The search will not go below lowest_immers_pos. After detection, + the channel performs the configured post-detection move (by default retracting 2.0 mm). + + This is a low level method that takes parameters in "head space", not using the tip length. + + Args: + channel_idx: Channel index (0-based). + lowest_immers_pos: Lowest allowed search position in mm (hard stop). Defaults to 99.98. + start_pos_search: Search start position in mm. If None, computed from tip length. + channel_speed: Search speed in mm/s. Defaults to 10.0. + channel_acceleration: Search acceleration in mm/s^2. Defaults to 800.0. + detection_edge: Edge steepness threshold for cLLD detection (0-1023). Defaults to 10. + detection_drop: Offset applied after cLLD edge detection (0-1023). Defaults to 2. + post_detection_trajectory: Instrument post-detection move mode (0 or 1). Defaults to 1. + post_detection_dist: Distance in mm to move after detection (interpreted per trajectory). + Defaults to 2.0. + + Raises: + ValueError: If channel_idx is out of range. + RuntimeError: If no tip is mounted on channel_idx. + AssertionError: If any parameter is outside the instrument-supported range. + """ + + # Preconditions checks + # Ensure valid channel index + if not isinstance(channel_idx, int) or not (0 <= channel_idx <= self.num_channels - 1): + raise ValueError(f"channel_idx must be in [0, {self.num_channels - 1}], is {channel_idx}") + + # Conversions & machine-compatibility check of parameters + lowest_immers_pos_increments = STARBackend.mm_to_z_drive_increment(lowest_immers_pos) + start_pos_search_increments = STARBackend.mm_to_z_drive_increment(start_pos_search) + channel_speed_increments = STARBackend.mm_to_z_drive_increment(channel_speed) + channel_acceleration_thousand_increments = STARBackend.mm_to_z_drive_increment( + channel_acceleration / 1000 + ) + post_detection_dist_increments = STARBackend.mm_to_z_drive_increment(post_detection_dist) + + assert 9_320 <= lowest_immers_pos_increments <= 31_200, ( + f"Lowest immersion position must be between \n{STARBackend.z_drive_increment_to_mm(9_320)}" + + f" and {STARBackend.z_drive_increment_to_mm(31_200)} mm, is {lowest_immers_pos} mm" + ) + assert 9_320 <= start_pos_search_increments <= 31_200, ( + f"Start position of LLD search must be between \n{STARBackend.z_drive_increment_to_mm(9_320)}" + + f" and {STARBackend.z_drive_increment_to_mm(31_200)} mm, is {start_pos_search} mm" + ) + assert 20 <= channel_speed_increments <= 15_000, ( + f"LLD search speed must be between \n{STARBackend.z_drive_increment_to_mm(20)}" + + f"and {STARBackend.z_drive_increment_to_mm(15_000)} mm/sec, is {channel_speed} mm/sec" + ) + assert 5 <= channel_acceleration_thousand_increments <= 150, ( + f"Channel acceleration must be between \n{STARBackend.z_drive_increment_to_mm(5 * 1_000)} " + + f" and {STARBackend.z_drive_increment_to_mm(150 * 1_000)} mm/sec**2, is {channel_acceleration} mm/sec**2" + ) + assert 0 <= detection_edge <= 1_023, ( + "Edge steepness at capacitive LLD detection must be between 0 and 1023" + ) + assert 0 <= detection_drop <= 1_023, ( + "Offset after capacitive LLD edge detection must be between 0 and 1023" + ) + assert 0 <= post_detection_dist_increments <= 9_999, ( + "Post cLLD-detection movement distance must be between \n0" + + f" and {STARBackend.z_drive_increment_to_mm(9_999)} mm, is {post_detection_dist} mm" + ) + + await self.send_command( + module=STARBackend.channel_id(channel_idx), + command="ZL", + zh=f"{lowest_immers_pos_increments:05}", # Lowest immersion position [increment] + zc=f"{start_pos_search_increments:05}", # Start position of LLD search [increment] + zl=f"{channel_speed_increments:05}", # Speed of channel movement + zr=f"{channel_acceleration_thousand_increments:03}", # Acceleration [1000 increment/second^2] + gt=f"{detection_edge:04}", # Edge steepness at capacitive LLD detection + gl=f"{detection_drop:04}", # Offset after capacitive LLD edge detection + zj=post_detection_trajectory, # Movement of the channel after contacting surface + zi=f"{post_detection_dist_increments:04}", # Distance to move up after detection [increment] + ) + + async def clld_probe_z_height_using_channel( + self, + channel_idx: int, # 0-based indexing of channels! + lowest_immers_pos: float = 99.98, + start_pos_search: Optional[float] = None, + channel_speed: float = 10.0, + channel_acceleration: float = 800.0, + detection_edge: int = 10, + detection_drop: int = 2, + post_detection_trajectory: Literal[0, 1] = 1, + post_detection_dist: float = 2.0, + move_channels_to_safe_pos_after: bool = False, + ) -> float: + """Probe the liquid surface Z-height using a channel's capacitive LLD (cLLD). + + Uses the specified channel to perform a downward cLLD search and returns the + last liquid level detected by the instrument for that channel. + + This helper is responsible for: + - Ensuring a tip is mounted on the chosen channel. + - Reading the mounted tip length and applying the fixed fitting depth (8 mm) + to convert *tip-referenced* Z positions (C0-style coordinates) into the + channel Z-drive coordinates required by the firmware `ZL` cLLD command. + - Optionally moving channels to a Z-safe position after probing. + + Note: + cLLD requires a conductive target (e.g., conductive liquid / surface). + + Args: + channel_idx: Channel index to probe with (0-based; backmost channel = 0). + lowest_immers_pos: Lowest allowed search position in mm, expressed in the *tip-referenced* coordinate system (i.e., the position you would use for commands that include tip length). Internally converted to channel Z-drive coordinates before issuing `ZL`. + start_pos_search: Start position for the cLLD search in mm, expressed in the *tip-referenced* coordinate system. Internally converted to channel Z-drive coordinates before issuing `ZL`. If None, the highest safe position is used based on tip length. + channel_speed: Search speed in mm/s. Defaults to 10.0. + channel_acceleration: Search acceleration in mm/s^2. Defaults to 800.0. + detection_edge: Edge steepness threshold for cLLD detection (0-1023). Defaults to 10. + detection_drop: Offset applied after cLLD edge detection (0-1023). Defaults to 2. + post_detection_trajectory: Firmware post-detection move mode (0 or 1). Defaults to 1. + post_detection_dist: Distance in mm to move after detection (interpreted per trajectory). Defaults to 2.0. + move_channels_to_safe_pos_after: If True, moves all channels to a Z-safe position after the probing sequence completes. + + Raises: + RuntimeError: If no tip is mounted on `channel_idx`. + ValueError: If the computed start position is outside the allowed safe range. + STARFirmwareError: If the firmware reports an error during cLLD (channels are moved to Z-safe before re-raising). + + Returns: + The detected liquid surface Z-height in mm as reported by `request_pip_height_last_lld()` for `channel_idx`. + """ + + # Ensure tip is mounted + tip_presence = await self.request_tip_presence() + if not tip_presence[channel_idx]: + raise RuntimeError(f"No tip mounted on channel {channel_idx}") + + # Compute the highest position the tip can start the search from based on the known highest head position + tip_len = await self.request_tip_len_on_channel(channel_idx) + safe_tip_top_z_pos = ( + STARBackend.MAXIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) # head space -> tip space + + if start_pos_search is None: + start_pos_search = safe_tip_top_z_pos + + # Check if lowest_immers_pos is allowed + if lowest_immers_pos < STARBackend.MINIMUM_CHANNEL_Z_POSITION: + raise ValueError(f"lowest_immers_pos must be at least 99.98 mm but is {lowest_immers_pos} mm") + + # Correct for tip length + fitting depth (low level command is in head space, we are in tip space) + lowest_immers_pos_head_space = ( + lowest_immers_pos + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) # tip space -> head space + channel_head_start_pos = round( + start_pos_search + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH, 2 + ) + + # Check that start position is within allowed range + if not (lowest_immers_pos <= start_pos_search <= safe_tip_top_z_pos): + raise ValueError( + f"Start position of LLD search must be between \n{lowest_immers_pos} and {safe_tip_top_z_pos} mm, is {start_pos_search} mm" + ) + + try: + await self._move_z_drive_to_liquid_surface_using_clld( + channel_idx=channel_idx, + lowest_immers_pos=lowest_immers_pos_head_space, + start_pos_search=channel_head_start_pos, + channel_speed=channel_speed, + channel_acceleration=channel_acceleration, + detection_edge=detection_edge, + detection_drop=detection_drop, + post_detection_trajectory=post_detection_trajectory, + post_detection_dist=post_detection_dist, + ) + except STARFirmwareError: + await self.move_all_channels_in_z_safety() + raise + + if move_channels_to_safe_pos_after: + await self.move_all_channels_in_z_safety() + + current_absolute_liquid_heights = await self.request_pip_height_last_lld() + return current_absolute_liquid_heights[channel_idx] + + async def _search_for_surface_using_plld( + self, + channel_idx: int, # 0-based indexing of channels! + lowest_immers_pos: float = 99.98, # mm of the head_probe! + start_pos_search: float = 334.7, # mm of the head_probe! + channel_speed_above_start_pos_search: float = 120.0, # mm/sec + channel_speed: float = 10.0, # mm + channel_acceleration: float = 800.0, # mm/sec**2 + z_drive_current_limit: int = 3, + tip_has_filter: bool = False, + dispense_drive_speed: float = 5.0, # mm/sec + dispense_drive_acceleration: float = 0.2, # mm/sec**2 + dispense_drive_max_speed: float = 14.5, # mm/sec + dispense_drive_current_limit: int = 3, + plld_detection_edge: int = 30, + plld_detection_drop: int = 10, + clld_verification: bool = False, # cLLD Verification feature + clld_detection_edge: int = 10, # cLLD Verification feature + clld_detection_drop: int = 2, # cLLD Verification feature + max_delta_plld_clld: float = 5.0, # cLLD Verification feature; mm + plld_mode: Optional[PressureLLDMode] = None, # Foam feature + plld_foam_detection_drop: int = 30, # Foam feature + plld_foam_detection_edge_tolerance: int = 30, # Foam feature + plld_foam_ad_values: int = 30, # Foam feature; unknown unit + plld_foam_search_speed: float = 10.0, # Foam feature; mm/sec + dispense_back_plld_volume: Optional[float] = None, # uL + post_detection_trajectory: Literal[0, 1] = 1, + post_detection_dist: float = 2.0, # mm + ) -> Tuple[float, float]: + """Search a surface using pressured-based liquid level detection (pLLD) + (1) with or (2) without additional cLLD verification, and (a) with foam detection sub-mode or + (b) without foam detection sub-mode. + + Notes: + - This command is implemented via the PX command module, i.e. it IS parallelisable + - lowest_immers_pos & start_pos_search refer to the head_probe z-coordinate (not the tip) + - The return values represent head_probe z-positions (not the tip) in mm + + Args: + lowest_immers_pos: Lowest allowed Z during the search (mm). Default 99.98. + start_pos_search: Z position where the search begins (mm). Default 334.7. + channel_speed_above_start_pos_search: Z speed above the start position (mm/s). Default 120.0. + channel_speed: Z search speed (mm/s). Default 10.0. + channel_acceleration: Z acceleration (mm/s**2). Default 800.0. + z_drive_current_limit: Z drive current limit (instrument units). Default 3. + tip_has_filter: Whether a filter tip is mounted. Default False. + dispense_drive_speed: Dispense drive speed (mm/s). Default 5.0. + dispense_drive_acceleration: Dispense drive acceleration (mm/s**2). Default 0.2. + dispense_drive_max_speed: Dispense drive max speed (mm/s). Default 14.5. + dispense_drive_current_limit: Dispense drive current limit (instrument units). Default 3. + plld_detection_edge: Pressure detection edge threshold. Default 30. + plld_detection_drop: Pressure detection drop threshold. Default 10. + clld_verification: Activates cLLD sensing concurrently with the pressure probing. Note: cLLD + measurement itself cannot be retrieved. Instead it can be used for other applications, including + (1) verification of the surface level detected by pLLD based on max_delta_plld_clld, + (2) detection of foam (more easily triggers cLLD), if desired, causing an error. + This activates all cLLD-specific arguments. Default False. + max_delta_plld_clld: Max allowed delta between pressure/capacitive detections (mm). Default 5.0. + clld_detection_edge: Capacitive detection edge threshold. Default 10. + clld_detection_drop: Capacitive detection drop threshold. Default 2. + plld_mode: Pressure-detection sub-mode (instrument-defined). Default None. + plld_foam_detection_drop: Foam detection drop threshold. Default 30. + plld_foam_detection_edge_tolerance: Foam detection edge tolerance. Default 30. + plld_foam_ad_values: Foam AD values (instrument units). Default 30. + plld_foam_search_speed: Foam search speed (mm/s). Default 10.0. + dispense_back_plld_volume: Optional dispense-back volume after detection (uL). Default None. + post_detection_trajectory: Post-detection movement pattern selector. Default 1. + post_detection_dist: Post-detection movement distance (mm). Default 2.0. + + Returns: + Two z-coordinates (mm), head_probe, meaning depends on the selected pressure sub-mode: + - Single-detection modes/PressureLLDMode.LIQUID: (liquid_level_pos, 0) + - Two-detection modes/PressureLLDMode.FOAM: (first_detection_pos, liquid_level_pos) + """ + + # Preconditions checks + # Ensure valid channel index + if not isinstance(channel_idx, int) or not (0 <= channel_idx <= self.num_channels - 1): + raise ValueError(f"channel_idx must be in [0, {self.num_channels - 1}], is {channel_idx}") + + if plld_mode is None: + plld_mode = self.PressureLLDMode.LIQUID + + if dispense_back_plld_volume is None: + dispense_back_plld_volume_mode = 0 + dispense_back_plld_volume_increments = 0 + else: + dispense_back_plld_volume_mode = 1 + dispense_back_plld_volume_increments = STARBackend.dispensing_drive_vol_to_increment( + dispense_back_plld_volume + ) + + # Conversions to machine units + lowest_immers_pos_increments = STARBackend.mm_to_z_drive_increment(lowest_immers_pos) + start_pos_search_increments = STARBackend.mm_to_z_drive_increment(start_pos_search) + + channel_speed_above_start_pos_search_increments = STARBackend.mm_to_z_drive_increment( + channel_speed_above_start_pos_search + ) + channel_speed_increments = STARBackend.mm_to_z_drive_increment(channel_speed) + channel_acceleration_thousand_increments = STARBackend.mm_to_z_drive_increment( + channel_acceleration / 1000 + ) + + dispense_drive_speed_increments = STARBackend.dispensing_drive_mm_to_increment( + dispense_drive_speed + ) + dispense_drive_acceleration_increments = STARBackend.dispensing_drive_mm_to_increment( + dispense_drive_acceleration + ) + dispense_drive_max_speed_increments = STARBackend.dispensing_drive_mm_to_increment( + dispense_drive_max_speed + ) + + post_detection_dist_increments = STARBackend.mm_to_z_drive_increment(post_detection_dist) + max_delta_plld_clld_increments = STARBackend.mm_to_z_drive_increment(max_delta_plld_clld) + + plld_foam_search_speed_increments = STARBackend.mm_to_z_drive_increment(plld_foam_search_speed) + + # Machine-compatibility parameter checks + assert 9320 <= lowest_immers_pos_increments <= 31_200, ( + f"Lowest immersion position must be between \n{STARBackend.z_drive_increment_to_mm(9_320)}" + + f" and {STARBackend.z_drive_increment_to_mm(31_200)} mm, is {lowest_immers_pos} mm" + ) + assert 9320 <= start_pos_search_increments <= 31_200, ( + f"Start position of LLD search must be between \n{STARBackend.z_drive_increment_to_mm(9_320)}" + + f" and {STARBackend.z_drive_increment_to_mm(31_200)} mm, is {start_pos_search} mm" + ) + + assert tip_has_filter in [True, False], "tip_has_filter must be a boolean" + + assert isinstance(clld_verification, bool), ( + f"clld_verification must be a boolean, is {clld_verification}" + ) + + assert plld_mode in [self.PressureLLDMode.LIQUID, self.PressureLLDMode.FOAM], ( + f"plld_mode must be either PressureLLDMode.LIQUID ({self.PressureLLDMode.LIQUID}) or " + + f"PressureLLDMode.FOAM ({self.PressureLLDMode.FOAM}), is {plld_mode}" + ) + + assert 20 <= channel_speed_above_start_pos_search_increments <= 15_000, ( + "Speed above start position of LLD search must be between \n" + + f"{STARBackend.z_drive_increment_to_mm(20)} and " + + f"{STARBackend.z_drive_increment_to_mm(15_000)} mm/sec, is " + + f"{channel_speed_above_start_pos_search} mm/sec" + ) + assert 20 <= channel_speed_increments <= 15_000, ( + f"LLD search speed must be between \n{STARBackend.z_drive_increment_to_mm(20)}" + + f"and {STARBackend.z_drive_increment_to_mm(15_000)} mm/sec, is {channel_speed} mm/sec" + ) + assert 5 <= channel_acceleration_thousand_increments <= 150, ( + f"Channel acceleration must be between \n{STARBackend.z_drive_increment_to_mm(5 * 1_000)} " + + f" and {STARBackend.z_drive_increment_to_mm(150 * 1_000)} mm/sec**2, is {channel_acceleration} mm/sec**2" + ) + assert 0 <= z_drive_current_limit <= 7, ( + f"Z-drive current limit must be between 0 and 7, is {z_drive_current_limit}" + ) + + assert 20 <= dispense_drive_speed_increments <= 13_500, ( + "Dispensing drive speed must be between \n" + + f"{STARBackend.dispensing_drive_increment_to_mm(20)} and " + + f"{STARBackend.dispensing_drive_increment_to_mm(13_500)} mm/sec, is {dispense_drive_speed} mm/sec" + ) + assert 1 <= dispense_drive_acceleration_increments <= 100, ( + "Dispensing drive acceleration must be between \n" + + f"{STARBackend.dispensing_drive_increment_to_mm(1)} and " + + f"{STARBackend.dispensing_drive_increment_to_mm(100)} mm/sec**2, is {dispense_drive_acceleration} mm/sec**2" + ) + assert 20 <= dispense_drive_max_speed_increments <= 13_500, ( + "Dispensing drive max speed must be between \n" + + f"{STARBackend.dispensing_drive_increment_to_mm(20)} and " + + f"{STARBackend.dispensing_drive_increment_to_mm(13_500)} mm/sec, is {dispense_drive_max_speed} mm/sec" + ) + assert 0 <= dispense_drive_current_limit <= 7, ( + f"Dispensing drive current limit must be between 0 and 7, is {dispense_drive_current_limit}" + ) + + assert 0 <= clld_detection_edge <= 1_023, ( + "Edge steepness at capacitive LLD detection must be between 0 and 1023" + ) + assert 0 <= clld_detection_drop <= 1_023, ( + "Offset after capacitive LLD edge detection must be between 0 and 1023" + ) + assert 0 <= plld_detection_edge <= 1_023, ( + "Edge steepness at pressure LLD detection must be between 0 and 1023" + ) + assert 0 <= plld_detection_drop <= 1_023, ( + "Offset after pressure LLD edge detection must be between 0 and 1023" + ) + + assert 0 <= max_delta_plld_clld_increments <= 9_999, ( + "Maximum allowed difference between pressure LLD and capacitive LLD detection z-positions " + + f"must be between 0 and {STARBackend.z_drive_increment_to_mm(9_999)} mm," + + f" is {max_delta_plld_clld} mm" + ) + + assert 0 <= plld_foam_detection_drop <= 1_023, ( + f"Pressure LLD foam detection drop must be between 0 and 1023, is {plld_foam_detection_drop}" + ) + assert 0 <= plld_foam_detection_edge_tolerance <= 1_023, ( + "Pressure LLD foam detection edge tolerance must be between 0 and 1023, " + + f"is {plld_foam_detection_edge_tolerance}" + ) + assert 0 <= plld_foam_ad_values <= 4_999, ( + f"Pressure LLD foam AD values must be between 0 and 4999, is {plld_foam_ad_values}" + ) + assert 20 <= plld_foam_search_speed_increments <= 13_500, ( + "Pressure LLD foam search speed must be between \n" + + f"{STARBackend.z_drive_increment_to_mm(20)} and " + + f"{STARBackend.z_drive_increment_to_mm(13_500)} mm/sec, is {plld_foam_search_speed} mm/sec" + ) + + assert dispense_back_plld_volume_mode in [0, 1], ( + "dispense_back_plld_volume_mode must be either 0 ('normal') or 1 " + + "('dispense back dispense_back_plld_volume'), " + + f"is {dispense_back_plld_volume_mode}" + ) + + assert 0 <= dispense_back_plld_volume_increments <= 26_666, ( + "Dispense back pressure LLD volume must be between \n0" + + f" and {STARBackend.dispensing_drive_increment_to_volume(26_666)} uL, is {dispense_back_plld_volume} uL" + ) + + assert 0 <= post_detection_dist_increments <= 9_999, ( + "Post cLLD-detection movement distance must be between \n0" + + f" and {STARBackend.z_drive_increment_to_mm(9_999)} mm, is {post_detection_dist} mm" + ) + + resp_raw = await self.send_command( + module=STARBackend.channel_id(channel_idx), + command="ZE", + zh=f"{lowest_immers_pos_increments:05}", + zc=f"{start_pos_search_increments:05}", + zi=f"{post_detection_dist_increments:04}", + zj=f"{post_detection_trajectory:01}", + gf=str(int(tip_has_filter)), + gt=f"{clld_detection_edge:04}", + gl=f"{clld_detection_drop:04}", + gu=f"{plld_detection_edge:04}", + gn=f"{plld_detection_drop:04}", + gm=str(int(clld_verification)), + gz=f"{max_delta_plld_clld_increments:04}", + cj=str(plld_mode.value), + co=f"{plld_foam_detection_drop:04}", + cp=f"{plld_foam_detection_edge_tolerance:04}", + cq=f"{plld_foam_ad_values:04}", + cl=f"{plld_foam_search_speed_increments:05}", + cc=str(dispense_back_plld_volume_mode), + cd=f"{dispense_back_plld_volume_increments:05}", + zv=f"{channel_speed_above_start_pos_search_increments:05}", + zl=f"{channel_speed_increments:05}", + zr=f"{channel_acceleration_thousand_increments:03}", + zw=f"{z_drive_current_limit}", + dl=f"{dispense_drive_speed_increments:05}", + dr=f"{dispense_drive_acceleration_increments:03}", + dv=f"{dispense_drive_max_speed_increments:05}", + dw=f"{dispense_drive_current_limit}", + read_timeout=max(self.read_timeout, 120), # it can take long (>30s) + ) + assert resp_raw is not None + + resp_probe_mm = [ + STARBackend.z_drive_increment_to_mm(int(return_val)) + for return_val in resp_raw.split("if")[-1].split() + ] + + # return depending on mode + return ( + (resp_probe_mm[0], 0) + if plld_mode == self.PressureLLDMode.LIQUID + else (resp_probe_mm[0], resp_probe_mm[1]) + ) + + async def plld_probe_z_height_using_channel( + self, + channel_idx: int, # 0-based indexing of channels! + lowest_immers_pos: float = 99.98, # mm + start_pos_search: Optional[float] = None, # mm + channel_speed_above_start_pos_search: float = 120.0, # mm/sec + channel_speed: float = 10.0, # mm + channel_acceleration: float = 800.0, # mm/sec**2 + z_drive_current_limit: int = 3, + tip_has_filter: bool = False, + dispense_drive_speed: float = 5.0, # mm/sec + dispense_drive_acceleration: float = 0.2, # mm/sec**2 + dispense_drive_max_speed: float = 14.5, # mm/sec + dispense_drive_current_limit: int = 3, + plld_detection_edge: int = 30, + plld_detection_drop: int = 10, + clld_verification: bool = False, # cLLD Verification feature + clld_detection_edge: int = 10, # cLLD Verification feature + clld_detection_drop: int = 2, # cLLD Verification feature + max_delta_plld_clld: float = 5.0, # cLLD Verification feature; mm + plld_mode: Optional[PressureLLDMode] = None, # Foam feature + plld_foam_detection_drop: int = 30, # Foam feature + plld_foam_detection_edge_tolerance: int = 30, # Foam feature + plld_foam_ad_values: int = 30, # Foam feature; unknown unit + plld_foam_search_speed: float = 10.0, # Foam feature; mm/sec + dispense_back_plld_volume: Optional[float] = None, # uL + post_detection_trajectory: Literal[0, 1] = 1, + post_detection_dist: float = 2.0, # mm + move_channels_to_safe_pos_after: bool = False, + ) -> Tuple[float, float]: + """Detect liquid level using pressured-based liquid level detection (pLLD) + (1) with or (2) without additional cLLD verification, and (a) with foam detection sub-mode or + (b) without foam detection sub-mode. + + Notes: + - This command is implemented via BOTH the PX and C0 command modules, i.e. it is NOT parallelisable! + - lowest_immers_pos & start_pos_search refer to the tip z-coordinate (not the head_probe)! + - The return values represent tip z-positions (not the head_probe) in mm! + + Args: + lowest_immers_pos: Lowest allowed search position in mm, expressed in the *tip-referenced* coordinate system (i.e., the position you would use for commands that include tip length). Internally converted to channel Z-drive coordinates before issuing `ZL`. + start_pos_search: Start position for the cLLD search in mm, expressed in the *tip-referenced* coordinate system. Internally converted to channel Z-drive coordinates before issuing `ZL`. If None, the highest safe position is used based on tip length. + channel_speed_above_start_pos_search: Z speed above the start position (mm/s). Default 120.0. + channel_speed: Z search speed (mm/s). Default 10.0. + channel_acceleration: Z acceleration (mm/s**2). Default 800.0. + z_drive_current_limit: Z drive current limit (instrument units). Default 3. + tip_has_filter: Whether a filter tip is mounted. Default False. + dispense_drive_speed: Dispense drive speed (mm/s). Default 5.0. + dispense_drive_acceleration: Dispense drive acceleration (mm/s**2). Default 0.2. + dispense_drive_max_speed: Dispense drive max speed (mm/s). Default 14.5. + dispense_drive_current_limit: Dispense drive current limit (instrument units). Default 3. + plld_detection_edge: Pressure detection edge threshold. Default 30. + plld_detection_drop: Pressure detection drop threshold. Default 10. + clld_verification: Activates cLLD sensing concurrently with the pressure probing. Note: cLLD + measurement itself cannot be retrieved. Instead it can be used for other applications, including + (1) verification of the surface level detected by pLLD based on max_delta_plld_clld, + (2) detection of foam (more easily triggers cLLD), if desired causing and error. + This activates all cLLD-specific arguments. Default False. + clld_detection_edge: Capacitive detection edge threshold. Default 10. + clld_detection_drop: Capacitive detection drop threshold. Default 2. + max_delta_plld_clld: Max allowed delta between pressure/capacitive detections (mm). Default 5.0. + plld_mode: Pressure-detection sub-mode (instrument-defined). Default None. + plld_foam_detection_drop: Foam detection drop threshold. Default 30. + plld_foam_detection_edge_tolerance: Foam detection edge tolerance. Default 30. + plld_foam_ad_values: Foam AD values (instrument units). Default 30. + plld_foam_search_speed: Foam search speed (mm/s). Default 10.0. + dispense_back_plld_volume: Optional dispense-back volume after detection (uL). Default None. + post_detection_trajectory: Post-detection movement pattern selector. Default 1. + post_detection_dist: Post-detection movement distance (mm). Default 2.0. + + Returns: + Two z-coordinates (mm), tip, meaning depends on the selected pressure sub-mode: + - Single-detection modes/PressureLLDMode.LIQUID: (liquid_level_pos, 0) + - Two-detection modes/PressureLLDMode.FOAM: (first_detection_pos, liquid_level_pos) + """ + + # Ensure tip is mounted + tip_presence = await self.request_tip_presence() + if not tip_presence[channel_idx]: + raise RuntimeError(f"No tip mounted on channel {channel_idx}") + + # Compute the highest position the tip can start the search from based on the known highest head position + tip_len = await self.request_tip_len_on_channel(channel_idx) + safe_tip_top_z_pos = ( + STARBackend.MAXIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) # head space -> tip space + + if start_pos_search is None: + start_pos_search = safe_tip_top_z_pos + + # Check if lowest_immers_pos is allowed + if lowest_immers_pos < STARBackend.MINIMUM_CHANNEL_Z_POSITION: + raise ValueError(f"lowest_immers_pos must be at least 99.98 mm but is {lowest_immers_pos} mm") + + # Correct for tip length + fitting depth (low level command is in head space, we are in tip space) + lowest_immers_pos_head_space = ( + lowest_immers_pos + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) # tip space -> head space + channel_head_start_pos = round( + start_pos_search + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH, 2 + ) + + # Check that start position is within allowed range + if not (lowest_immers_pos <= start_pos_search <= safe_tip_top_z_pos): + raise ValueError( + f"Start position of LLD search must be between \n{lowest_immers_pos} and {safe_tip_top_z_pos} mm, is {start_pos_search} mm" + ) + + try: + resp_probe_mm = await self._search_for_surface_using_plld( + channel_idx=channel_idx, + lowest_immers_pos=lowest_immers_pos_head_space, + start_pos_search=channel_head_start_pos, + channel_speed_above_start_pos_search=channel_speed_above_start_pos_search, + channel_speed=channel_speed, + channel_acceleration=channel_acceleration, + z_drive_current_limit=z_drive_current_limit, + tip_has_filter=tip_has_filter, + dispense_drive_speed=dispense_drive_speed, + dispense_drive_acceleration=dispense_drive_acceleration, + dispense_drive_max_speed=dispense_drive_max_speed, + dispense_drive_current_limit=dispense_drive_current_limit, + plld_detection_edge=plld_detection_edge, + plld_detection_drop=plld_detection_drop, + clld_verification=clld_verification, + clld_detection_edge=clld_detection_edge, + clld_detection_drop=clld_detection_drop, + max_delta_plld_clld=max_delta_plld_clld, + plld_mode=plld_mode, + plld_foam_detection_drop=plld_foam_detection_drop, + plld_foam_detection_edge_tolerance=plld_foam_detection_edge_tolerance, + plld_foam_ad_values=plld_foam_ad_values, + plld_foam_search_speed=plld_foam_search_speed, + dispense_back_plld_volume=dispense_back_plld_volume, + post_detection_trajectory=post_detection_trajectory, + post_detection_dist=post_detection_dist, + ) + except STARFirmwareError: + await self.move_all_channels_in_z_safety() + raise + + if plld_mode == self.PressureLLDMode.FOAM: + resp_tip_mm = ( + round(resp_probe_mm[0] - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH, 2), + round(resp_probe_mm[1] - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH, 2), + ) + else: + resp_tip_mm = ( + round(resp_probe_mm[0] - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH, 2), + 0.0, + ) + + if move_channels_to_safe_pos_after: + await self.move_all_channels_in_z_safety() + + return resp_tip_mm + + # TODO(v1): rename to request_stop_disk_z_position for consistency with + # move_channel_stop_disk_z and channels_request_stop_disk_z_positions. + async def request_probe_z_position(self, channel_idx: int) -> float: + """Request the z-position of the channel probe (EXCLUDING the tip)""" + resp = await self.send_command( + module=self.channel_id(channel_idx), command="RZ", fmt="rz######" + ) + increments = resp["rz"] + return self.z_drive_increment_to_mm(increments) + + async def request_tip_len_on_channel(self, channel_idx: int) -> float: + """Measures the length of the tip attached to the specified pipetting channel. + Checks if a tip is present on the given channel. Raises an error if no tip is present. + + Parameters: + channel_idx: Index of the pipetting channel (0-indexed). + + Returns: + The measured tip length in millimeters. + + Raises: + RuntimeError: If no tip is present on the channel. + """ + + # Check there is a tip on the channel + all_channel_occupancy = await self.request_tip_presence() + if not all_channel_occupancy[channel_idx]: + raise RuntimeError(f"No tip present on channel {channel_idx}") + + # Request z position of probe bottom + probe_position = await self.request_probe_z_position(channel_idx=channel_idx) + + # Request z-coordinate of probe+tip bottom + tip_bottom_z_coordinate = await self.request_tip_bottom_z_position(channel_idx=channel_idx) + + fitting_depth_of_all_standard_channel_tips = 8 # mm + return round( + probe_position - (tip_bottom_z_coordinate - fitting_depth_of_all_standard_channel_tips), + 1, + ) + + MAXIMUM_CHANNEL_Z_POSITION = 334.7 # mm (= z-drive increment 31_200) + MINIMUM_CHANNEL_Z_POSITION = 99.98 # mm (= z-drive increment 9_320) + DEFAULT_TIP_FITTING_DEPTH = 8 # mm, for 10, 50, 300, 1000 ul Hamilton tips + SEARCH_START_CLEARANCE_MM = 5 # mm above container top for LLD search start position + + async def ztouch_probe_z_height_using_channel( + self, + channel_idx: int, # 0-based indexing of channels! + tip_len: Optional[float] = None, # mm + lowest_immers_pos: float = 99.98, # mm + start_pos_search: Optional[float] = None, # mm + channel_speed: float = 10.0, # mm/sec + channel_acceleration: float = 800.0, # mm/sec**2 + channel_speed_upwards: float = 125.0, # mm + detection_limiter_in_PWM: int = 1, + push_down_force_in_PWM: int = 0, + post_detection_dist: float = 2.0, # mm + move_channels_to_safe_pos_after: bool = False, + ) -> float: + """Probes the Z-height below the specified channel on a Hamilton STAR liquid handling machine + using the channels 'z-touchoff' capabilities, i.e. a controlled triggering of the z-drive, + aka a controlled 'crash'. + + Args: + channel_idx: The index of the channel to use for probing. Backmost channel = 0. + tip_len: override the tip length (of tip on channel `channel_idx`). Default is the tip length + of the tip that was picked up. + lowest_immers_pos: The lowest immersion position in mm. + start_pos_lld_search: The start position for z-touch search in mm. + channel_speed: The speed of channel movement in mm/sec. + channel_acceleration: The acceleration of the channel in mm/sec**2. + detection_limiter_in_PWM: Offset PWM limiter value for searching + push_down_force_in_PWM: Offset PWM value for push down force. + cf000 = No push down force, drive is switched off. + post_detection_dist: Distance to move into the trajectory after detection in mm. + move_channels_to_safe_pos_after: Flag to move channels to a safe position after + operation. + + Returns: + The detected Z-height in mm. + """ + + version = await self.request_pip_channel_version(channel_idx) + year_matches = re.search(r"\b\d{4}\b", version) + if year_matches is not None: + year = int(year_matches.group()) + if year < 2022: + raise ValueError( + "Z-touch probing is not supported for PIP versions predating 2022, " + f"found version '{version}'" + ) + + if tip_len is None: + # currently a bug, will be fixed in the future + # reverted to previous implementation + # tip_len = self.head[channel_idx].get_tip().total_tip_length + tip_len = await self.request_tip_len_on_channel(channel_idx) + + if start_pos_search is None: + start_pos_search = ( + STARBackend.MAXIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) + + tip_len_used_in_increments = ( + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) / STARBackend.z_drive_mm_per_increment + channel_head_start_pos = ( + start_pos_search + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) # start_pos of the head itself! + safe_head_bottom_z_pos = ( + STARBackend.MINIMUM_CHANNEL_Z_POSITION + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH + ) + safe_head_top_z_pos = STARBackend.MAXIMUM_CHANNEL_Z_POSITION + + lowest_immers_pos_increments = STARBackend.mm_to_z_drive_increment(lowest_immers_pos) + start_pos_search_increments = STARBackend.mm_to_z_drive_increment(channel_head_start_pos) + channel_speed_increments = STARBackend.mm_to_z_drive_increment(channel_speed) + channel_acceleration_thousand_increments = STARBackend.mm_to_z_drive_increment( + channel_acceleration / 1000 + ) + channel_speed_upwards_increments = STARBackend.mm_to_z_drive_increment(channel_speed_upwards) + + assert 0 <= channel_idx <= 15, f"channel_idx must be between 0 and 15, is {channel_idx}" + assert 20 <= tip_len <= 120, "Total tip length must be between 20 and 120" + + assert 9320 <= lowest_immers_pos_increments <= 31_200, ( + "Lowest immersion position must be between \n99.98" + + f" and 334.7 mm, is {lowest_immers_pos} mm" + ) + assert safe_head_bottom_z_pos <= channel_head_start_pos <= safe_head_top_z_pos, ( + f"Start position of LLD search must be between \n{safe_head_bottom_z_pos}" + + f" and {safe_head_top_z_pos} mm, is {channel_head_start_pos} mm" + ) + assert 20 <= channel_speed_increments <= 15_000, ( + f"Z-touch search speed must be between \n{STARBackend.z_drive_increment_to_mm(20)}" + + f" and {STARBackend.z_drive_increment_to_mm(15_000)} mm/sec, is {channel_speed} mm/sec" + ) + assert 5 <= channel_acceleration_thousand_increments <= 150, ( + f"Channel acceleration must be between \n{STARBackend.z_drive_increment_to_mm(5 * 1_000)}" + + f" and {STARBackend.z_drive_increment_to_mm(150 * 1_000)} mm/sec**2, is {channel_speed} mm/sec**2" + ) + assert 20 <= channel_speed_upwards_increments <= 15_000, ( + f"Channel retraction speed must be between \n{STARBackend.z_drive_increment_to_mm(20)}" + + f" and {STARBackend.z_drive_increment_to_mm(15_000)} mm/sec, is {channel_speed_upwards} mm/sec" + ) + assert 0 <= detection_limiter_in_PWM <= 125, ( + "Detection limiter value must be between 0 and 125 PWM." + ) + assert 0 <= push_down_force_in_PWM <= 125, "Push down force between 0 and 125 PWM values" + assert 0 <= post_detection_dist <= 245, ( + f"Post detection distance must be between 0 and 245 mm, is {post_detection_dist}" + ) + + lowest_immers_pos_str = f"{lowest_immers_pos_increments:05}" + start_pos_search_str = f"{start_pos_search_increments:05}" + channel_speed_str = f"{channel_speed_increments:05}" + channel_acc_str = f"{channel_acceleration_thousand_increments:03}" + channel_speed_up_str = f"{channel_speed_upwards_increments:05}" + detection_limiter_in_PWM_str = f"{detection_limiter_in_PWM:03}" + push_down_force_in_PWM_str = f"{push_down_force_in_PWM:03}" + + ztouch_probed_z_height = await self.send_command( + module=STARBackend.channel_id(channel_idx), + command="ZH", + zb=start_pos_search_str, # begin of searching range [increment] + za=lowest_immers_pos_str, # end of searching range [increment] + zv=channel_speed_up_str, # speed z-drive upper section [increment/second] + zr=channel_acc_str, # acceleration z-drive [1000 increment/second] + zu=channel_speed_str, # speed z-drive lower section [increment/second] + cg=detection_limiter_in_PWM_str, # offset PWM limiter value for searching + cf=push_down_force_in_PWM_str, # offset PWM value for push down force + fmt="rz#####", + ) + # Subtract tip_length from measurement in increment, and convert to mm + result_in_mm = STARBackend.z_drive_increment_to_mm( + ztouch_probed_z_height["rz"] - tip_len_used_in_increments + ) + if post_detection_dist != 0: # Safety first + await self.move_channel_z(z=result_in_mm + post_detection_dist, channel=channel_idx) + if move_channels_to_safe_pos_after: + await self.move_all_channels_in_z_safety() + + return float(result_in_mm) + + @staticmethod + def channel_id(channel_idx: int) -> str: + """channel_idx: plr style, 0-indexed from the back""" + channel_ids = "123456789ABCDEFG" + return "P" + channel_ids[channel_idx] + + async def get_channels_y_positions(self) -> Dict[int, float]: + """Get the Y position of all channels in mm""" + resp = await self.send_command( + module="C0", + command="RY", + fmt="ry#### (n)", + ) + y_positions = [round(y / 10, 2) for y in resp["ry"]] + + # sometimes there is (likely) a floating point error and channels are reported to be + # closer together than the minimum required spacing. (When you set channels using + # position_channels_in_y_direction, it will raise an error.) We first ensure the last + # channel is not reported in front of the known minimum Y position, then traverse the + # list in reverse and enforce the per-channel minimum spacing. + min_y = self.extended_conf.left_arm_min_y_position + if y_positions[-1] < min_y - 0.2: + raise RuntimeError( + "Channels are reported to be too close to the front of the machine. " + f"The known minimum is {min_y}, which will be fixed automatically for " + f"{min_y - 0.2} 650: + raise ValueError("Channel 0 would hit the back of the robot") + + if channel_locations[self.num_channels - 1] < 6: + raise ValueError("Channel N would hit the front of the robot") + + for i in range(len(channel_locations) - 1): + required = self._min_spacing_between(i, i + 1) + actual = channel_locations[i] - channel_locations[i + 1] + if round(actual * 1000) < round(required * 1000): # compare in um to avoid float issues + raise ValueError( + f"Channels {i} and {i + 1} must be at least {required}mm apart, " + f"but are {actual:.2f}mm apart." + ) + + yp = " ".join([f"{round(y * 10):04}" for y in channel_locations.values()]) + return await self.send_command( + module="C0", + command="JY", + yp=yp, + ) + + async def get_channels_z_positions(self) -> Dict[int, float]: + """Get the Y position of all channels in mm""" + resp = await self.send_command( + module="C0", + command="RZ", + fmt="rz#### (n)", + ) + return {channel_idx: round(y / 10, 2) for channel_idx, y in enumerate(resp["rz"])} + + async def position_channels_in_z_direction(self, zs: Dict[int, float]): + channel_locations = await self.get_channels_z_positions() + + for channel_idx, z in zs.items(): + channel_locations[channel_idx] = z + + return await self.send_command( + module="C0", command="JZ", zp=[f"{round(z * 10):04}" for z in channel_locations.values()] + ) + + async def pierce_foil( + self, + wells: Union[Well, List[Well]], + piercing_channels: List[int], + hold_down_channels: List[int], + move_inwards: float, + spread: Literal["wide", "tight"] = "wide", + one_by_one: bool = False, + distance_from_bottom: float = 20.0, + ): + """Pierce the foil of the media source plate at the specified column. Throw away the tips + after piercing because there will be a bit of foil stuck to the tips. Use this method + before aspirating from a foil-sealed plate to make sure the tips are clean and the + aspirations are accurate. + + Args: + wells: Well or wells in the plate to pierce the foil. If multiple wells, they must be on one + column. + piercing_channels: The channels to use for piercing the foil. + hold_down_channels: The channels to use for holding down the plate when moving up the + piercing channels. + spread: The spread of the piercing channels in the well. + one_by_one: If True, the channels will pierce the foil one by one. If False, all channels + will pierce the foil simultaneously. + """ + + x: float + ys: List[float] + z: float + + # if only one well is give, but in a list, convert to Well so we fall into single-well logic. + if isinstance(wells, list) and len(wells) == 1: + wells = wells[0] + + if isinstance(wells, Well): + well = wells + x, y, z = well.get_location_wrt(self.deck, "c", "c", "cavity_bottom") + + if spread == "wide": + offsets = get_wide_single_resource_liquid_op_offsets( + resource=well, + num_channels=len(piercing_channels), + min_spacing=max( + self._min_spacing_between(lo, hi) + for lo, hi in zip(sorted(piercing_channels)[:-1], sorted(piercing_channels)[1:]) + ), + ) + else: + offsets = get_tight_single_resource_liquid_op_offsets( + well, num_channels=len(piercing_channels) + ) + ys = [y + offset.y for offset in offsets] + else: + assert len(set(w.get_location_wrt(self.deck).x for w in wells)) == 1, ( + "Wells must be on the same column" + ) + absolute_center = wells[0].get_location_wrt(self.deck, "c", "c", "cavity_bottom") + x = absolute_center.x + ys = [well.get_location_wrt(self.deck, x="c", y="c").y for well in wells] + z = absolute_center.z + + await self.move_channel_x(0, x=x) + + await self.position_channels_in_y_direction( + {channel: y for channel, y in zip(piercing_channels, ys)} + ) + + zs = [z + distance_from_bottom for _ in range(len(piercing_channels))] + if one_by_one: + for channel in piercing_channels: + await self.move_channel_z(channel, z) + else: + await self.position_channels_in_z_direction( + {channel: z for channel, z in zip(piercing_channels, zs)} + ) + + await self.step_off_foil( + [wells] if isinstance(wells, Well) else wells, + back_channel=hold_down_channels[0], + front_channel=hold_down_channels[1], + move_inwards=move_inwards, + ) + + async def step_off_foil( + self, + wells: Union[Well, List[Well]], + front_channel: int, + back_channel: int, + move_inwards: float = 2, + move_height: float = 15, + ): + """ + Hold down a plate by placing two channels on the edges of a plate that is sealed with foil + while moving up the channels that are still within the foil. This is useful when, for + example, aspirating from a plate that is sealed: without holding it down, the tips might get + stuck in the plate and move it up when retracting. Putting plates on the edge prevents this. + + When aspirating or dispensing in the foil, be sure to set the `min_z_endpos` parameter in + `lh.aspirate` or `lh.dispense` to a value in the foil. You might want to use something like + + .. code-block:: python + + well = plate.get_well("A3") + await lh.aspirate( + [well]*4, vols=[100]*4, use_channels=[7,8,9,10], + min_z_endpos=well.get_location_wrt(self.deck, z="cavity_bottom").z, + surface_following_distance=0, + pull_out_distance_transport_air=[0] * 4) + await step_off_foil(lh.backend, [well], front_channel=11, back_channel=6, move_inwards=3) + + Args: + wells: Wells in the plate to hold down. (x-coordinate of channels will be at center of wells). + Must be sorted from back to front. + front_channel: The channel to place on the front of the plate. + back_channel: The channel to place on the back of the plate. + move_inwards: mm to move inwards (backward on the front channel; frontward on the back). + move_height: mm to move upwards after piercing the foil. front_channel and back_channel will hold the plate down. + """ + + if front_channel <= back_channel: + raise ValueError( + "front_channel should be in front of back_channel. Channels are 0-indexed from the back." + ) + + if isinstance(wells, Well): + wells = [wells] + + plates = set(well.parent for well in wells) + assert len(plates) == 1, "All wells must be in the same plate" + plate = plates.pop() + assert plate is not None + + z_location = plate.get_location_wrt(self.deck, z="top").z + + if plate.get_absolute_rotation().z % 360 == 0: + back_location = plate.get_location_wrt(self.deck, y="b") + front_location = plate.get_location_wrt(self.deck, y="f") + elif plate.get_absolute_rotation().z % 360 == 90: + back_location = plate.get_location_wrt(self.deck, x="r") + front_location = plate.get_location_wrt(self.deck, x="l") + elif plate.get_absolute_rotation().z % 360 == 180: + back_location = plate.get_location_wrt(self.deck, y="f") + front_location = plate.get_location_wrt(self.deck, y="b") + elif plate.get_absolute_rotation().z % 360 == 270: + back_location = plate.get_location_wrt(self.deck, x="l") + front_location = plate.get_location_wrt(self.deck, x="r") + else: + raise ValueError("Plate rotation must be a multiple of 90 degrees") + + try: + # Then move all channels in the y-space simultaneously. + await self.position_channels_in_y_direction( + { + front_channel: front_location.y + move_inwards, + back_channel: back_location.y - move_inwards, + } + ) + + await self.move_channel_z(front_channel, z_location) + await self.move_channel_z(back_channel, z_location) + finally: + # Move channels that are lower than the `front_channel` and `back_channel` to + # the just above the foil, in case the foil pops up. + zs = await self.get_channels_z_positions() + indices = [channel_idx for channel_idx, z in zs.items() if z < z_location] + idx = { + idx: z_location + move_height for idx in indices if idx not in (front_channel, back_channel) + } + await self.position_channels_in_z_direction(idx) + + # After that, all channels are clear to move up. + await self.move_all_channels_in_z_safety() + + async def request_volume_in_tip(self, channel: int) -> float: + resp = await self.send_command(STARBackend.channel_id(channel), "QC", fmt="qc##### (n)") + _, current_volume = resp["qc"] # first is max volume + return float(current_volume) / 10 + + @asynccontextmanager + async def slow_iswap(self, wrist_velocity: int = 20_000, gripper_velocity: int = 20_000): + """A context manager that sets the iSWAP to slow speed during the context""" + assert 20 <= gripper_velocity <= 75_000 + assert 20 <= wrist_velocity <= 65_000 + + original_wv = (await self.send_command("R0", "RA", ra="wv", fmt="wv#####"))["wv"] + original_tv = (await self.send_command("R0", "RA", ra="tv", fmt="tv#####"))["tv"] + + await self.send_command("R0", "AA", wv=gripper_velocity) # wrist velocity + await self.send_command("R0", "AA", tv=wrist_velocity) # gripper velocity + try: + yield + finally: + await self.send_command("R0", "AA", wv=original_wv) + await self.send_command("R0", "AA", tv=original_tv) + + # HamiltonHeaterShakerInterface + + async def send_hhs_command(self, index: int, command: str, **kwargs) -> str: + resp = await self.send_command( + module=f"T{index}", + command=command, + **kwargs, + ) + assert isinstance(resp, str) + return resp + + # ------------ STAR(RS-232/TCC1/2)-connected Hamilton Heater Cooler (HHS) ------------- + + async def check_type_is_hhc(self, device_number: int): + """ + Convenience method to check that connected device is an HHC. + Executed through firmware query + """ + + firmware_version = await self.send_command(module=f"T{device_number}", command="RF") + if "Hamilton Heater Cooler" not in firmware_version: + raise ValueError( + f"Device number {device_number} does not connect to a Hamilton" + f" Heater-Cooler, found {firmware_version} instead." + f"Have you called the wrong device number?" + ) + + async def initialize_hhc(self, device_number: int) -> str: + """Initialize Hamilton Heater Cooler (HHC) at specified TCC port + + Args: + device_number: TCC connect number to the HHC + """ + + module_pointer = f"T{device_number}" + + # Request module configuration + try: + await self.send_command(module=module_pointer, command="QU") + except TimeoutError as exc: + error_message = ( + f"No Hamilton Heater Cooler found at device_number {device_number}" + f", have you checked your connections? Original error: {exc}" + ) + raise ValueError(error_message) from exc + + await self.check_type_is_hhc(device_number) + + # Request module configuration + hhc_init_status = await self.send_command(module=module_pointer, command="QW", fmt="qw#") + hhc_init_status = hhc_init_status["qw"] + + info = "HHC already initialized" + # Initializing HHS if necessary + if hhc_init_status != 1: + # Initialize device + await self.send_command(module=module_pointer, command="LI") + info = f"HHS at device number {device_number} initialized." + + return info + + async def start_temperature_control_at_hhc( + self, + device_number: int, + temp: Union[float, int], + ): + """Start temperature regulation of specified HHC""" + + await self.check_type_is_hhc(device_number) + assert 0 < temp <= 105 + + # Ensure proper temperature input handling + if isinstance(temp, (float, int)): + safe_temp_str = f"{round(temp * 10):04d}" + else: + safe_temp_str = str(temp) + + return await self.send_command( + module=f"T{device_number}", + command="TA", # temperature adjustment + ta=safe_temp_str, + tb="1800", # TODO: identify precise purpose? + tc="0020", # TODO: identify precise purpose? + ) + + async def get_temperature_at_hhc(self, device_number: int) -> dict: + """Query current temperatures of both sensors of specified HHC""" + + await self.check_type_is_hhc(device_number) + + request_temperature = await self.send_command(module=f"T{device_number}", command="RT") + processed_t_info = [int(x) / 10 for x in request_temperature.split("+")[-2:]] + + return { + "middle_T": processed_t_info[0], + "edge_T": processed_t_info[-1], + } + + async def query_whether_temperature_reached_at_hhc(self, device_number: int): + """Stop temperature regulation of specified HHC""" + + await self.check_type_is_hhc(device_number) + query_current_control_status = await self.send_command( + module=f"T{device_number}", command="QD", fmt="qd#" + ) + + return query_current_control_status["qd"] == 0 + + async def stop_temperature_control_at_hhc(self, device_number: int): + """Stop temperature regulation of specified HHC""" + + await self.check_type_is_hhc(device_number) + + return await self.send_command(module=f"T{device_number}", command="TO") + + # -------------- Extra - Probing labware with STAR - making STAR into a CMM -------------- + + +class UnSafe: + """ + Namespace for actions that are unsafe to perform. + For example, actions that send the iSWAP outside of the Hamilton Deck + """ + + def __init__(self, star: "STARBackend"): + self.star = star + + async def put_in_hotel( + self, + hotel_center_x_coord: int = 0, + hotel_center_y_coord: int = 0, + hotel_center_z_coord: int = 0, + hotel_center_x_direction: Literal[0, 1] = 0, + hotel_center_y_direction: Literal[0, 1] = 0, + hotel_center_z_direction: Literal[0, 1] = 0, + clearance_height: int = 50, + hotel_depth: int = 1_300, + grip_direction: GripDirection = GripDirection.FRONT, + traverse_height_at_beginning: int = 3_600, + z_position_at_end: int = 3_600, + grip_strength: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] = 5, + open_gripper_position: int = 860, + collision_control: Literal[0, 1] = 1, + high_acceleration_index: Literal[1, 2, 3, 4] = 4, + low_acceleration_index: Literal[1, 2, 3, 4] = 1, + fold_up_at_end: bool = True, + ): + """ + A hotel is a location to store a plate. This can be a loading + dock for an external machine such as a cytomat or a centrifuge. + + Take care when using this command to interact with hotels located + outside of the hamilton deck area. Ensure that rotations of the + iSWAP arm don't collide with anything. + + tip: set the hotel depth big enough so that the boundary is inside the + hamilton deck. The iSWAP rotations will happen before it enters the hotel. + + The units of all relevant variables are in 0.1mm + """ + + assert 0 <= hotel_center_x_coord <= 99_999 + assert 0 <= hotel_center_y_coord <= 6_500 + assert 0 <= hotel_center_z_coord <= 3_500 + assert 0 <= clearance_height <= 999 + assert 0 <= hotel_depth <= 3_000 + assert 0 <= traverse_height_at_beginning <= 3_600 + assert 0 <= z_position_at_end <= 3_600 + assert 0 <= open_gripper_position <= 9_999 + + return await self.star.send_command( + module="C0", + command="PI", + xs=f"{hotel_center_x_coord:05}", + xd=hotel_center_x_direction, + yj=f"{hotel_center_y_coord:04}", + yd=hotel_center_y_direction, + zj=f"{hotel_center_z_coord:04}", + zd=hotel_center_z_direction, + zc=f"{clearance_height:03}", + hd=f"{hotel_depth:04}", + gr={ + GripDirection.FRONT: 1, + GripDirection.RIGHT: 2, + GripDirection.BACK: 3, + GripDirection.LEFT: 4, + }[grip_direction], + th=f"{traverse_height_at_beginning:04}", + te=f"{z_position_at_end:04}", + gw=grip_strength, + go=f"{open_gripper_position:04}", + ga=collision_control, + xe=f"{high_acceleration_index} {low_acceleration_index}", + gc=int(fold_up_at_end), + ) + + async def get_from_hotel( + self, + hotel_center_x_coord: int = 0, + hotel_center_y_coord: int = 0, + hotel_center_z_coord: int = 0, + # for direction, 0 is positive, 1 is negative + hotel_center_x_direction: Literal[0, 1] = 0, + hotel_center_y_direction: Literal[0, 1] = 0, + hotel_center_z_direction: Literal[0, 1] = 0, + clearance_height: int = 50, + hotel_depth: int = 1_300, + grip_direction: GripDirection = GripDirection.FRONT, + traverse_height_at_beginning: int = 3_600, + z_position_at_end: int = 3_600, + grip_strength: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] = 5, + open_gripper_position: int = 860, + plate_width: int = 800, + plate_width_tolerance: int = 20, + collision_control: Literal[0, 1] = 1, + high_acceleration_index: Literal[1, 2, 3, 4] = 4, + low_acceleration_index: Literal[1, 2, 3, 4] = 1, + fold_up_at_end: bool = True, + ): + """ + A hotel is a location to store a plate. This can be a loading + dock for an external machine such as a cytomat or a centrifuge. + + Take care when using this command to interact with hotels located + outside of the hamilton deck area. Ensure that rotations of the + iSWAP arm don't collide with anything. + + tip: set the hotel depth big enough so that the boundary is inside the + hamilton deck. The iSWAP rotations will happen before it enters the hotel. + + The units of all relevant variables are in 0.1mm + """ + + assert 0 <= hotel_center_x_coord <= 99_999 + assert 0 <= hotel_center_y_coord <= 6_500 + assert 0 <= hotel_center_z_coord <= 3_500 + assert 0 <= clearance_height <= 999 + assert 0 <= hotel_depth <= 3_000 + assert 0 <= traverse_height_at_beginning <= 3_600 + assert 0 <= z_position_at_end <= 3_600 + assert 0 <= open_gripper_position <= 9_999 + assert 0 <= plate_width <= 9_999 + assert 0 <= plate_width_tolerance <= 99 + + return await self.star.send_command( + module="C0", + command="PO", + xs=f"{hotel_center_x_coord:05}", + xd=hotel_center_x_direction, + yj=f"{hotel_center_y_coord:04}", + yd=hotel_center_y_direction, + zj=f"{hotel_center_z_coord:04}", + zd=hotel_center_z_direction, + zc=f"{clearance_height:03}", + hd=f"{hotel_depth:04}", + gr={ + GripDirection.FRONT: 1, + GripDirection.RIGHT: 2, + GripDirection.BACK: 3, + GripDirection.LEFT: 4, + }[grip_direction], + th=f"{traverse_height_at_beginning:04}", + te=f"{z_position_at_end:04}", + gw=grip_strength, + go=f"{open_gripper_position:04}", + gb=f"{plate_width:04}", + gt=f"{plate_width_tolerance:02}", + ga=collision_control, + xe=f"{high_acceleration_index} {low_acceleration_index}", + gc=int(fold_up_at_end), + ) + + async def violently_shoot_down_tip(self, channel_idx: int): + """Shoot down the tip on the specified channel by releasing the drive that holds the spring. The + tips will shoot down in place at an acceleration bigger than g. This is done by initializing + the squeezer drive wihile a tip is mounted. + + Safe to do when above a tip rack, for example directly after a tip pickup. + + .. warning:: + + Consider this method an easter egg. Not for serious use. + """ + await self.star.send_command(module=STARBackend.channel_id(channel_idx), command="SI") + + +# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) +# https://github.com/PyLabRobot/pylabrobot/issues/466 + + +class STAR(STARBackend): + def __init__(self, *args, **kwargs): + warnings.warn( + "`STAR` is deprecated and will be removed in a future release. " + "Please use `STARBackend` instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_chatterbox.py new file mode 100644 index 00000000000..2a426177ba0 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -0,0 +1,516 @@ +import copy +import datetime +import logging +import warnings +from contextlib import asynccontextmanager +from dataclasses import replace +from typing import Dict, List, Literal, Optional, Tuple, Union + +from pylabrobot.io.validation_utils import LOG_LEVEL_IO +from pylabrobot.legacy.liquid_handling.backends import LiquidHandlerBackend +from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_backend import ( + DriveConfiguration, + ExtendedConfiguration, + Head96Information, + MachineConfiguration, + STARBackend, + iSWAPInformation, +) +from pylabrobot.resources.container import Container +from pylabrobot.resources.tip_tracker import does_tip_tracking +from pylabrobot.resources.well import Well + +logger = logging.getLogger("pylabrobot") + +_DEFAULT_MACHINE_CONFIGURATION = MachineConfiguration( + pip_type_1000ul=True, + kb_iswap_installed=True, + auto_load_installed=True, + num_pip_channels=8, +) + +_DEFAULT_EXTENDED_CONFIGURATION = ExtendedConfiguration( + left_x_drive_large=True, + iswap_gripper_wide=True, + instrument_size_slots=30, + auto_load_size_slots=30, + tip_waste_x_position=800.0, + left_x_drive=DriveConfiguration(iswap_installed=True, core_96_head_installed=True), + min_iswap_collision_free_position=350.0, + max_iswap_collision_free_position=600.0, +) + +# Minimal left-drive X position of a dual-rail arm. Validated against real hardware; +# the single-rail minimum is not yet known (#822). Only the chatterbox needs this +# literal - a physical STAR reports its own value from the drive-range query. +_DUAL_RAIL_LEFT_X_MIN = 95.0 + +# Hamilton factory defaults. Per-machine EEPROM calibration will differ +# slightly (e.g., L1=137.8, L2=137.7, STRAIGHT=-45.01 on one tested machine); +# these defaults are accurate enough for simulation but not for +# calibration-sensitive applications. +_DEFAULT_ISWAP_INFORMATION = iSWAPInformation( + fw_version="simulated", + rotation_drive_x_offset=34.0, + rotation_drive_y_max=627.4, + link_1_length=138.0, + link_2_length=138.0, + rotation_drive_predefined_increments={ + STARBackend.RotationDriveOrientation.LEFT: -29068, # ~-90 deg + STARBackend.RotationDriveOrientation.FRONT: 0, # ~+0 deg + STARBackend.RotationDriveOrientation.RIGHT: 29068, # ~+90 deg + STARBackend.RotationDriveOrientation.PARKED_RIGHT: 29500, # ~+91 deg + }, + wrist_drive_predefined_increments={ + STARBackend.WristDriveOrientation.RIGHT: -26577, # ~-135 deg + STARBackend.WristDriveOrientation.STRAIGHT: -8859, # ~-45 deg + STARBackend.WristDriveOrientation.LEFT: 8859, # ~+45 deg + STARBackend.WristDriveOrientation.REVERSE: 26577, # ~+135 deg + }, +) + + +class STARChatterboxBackend(STARBackend): + """Chatterbox backend for 'STAR'""" + + def __init__( + self, + num_channels: int = 8, + machine_configuration: MachineConfiguration = _DEFAULT_MACHINE_CONFIGURATION, + extended_configuration: ExtendedConfiguration = _DEFAULT_EXTENDED_CONFIGURATION, + iswap_information: Optional[iSWAPInformation] = None, + channels_minimum_y_spacing: Optional[List[float]] = None, + # deprecated parameters + core96_head_installed: Optional[bool] = None, + iswap_installed: Optional[bool] = None, + ): + """Initialize a chatter box backend. + + Args: + num_channels: Number of pipetting channels (default: 8) + machine_configuration: Machine configuration to return from `request_machine_configuration`. + extended_configuration: Extended configuration to return from `request_extended_configuration`. + iswap_information: Optional override for the simulated iSWAP setup state + (link lengths, EEPROM-calibrated stops, fw version). None means use + `_DEFAULT_ISWAP_INFORMATION` (Hamilton factory defaults). Only used + when the extended configuration reports iSWAP as installed. + channels_minimum_y_spacing: Per-channel minimum Y spacing in mm. If None, defaults to + `extended_configuration.min_raster_pitch_pip_channels` for all channels. + core96_head_installed: Deprecated. Set `extended_configuration.left_x_drive + .core_96_head_installed` instead. + iswap_installed: Deprecated. Set `extended_configuration.left_x_drive + .iswap_installed` instead. + """ + super().__init__() + self._num_channels = num_channels + self._iswap_parked = True + self._sim_iswap_information = iswap_information # None means use default at setup + + if core96_head_installed is not None or iswap_installed is not None: + extended_configuration = copy.deepcopy(extended_configuration) + xl = copy.deepcopy(extended_configuration.left_x_drive) + if core96_head_installed is not None: + warnings.warn( + "core96_head_installed is deprecated. Pass an ExtendedConfiguration with " + "left_x_drive.core_96_head_installed set instead.", + DeprecationWarning, + stacklevel=2, + ) + xl.core_96_head_installed = core96_head_installed + if iswap_installed is not None: + warnings.warn( + "iswap_installed is deprecated. Pass an ExtendedConfiguration with " + "left_x_drive.iswap_installed set instead.", + DeprecationWarning, + stacklevel=2, + ) + xl.iswap_installed = iswap_installed + extended_configuration.left_x_drive = xl + + self._machine_configuration = machine_configuration + self._extended_conf = extended_configuration + + if channels_minimum_y_spacing is not None: + if len(channels_minimum_y_spacing) != num_channels: + raise ValueError( + f"channels_minimum_y_spacing has {len(channels_minimum_y_spacing)} entries, " + f"expected {num_channels}." + ) + self._channels_minimum_y_spacing = list(channels_minimum_y_spacing) + else: + self._channels_minimum_y_spacing = [ + extended_configuration.min_raster_pitch_pip_channels + ] * num_channels + + async def setup( + self, + skip_instrument_initialization=False, + skip_pip=False, + skip_autoload=False, + skip_iswap=False, + skip_core96_head=False, + ): + """Initialize the chatterbox backend and detect installed modules. + + Args: + skip_instrument_initialization: If True, skip instrument initialization. + skip_pip: If True, skip pipetting channel initialization. + skip_autoload: If True, skip initializing the autoload module, if applicable. + skip_iswap: If True, skip initializing the iSWAP module, if applicable. + skip_core96_head: If True, skip initializing the CoRe 96 head module, if applicable. + """ + await LiquidHandlerBackend.setup(self) + + self.id_ = 0 + + # Request machine information + self._machine_conf = await self.request_machine_configuration() + self._extended_conf = await self.request_extended_configuration() + + # Mock firmware information for 96-head if installed + if self.extended_conf.left_x_drive.core_96_head_installed and not skip_core96_head: + fw_version = datetime.date(2023, 1, 1) + instrument_type: Head96Information.InstrumentType = "FM-STAR" + self._head96_information = Head96Information( + fw_version=fw_version, + x_offset=365.0, # factory default; hardware reads the per-machine value from EEPROM (kf) + supports_clot_monitoring_clld=False, + stop_disc_type="core_ii", + instrument_type=instrument_type, + head_type="96 head II", + z_range=self._head96_resolve_z_range(instrument_type), + ) + # Seed the mutable drive defaults from the machine (mirrors STARBackend); the head96_request_* + # overrides below return the canned 2013+ factory registers. + self._head96_y_drive_speed_default = await self.head96_request_y_speed() + self._head96_y_drive_acceleration_default = await self.head96_request_y_acceleration() + self._head96_z_drive_speed_default = await self.head96_request_z_speed() + self._head96_z_drive_acceleration_default = await self.head96_request_z_acceleration() + else: + self._head96_information = None + + # Mock iSWAP setup state if installed. One assignment - constructor override + # (if given) takes precedence over the factory-default record. + if self.extended_conf.left_x_drive.iswap_installed and not skip_iswap: + self._iswap_information = self._sim_iswap_information or _DEFAULT_ISWAP_INFORMATION + else: + self._iswap_information = None + + async def stop(self): + await LiquidHandlerBackend.stop(self) + self._setup_done = False + + # # # # # # # # Low-level command sending/receiving # # # # # # # # + + async def _write_and_read_command( + self, + id_: Optional[int], + cmd: str, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + wait: bool = True, + ) -> Optional[str]: + logger.log(LOG_LEVEL_IO, "%s", cmd) + return None + + async def send_raw_command( + self, + command: str, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + wait: bool = True, + ) -> Optional[str]: + logger.log(LOG_LEVEL_IO, "%s", command) + return None + + # # # # # # # # STAR configuration # # # # # # # # + + async def request_machine_configuration(self) -> MachineConfiguration: + return self._machine_configuration + + async def request_extended_configuration(self) -> ExtendedConfiguration: + """Return the configured extended configuration with X-arm geometry resolved. + + Mirrors STARBackend.request_extended_configuration: fills each installed drive's + geometry from the mocked X-drive range/envelope replies. A right drive that was not + configured (None) stays None. + """ + conf = self._extended_conf + assert conf is not None + ranges = await self.request_maximal_ranges_of_x_drives() + wraps = await self.request_working_envelopes_per_arm() + + def _with_geometry( + drive: Optional[DriveConfiguration], side: str, width: float + ) -> Optional[DriveConfiguration]: + if drive is None: + return None + wrap, workspace_range = wraps[side] + if wrap == 0: # arm not installed + return None + return replace(drive, width=width, x_range=ranges[side], workspace_range=workspace_range) + + left_x_drive = _with_geometry(conf.left_x_drive, "left", conf.left_x_arm_width) + assert left_x_drive is not None, "STAR must have a left X-arm" + + return replace( + conf, + left_x_drive=left_x_drive, + right_x_drive=_with_geometry(conf.right_x_drive, "right", conf.right_x_arm_width), + ) + + def _simulated_x_reach_max(self) -> float: + """Rightmost reachable X (mm) in simulation, from the deck's width. + + The hardware backend reads the true drive travel from the RU query; here there is no + machine, so the deck's own width is the closest bound that covers every on-deck + position without under-reporting reach (a per-rail estimate clips the deck's right edge). + """ + deck = self._deck + if deck is not None: + return deck.get_size_x() + return 1338.0 # nominal STAR reach + + async def request_maximal_ranges_of_x_drives(self) -> Dict[str, Tuple[float, float]]: + x_range = (_DUAL_RAIL_LEFT_X_MIN, self._simulated_x_reach_max()) + return {"left": x_range, "right": x_range} + + async def request_working_envelopes_per_arm( + self, + ) -> Dict[str, Tuple[float, Tuple[float, float]]]: + workspace = (-323.2, self._simulated_x_reach_max()) + left = (595.2, workspace) # wrap, workspace + # A wrap of 0 signals "arm not installed" (per the base method's contract). + right_installed = self.extended_conf.right_x_drive is not None + right = (595.2, workspace) if right_installed else (0.0, (0.0, 0.0)) + return {"left": left, "right": right} + + # # # # # # # # 1_000 uL Channel: Basic Commands # # # # # # # # + + async def request_tip_presence(self) -> List[Optional[bool]]: + """Return mock tip presence based on the tip tracker state. + + Returns: + A list of length `num_channels` where each element is `True` if a tip is mounted, + `False` if not, or `None` if unknown. + """ + return [self.head[ch].has_tip for ch in range(self.num_channels)] + + async def request_z_pos_channel_n(self, channel: int) -> float: + return 285.0 + + async def channel_dispensing_drive_request_position( + self, channel_idx: int, simulated_value: float = 0.0 + ) -> float: + """Override to return mock dispensing drive position. + + This method is called when the system needs to know the current position + of a channel's dispensing drive (e.g., before emptying tips). + + Returns a mock position with a default value of 0.0 for all channels. + """ + if not (0 <= channel_idx < self.num_channels): + raise ValueError(f"channel_idx must be between 0 and {self.num_channels - 1}") + + return simulated_value + + async def channel_request_y_minimum_spacing(self, channel_idx: int) -> float: + """Return mock minimum Y spacing for the given channel. + + Returns the value stored in ``_channels_minimum_y_spacing`` (set during + ``__init__()``) without issuing any hardware commands. + """ + if not 0 <= channel_idx <= self.num_channels - 1: + raise ValueError( + f"channel_idx must be between 0 and {self.num_channels - 1}, got {channel_idx}." + ) + return self._channels_minimum_y_spacing[channel_idx] + + async def channels_request_y_minimum_spacing(self) -> List[float]: + """Return mock per-channel minimum Y spacings for all channels.""" + return list(self._channels_minimum_y_spacing) + + async def move_channel_y(self, channel: int, y: float): + logger.info("moving channel %s to y: %s", channel, y) + + async def move_all_channels_in_z_safety(self): + logger.info("moving all channels to z safety") + + async def position_channels_in_z_direction(self, zs: Dict[int, float]): + logger.info("positioning channels in z: %s", zs) + + # # # # # # # # 1_000 uL Channel: Complex Commands # # # # # # # # + + async def step_off_foil( + self, + wells: Union[Well, List[Well]], + front_channel: int, + back_channel: int, + move_inwards: float = 2, + move_height: float = 15, + ): + logger.info( + "stepping off foil | wells: %s | front channel: %s | " + "back channel: %s | move inwards: %s | move height: %s", + wells, + front_channel, + back_channel, + move_inwards, + move_height, + ) + + async def pierce_foil( + self, + wells: Union[Well, List[Well]], + piercing_channels: List[int], + hold_down_channels: List[int], + move_inwards: float, + spread: Literal["wide", "tight"] = "wide", + one_by_one: bool = False, + distance_from_bottom: float = 20.0, + ): + logger.info( + "piercing foil | wells: %s | piercing channels: %s | " + "hold down channels: %s | move inwards: %s | " + "spread: %s | one by one: %s | distance from bottom: %s", + wells, + piercing_channels, + hold_down_channels, + move_inwards, + spread, + one_by_one, + distance_from_bottom, + ) + + # # # # # # # # Extension: 96-Head # # # # # # # # + + async def head96_request_firmware_version(self) -> datetime.date: + """Return mock 96-head firmware version.""" + return datetime.date(2023, 1, 1) + + # The Y/Z drive speed/acceleration registers a 2013+ (2023 mock) head reports at setup, returned + # through the real unit conversions so the seeded defaults match a live machine's factory values. + async def head96_request_y_speed(self) -> float: + return self._head96_y_drive_increment_to_mm(25000) + + async def head96_request_y_acceleration(self) -> float: + return self._head96_y_drive_increment_to_mm(35000) + + async def head96_request_z_speed(self) -> float: + return 85.0 + + async def head96_request_z_acceleration(self) -> float: + return 400.0 + + async def head96_request_tip_presence(self) -> int: + """Mock 96-head tip presence from the tip tracker: 1 if any channel holds a tip, else 0. + + Raises if tip tracking is disabled, since the tracker is then not updated and has no state to report. + """ + if not does_tip_tracking() or self.head96 is None: + raise RuntimeError( + "cannot report 96-head tip presence with tip tracking disabled in simulation; " + "enable it with set_tip_tracking(True) or call with requires_tip=False" + ) + return int(any(tracker.has_tip for tracker in self.head96.values())) + + # # # # # # # # Extension: iSWAP # # # # # # # # + + async def request_iswap_initialization_status(self) -> bool: + """Return mock iSWAP initialization status.""" + return True + + @property + def iswap_parked(self) -> bool: + return self._iswap_parked is True + + async def move_iswap_x( + self, + x_position: float, + acceleration_level: int = 3, + current_protection_limiter: int = 7, + ): + logger.info("moving iswap x to %s", x_position) + + async def move_iswap_y( + self, + y_position: float, + speed: float = 220.0, + acceleration_level: int = 2, + current_protection_limiter: int = 7, + make_space: bool = False, + ): + logger.info("moving iswap y to %s", y_position) + + async def move_iswap_z( + self, + z_position: float, + speed: float = 118.0, + acceleration: float = 643.66, + current_protection_limiter: int = 6, + ): + logger.info("moving iswap z to %s", z_position) + + @asynccontextmanager + async def slow_iswap(self, wrist_velocity: int = 20_000, gripper_velocity: int = 20_000): + """A context manager that sets the iSWAP to slow speed during the context.""" + assert 20 <= gripper_velocity <= 75_000, "Gripper velocity out of range." + assert 20 <= wrist_velocity <= 65_000, "Wrist velocity out of range." + + messages = ["start slow iswap"] + try: + yield + finally: + messages.append("end slow iswap") + logger.info("%s", " | ".join(messages)) + + # # # # # # # # Liquid Level Detection (LLD) # # # # # # # # + + async def request_tip_len_on_channel(self, channel_idx: int) -> float: + """Return tip length from the tip tracker. + + Args: + channel_idx: Index of the pipetting channel (0-indexed). + + Returns: + The tip length in mm from the tip tracker. + + Raises: + NoTipError: If no tip is present on the channel (via tip tracker). + """ + tip = self.head[channel_idx].get_tip() + return tip.total_tip_length + + async def position_channels_in_y_direction(self, ys, make_space=True): + logger.info("positioning channels in y: %s make_space: %s", ys, make_space) + + async def request_pip_height_last_lld(self): + return list(range(12)) + + async def _run_lld_on_channel_batch( + self, + batch, + containers: List[Container], + tip_lengths: List[float], + z_cavity_bottom: List[float], + z_top: List[float], + lld_mode: List["STARBackend.LLDMode"], + search_speed: float, + n_replicates: int, + ) -> Dict[int, List[Optional[float]]]: + """Simulate LLD by computing absolute heights from each container's volume tracker. + + Empty containers report the cavity-bottom Z (relative height 0). Non-empty + containers report ``cavity_bottom + compute_height_from_volume(volume)`` so the + parent ``probe_liquid_heights`` can subtract ``z_cavity_bottom`` consistently. + """ + measurements: Dict[int, List[Optional[float]]] = {} + for orig_idx in batch.indices: + container = containers[orig_idx] + volume = container.tracker.get_used_volume() + if volume == 0: + absolute_height = z_cavity_bottom[orig_idx] + else: + absolute_height = z_cavity_bottom[orig_idx] + container.compute_height_from_volume(volume) + measurements[orig_idx] = [absolute_height] * n_replicates + return measurements diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_tests.py similarity index 99% rename from pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_tests.py index 592ff2fb744..4ab384fa381 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_tests.py @@ -8,11 +8,11 @@ from dataclasses import replace from typing import Literal, cast -from pylabrobot.arms.standard import CartesianCoords -from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.standard import GripDirection, Mix, Pickup -from pylabrobot.plate_reading import PlateReader -from pylabrobot.plate_reading.chatterbox import PlateReaderChatterboxBackend +from pylabrobot.legacy.arms.standard import CartesianCoords +from pylabrobot.legacy.liquid_handling import LiquidHandler +from pylabrobot.legacy.liquid_handling.standard import GripDirection, Mix, Pickup +from pylabrobot.legacy.plate_reading import PlateReader +from pylabrobot.legacy.plate_reading.chatterbox import PlateReaderChatterboxBackend from pylabrobot.resources import ( PLT_CAR_L5AC_A00, PLT_CAR_L5MD_A00, diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/__init__.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/__init__.py new file mode 100644 index 00000000000..4c36917049f --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/__init__.py @@ -0,0 +1,6 @@ +"""Hamilton backends for liquid handling.""" + +from .base import HamiltonLiquidHandler +from .pump import Pump # TODO: move elsewhere. +from .STAR_backend import STAR +from .vantage_backend import Vantage diff --git a/pylabrobot/liquid_handling/backends/hamilton/base.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/base.py similarity index 99% rename from pylabrobot/liquid_handling/backends/hamilton/base.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/base.py index eab062ea57b..7dd688b0bd2 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/base.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/base.py @@ -16,10 +16,10 @@ ) from pylabrobot.io.usb import USB -from pylabrobot.liquid_handling.backends.backend import ( +from pylabrobot.legacy.liquid_handling.backends.backend import ( LiquidHandlerBackend, ) -from pylabrobot.liquid_handling.standard import PipettingOp +from pylabrobot.legacy.liquid_handling.standard import PipettingOp from pylabrobot.resources import TipSpot from pylabrobot.resources.hamilton import ( HamiltonTip, diff --git a/pylabrobot/liquid_handling/backends/hamilton/common.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/common.py similarity index 100% rename from pylabrobot/liquid_handling/backends/hamilton/common.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/common.py diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend.py similarity index 99% rename from pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend.py index 7018e22590b..8536e33d939 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend.py @@ -10,21 +10,21 @@ import logging from typing import Dict, List, Optional, Sequence, Tuple, TypeVar, Union -from pylabrobot.liquid_handling.backends.hamilton.common import fill_in_defaults -from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.common import fill_in_defaults +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.introspection import ( HamiltonIntrospection, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.messages import ( HoiParams, HoiParamsParser, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import ( HamiltonProtocol, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPBackend -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPBackend +from pylabrobot.legacy.liquid_handling.standard import ( Drop, DropTipRack, MultiHeadAspirationContainer, diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend_tests.py similarity index 98% rename from pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend_tests.py index 84a8003de2a..ecf883f65a7 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend_tests.py @@ -8,7 +8,7 @@ import unittest.mock from typing import Optional -from pylabrobot.liquid_handling.backends.hamilton.nimbus_backend import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.nimbus_backend import ( Aspirate, DisableADC, Dispense, @@ -31,10 +31,13 @@ UnlockDoor, _get_tip_type_from_tip, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams, HoiParamsParser -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.messages import ( + HoiParams, + HoiParamsParser, +) +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.legacy.liquid_handling.standard import ( Drop, Pickup, SingleChannelAspiration, diff --git a/pylabrobot/liquid_handling/backends/hamilton/pump.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/pump.py similarity index 93% rename from pylabrobot/liquid_handling/backends/hamilton/pump.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/pump.py index 615da53efae..3fd04ba64e4 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/pump.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/pump.py @@ -1,4 +1,4 @@ -from pylabrobot.liquid_handling.backends.hamilton.STAR_backend import STAR +from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_backend import STAR from pylabrobot.resources import Coordinate, Resource diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/__init__.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/__init__.py similarity index 100% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/__init__.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/__init__.py diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/commands.py similarity index 95% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/commands.py index 633a6b15c01..89dc55894e6 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/commands.py @@ -9,13 +9,13 @@ import inspect from typing import Optional -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.messages import ( CommandMessage, CommandResponse, HoiParams, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol class HamiltonCommand: diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/introspection.py similarity index 98% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/introspection.py index 247de40fde1..5e19c55d7a2 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/introspection.py @@ -11,10 +11,13 @@ from dataclasses import dataclass, field from typing import Any, Dict, List -from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams, HoiParamsParser -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.messages import ( + HoiParams, + HoiParamsParser, +) +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import ( HamiltonDataType, HamiltonProtocol, ) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/messages.py similarity index 99% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/messages.py index df32f5289ab..d2f6bb98729 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/messages.py @@ -38,14 +38,14 @@ from typing import Any from pylabrobot.io.binary import Reader, Writer -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import ( Address, HarpPacket, HoiPacket, IpPacket, RegistrationPacket, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import ( HamiltonDataType, HarpTransportableProtocol, RegistrationOptionType, diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/packets.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/packets.py similarity index 100% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/packets.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/packets.py diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/protocol.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/protocol.py new file mode 100644 index 00000000000..9e916e91db3 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/protocol.py @@ -0,0 +1,178 @@ +"""Hamilton TCP protocol constants and enumerations. + +This module contains all protocol-level constants, enumerations, and type definitions +used throughout the Hamilton TCP communication stack. +""" + +from __future__ import annotations + +from enum import IntEnum + +# Hamilton protocol version (from Piglet: version byte 0x30 = major 3, minor 0) +HAMILTON_PROTOCOL_VERSION_MAJOR = 3 +HAMILTON_PROTOCOL_VERSION_MINOR = 0 + + +class HamiltonProtocol(IntEnum): + """Hamilton protocol identifiers. + + These values are derived from the piglet Rust implementation: + - Protocol 2: PIPETTE - pipette-specific operations + - Protocol 3: REGISTRATION - object registration and discovery + - Protocol 6: OBJECT_DISCOVERY - general object discovery and method calls + - Protocol 7: INITIALIZATION - connection initialization and client ID negotiation + """ + + PIPETTE = 0x02 + REGISTRATION = 0x03 + OBJECT_DISCOVERY = 0x06 + INITIALIZATION = 0x07 + + +class Hoi2Action(IntEnum): + """HOI2/HARP2 action codes (bits 0-3 of action field). + + Values from Hamilton.Components.TransportLayer.Protocols.HoiPacket2Constants.Hoi2Action + + The action byte combines the action code (lower 4 bits) with the response_required flag (bit 4): + - action_byte = action_code | (0x10 if response_required else 0x00) + - Example: COMMAND_REQUEST with response = 3 | 0x10 = 0x13 + - Example: STATUS_REQUEST without response = 0 | 0x00 = 0x00 + + Common action codes: + - COMMAND_REQUEST (3): Send a command to an object (most common for method calls) + - STATUS_REQUEST (0): Request status information + - COMMAND_RESPONSE (4): Response to a command + - STATUS_RESPONSE (1): Response with status information + + NOTE: According to Hamilton documentation, both HARP2 and HOI2 use the same action + enumeration values. This needs verification through TCP introspection. + """ + + STATUS_REQUEST = 0 + STATUS_RESPONSE = 1 + STATUS_EXCEPTION = 2 + COMMAND_REQUEST = 3 + COMMAND_RESPONSE = 4 + COMMAND_EXCEPTION = 5 + COMMAND_ACK = 6 + UPSTREAM_SYSTEM_EVENT = 7 + DOWNSTREAM_SYSTEM_EVENT = 8 + EVENT = 9 + INVALID_ACTION_RESPONSE = 10 + STATUS_WARNING = 11 + COMMAND_WARNING = 12 + + +class HarpTransportableProtocol(IntEnum): + """HARP2 protocol field values - determines payload type. + + From Hamilton.Components.TransportLayer.Protocols.HarpTransportableProtocol. + The protocol field at byte 14 in HARP2 tells which payload parser to use. + """ + + HOI2 = 2 # Payload is Hoi2 structure (Protocol 2) + REGISTRATION2 = 3 # Payload is Registration2 structure (Protocol 3) + NOT_DEFINED = 0xFF # Invalid/unknown protocol + + +class RegistrationActionCode(IntEnum): + """Registration2 action codes (bytes 0-1 in Registration2 packet). + + From Hamilton.Components.TransportLayer.Protocols.RegistrationPacket2Constants.RegistrationActionCode2. + + Note: HARP action values for Registration packets are different from HOI action codes: + - 0x13 (19): Request with response required (typical for HARP_PROTOCOL_REQUEST) + - 0x14 (20): Response with data (typical for HARP_PROTOCOL_RESPONSE) + - 0x03 (3): Request without response + """ + + REGISTRATION_REQUEST = 0 # Initial registration handshake + REGISTRATION_RESPONSE = 1 # Response to registration + DEREGISTRATION_REQUEST = 2 # Cleanup on disconnect + DEREGISTRATION_RESPONSE = 3 # Deregistration acknowledgment + NODE_RESET_INDICATION = 4 # Node will reset + BRIDGE_REGISTRATION_REQUEST = 5 # Bridge registration + START_NODE_IDENTIFICATION = 6 # Start identification + START_NODE_IDENTIFICATION_RESPONSE = 7 + STOP_NODE_IDENTIFICATION = 8 # Stop identification + STOP_NODE_IDENTIFICATION_RESPONSE = 9 + LIST_OF_REGISTERED_MODULES_REQUEST = 10 # Request registered modules + LIST_OF_REGISTERED_MODULES_RESPONSE = 11 + HARP_PROTOCOL_REQUEST = 12 # Request objects (most important!) + HARP_PROTOCOL_RESPONSE = 13 # Response with object list + HARP_NODE_REMOVED_FROM_NETWORK = 14 + LIST_OF_REGISTERED_NODES_REQUEST = 15 + LIST_OF_REGISTERED_NODES_RESPONSE = 16 + + +class RegistrationOptionType(IntEnum): + """Registration2 option types (byte 0 of each option). + + From Hamilton.Components.TransportLayer.Protocols.RegistrationPacket2Constants.Option. + + These are semantic labels for the TYPE of information (what it means), while the + actual data inside uses Hamilton type_ids (how it's encoded). + """ + + RESERVED = 0 # Padding for 16-bit alignment when odd number of unsupported options + INCOMPATIBLE_VERSION = 1 # Version mismatch error (HARP version too high) + UNSUPPORTED_OPTIONS = 2 # Unknown options error + START_NODE_IDENTIFICATION = 3 # Identification timeout (seconds) + HARP_NETWORK_ADDRESS = 4 # Registered module/node IDs + HARP_PROTOCOL_REQUEST = 5 # Protocol request + HARP_PROTOCOL_RESPONSE = 6 # PRIMARY: Contains object ID lists (most commonly used) + + +class HamiltonDataType(IntEnum): + """Hamilton parameter data types for wire encoding in DataFragments. + + These constants represent the type identifiers used in Hamilton DataFragments + for HOI2 command parameters. Each type ID corresponds to a specific data format + and encoding scheme used on the wire. + + From Hamilton.Components.TransportLayer.Protocols.Parameter.ParameterTypes. + """ + + # Scalar integer types + I8 = 1 + I16 = 2 + I32 = 3 + U8 = 4 + U16 = 5 + U32 = 6 + I64 = 36 + U64 = 37 + + # Floating-point types + F32 = 40 + F64 = 41 + + # String and boolean + STRING = 15 + BOOL = 23 + + # Array types + U8_ARRAY = 22 + I8_ARRAY = 24 + I16_ARRAY = 25 + U16_ARRAY = 26 + I32_ARRAY = 27 + U32_ARRAY = 28 + BOOL_ARRAY = 29 + STRING_ARRAY = 34 + I64_ARRAY = 38 + U64_ARRAY = 39 + F32_ARRAY = 42 + F64_ARRAY = 43 + + +class HoiRequestId(IntEnum): + """Request types for HarpProtocolRequest (byte 3 in command_data). + + From Hamilton.Components.TransportLayer.Protocols.RegistrationPacket2Constants.HarpProtocolRequest.HoiRequestId. + """ + + ROOT_OBJECT_OBJECT_ID = 1 # Request root objects (pipette, deck, etc.) + GLOBAL_OBJECT_ADDRESS = 2 # Request global objects + CPU_OBJECT_ADDRESS = 3 # Request CPU objects diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/tcp_tests.py similarity index 99% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/tcp_tests.py index 80c249a424a..ca670469dc3 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/tcp_tests.py @@ -6,8 +6,8 @@ import unittest -from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.messages import ( CommandMessage, CommandResponse, HoiParams, @@ -17,7 +17,7 @@ RegistrationMessage, RegistrationResponse, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import ( Address, HarpPacket, HoiPacket, @@ -26,7 +26,7 @@ decode_version_byte, encode_version_byte, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import ( HamiltonDataType, HamiltonProtocol, Hoi2Action, diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp_backend.py similarity index 97% rename from pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/tcp_backend.py index 9c6a9acbb13..3792732dd5a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp_backend.py @@ -13,17 +13,17 @@ from pylabrobot.io.binary import Reader from pylabrobot.io.socket import Socket -from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend -from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( +from pylabrobot.legacy.liquid_handling.backends.backend import LiquidHandlerBackend +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.messages import ( CommandResponse, InitMessage, InitResponse, RegistrationMessage, RegistrationResponse, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import ( Hoi2Action, HoiRequestId, RegistrationActionCode, diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py similarity index 99% rename from pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py index a68b8375790..07c02e4fc0c 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py @@ -5,14 +5,14 @@ import warnings from typing import Dict, List, Optional, Sequence, Union, cast -from pylabrobot.liquid_handling.backends.hamilton.base import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.base import ( HamiltonLiquidHandler, ) -from pylabrobot.liquid_handling.liquid_classes.hamilton import ( +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton import ( HamiltonLiquidClass, get_vantage_liquid_class, ) -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.standard import ( Drop, DropTipRack, MultiHeadAspirationContainer, diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_tests.py similarity index 99% rename from pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_tests.py index 2f168b8d22e..ca3209b395b 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_tests.py @@ -1,8 +1,8 @@ import unittest from typing import Any, List, Optional -from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.standard import Pickup +from pylabrobot.legacy.liquid_handling import LiquidHandler +from pylabrobot.legacy.liquid_handling.standard import Pickup from pylabrobot.resources import ( PLT_CAR_L5AC_A00, TIP_CAR_480_A00, diff --git a/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py new file mode 100644 index 00000000000..31327da546d --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py @@ -0,0 +1,757 @@ +import inspect +import logging +import uuid +from typing import Any, Dict, List, Optional, Tuple, Union, cast + +from pylabrobot import utils +from pylabrobot.io import LOG_LEVEL_IO +from pylabrobot.legacy.liquid_handling.backends.backend import ( + LiquidHandlerBackend, +) +from pylabrobot.legacy.liquid_handling.errors import NoChannelError +from pylabrobot.legacy.liquid_handling.standard import ( + Drop, + DropTipRack, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, +) +from pylabrobot.resources import ( + Coordinate, + Tip, +) +from pylabrobot.resources.opentrons import OTDeck +from pylabrobot.resources.tip_rack import TipRack + +try: + import ot_api + + USE_OT = True +except ImportError as e: + USE_OT = False + _OT_IMPORT_ERROR = e + + +# https://github.com/Opentrons/opentrons/issues/14590 +# https://labautomation.io/t/connect-pylabrobot-to-ot2/2862/18 +_OT_DECK_IS_ADDRESSABLE_AREA_VERSION = "7.1.0" + +logger = logging.getLogger(__name__) + + +class _IOLogger: + """Transparent proxy over the ``ot_api`` module that logs every call at + ``LOG_LEVEL_IO``. + + The OT-2 talks HTTP through ``ot_api`` rather than a pylabrobot.io transport, so + this wrapper gives it the same wire-level logging every other backend gets from + its io object. Submodules (``lh``, ``health``, ...) are wrapped recursively; + plain attributes (e.g. ``run_id``) pass through untouched. + """ + + def __init__(self, target: Any, prefix: str = ""): + self._target = target + self._prefix = prefix + + def __getattr__(self, name: str) -> Any: + attr = getattr(self._target, name) + qualified = f"{self._prefix}.{name}" if self._prefix else name + if inspect.ismodule(attr): + return _IOLogger(attr, qualified) + if callable(attr): + + def _logged(*args, **kwargs): + parts = [repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()] + logger.log(LOG_LEVEL_IO, "%s(%s)", qualified, ", ".join(parts)) + return attr(*args, **kwargs) + + return _logged + return attr + + +class OpentronsOT2Backend(LiquidHandlerBackend): + """Backends for the Opentrons OT2 liquid handling robots.""" + + pipette_name2volume = { + "p10_single": 10, + "p10_multi": 10, + "p20_single_gen2": 20, + "p20_multi_gen2": 20, + "p50_single": 50, + "p50_multi": 50, + "p300_single": 300, + "p300_multi": 300, + "p300_single_gen2": 300, + "p300_multi_gen2": 300, + "p1000_single": 1000, + "p1000_single_gen2": 1000, + "p300_single_gen3": 300, + "p1000_single_gen3": 1000, + } + + def __init__(self, host: str, port: int = 31950): + super().__init__() + + if not USE_OT: + raise RuntimeError( + "Opentrons is not installed. Please run pip install pylabrobot[opentrons]." + f" Import error: {_OT_IMPORT_ERROR}." + ) + + self.host = host + self.port = port + + # All hardware I/O goes through this handle so a subclass (e.g. the chatterbox) + # can dry-run the backend by swapping it for a recording stand-in. The real handle + # wraps ot_api to log every HTTP call at LOG_LEVEL_IO, like other backends' io. + self._ot: Any = _IOLogger(ot_api) + + self._ot.set_host(host) + self._ot.set_port(port) + + self.ot_api_version: Optional[str] = None + self.left_pipette: Optional[Dict[str, str]] = None + self.right_pipette: Optional[Dict[str, str]] = None + + self.traversal_height = 120 # test + self._tip_racks: Dict[str, int] = {} # tip_rack.name -> slot index + self._plr_name_to_load_name: Dict[str, str] = {} + + def serialize(self) -> dict: + return { + **super().serialize(), + "host": self.host, + "port": self.port, + } + + async def setup(self, skip_home: bool = False): + # create run + run_id = self._ot.runs.create() + self._ot.set_run(run_id) + + # get pipettes, then assign them + self.left_pipette, self.right_pipette = self._ot.lh.add_mounted_pipettes() + + self.left_pipette_has_tip = self.right_pipette_has_tip = False + + # get api version + health = self._ot.health.get() + self.ot_api_version = health["api_version"] + + if not skip_home: + await self.home() + + @property + def num_channels(self) -> int: + return len([p for p in [self.left_pipette, self.right_pipette] if p is not None]) + + async def stop(self): + """Cancel any active OT run, then clear labware definitions.""" + self._plr_name_to_load_name = {} + self._tip_racks = {} + self.left_pipette = None + self.right_pipette = None + + # cancel the HTTP-API run if it exists (helpful to make device available again in official Opentrons app) + run_id = getattr(self._ot, "run_id", None) + if run_id: + try: + self._ot.requestor.post(f"/runs/{run_id}/cancel") + except Exception: + try: + self._ot.requestor.post(f"/runs/{run_id}/actions/cancel") + except Exception: + try: + self._ot.requestor.delete(f"/runs/{run_id}") + except Exception: + pass + + def get_ot_name(self, plr_resource_name: str) -> str: + """Opentrons only allows names in ^[a-z0-9._]+$, but in PLR we are flexible. + So we map PLR names to OT names here. + """ + if plr_resource_name not in self._plr_name_to_load_name: + ot_load_name = uuid.uuid4().hex + self._plr_name_to_load_name[plr_resource_name] = ot_load_name + return self._plr_name_to_load_name[plr_resource_name] + + def select_tip_pipette(self, tip: Tip, with_tip: bool) -> Optional[str]: + """Select a pipette based on maximum tip volume for tip pick up or drop. + + The volume of the head must match the maximum tip volume. If both pipettes have the same + maximum volume, the left pipette is selected. + + Args: + with_tip: If True, get a channel that has a tip. + + Returns: + The id of the pipette, or None if no pipette is available. + """ + + if self.can_pick_up_tip(0, tip) and with_tip == self.left_pipette_has_tip: + assert self.left_pipette is not None + return cast(str, self.left_pipette["pipetteId"]) + + if self.can_pick_up_tip(1, tip) and with_tip == self.right_pipette_has_tip: + assert self.right_pipette is not None + return cast(str, self.right_pipette["pipetteId"]) + + return None + + async def _assign_tip_rack(self, tip_rack: TipRack, tip: Tip): + ot_slot_size_y = 86 + lw = { + "schemaVersion": 2, + "version": 1, + "namespace": "pylabrobot", + "metadata": { + "displayName": self.get_ot_name(tip_rack.name), + "displayCategory": "tipRack", + "displayVolumeUnits": "µL", + }, + "brand": { + "brand": "unknown", + }, + "parameters": { + "format": "96Standard", + "isTiprack": True, + # should we get the tip length from calibration on the robot? /calibration/tip_length + "tipLength": tip.total_tip_length, + "tipOverlap": tip.fitting_depth, + "loadName": self.get_ot_name(tip_rack.name), + "isMagneticModuleCompatible": False, # do we really care? If yes, store. + }, + "ordering": utils.reshape_2d( + [self.get_ot_name(tip_spot.name) for tip_spot in tip_rack.get_all_items()], + (tip_rack.num_items_x, tip_rack.num_items_y), + ), + "cornerOffsetFromSlot": { + "x": 0, + "y": ot_slot_size_y + - tip_rack.get_absolute_size_y(), # hinges push it to the back (PLR is LFB, OT is LBB) + "z": 0, + }, + "dimensions": { + "xDimension": tip_rack.get_absolute_size_x(), + "yDimension": tip_rack.get_absolute_size_y(), + "zDimension": tip_rack.get_absolute_size_z(), + }, + "wells": { + self.get_ot_name(child.name): { + "depth": child.get_absolute_size_z(), + "x": cast(Coordinate, child.location).x + child.get_absolute_size_x() / 2, + "y": cast(Coordinate, child.location).y + child.get_absolute_size_y() / 2, + "z": cast(Coordinate, child.location).z, + "shape": "circular", + "diameter": child.get_absolute_size_x(), + "totalLiquidVolume": tip.maximal_volume, + } + for child in tip_rack.children + }, + "groups": [ + { + "wells": [self.get_ot_name(tip_spot.name) for tip_spot in tip_rack.get_all_items()], + "metadata": { + "displayName": None, + "displayCategory": "tipRack", + "wellBottomShape": "flat", # required even for tip racks + }, + } + ], + } + + data = self._ot.labware.define(lw) + namespace, definition, version = data["data"]["definitionUri"].split("/") + + # assign labware to robot + labware_uuid = self.get_ot_name(tip_rack.name) + + deck = tip_rack.parent + while deck is not None and not isinstance(deck, OTDeck): + deck = deck.parent # labware sits in a slot holder, whose parent is the deck + assert isinstance(deck, OTDeck) + slot = deck.get_slot(tip_rack) + assert slot is not None, "tip rack must be on deck" + + self._ot.labware.add( + load_name=definition, + namespace=namespace, + ot_location=slot, + version=version, + labware_id=labware_uuid, + display_name=self.get_ot_name(tip_rack.name), + ) + + self._tip_racks[tip_rack.name] = slot + + def _get_pickup_pipette(self, ops: List[Pickup]) -> str: + """Get the pipette for a tip pick-up, or raise.""" + assert len(ops) == 1, "only one channel supported for now" + op = ops[0] + assert op.resource.parent is not None, "must not be a floating resource" + pipette_id = self.select_tip_pipette(op.tip, with_tip=False) + if not pipette_id: + raise NoChannelError("No pipette channel of right type with no tip available.") + return pipette_id + + def _get_drop_pipette(self, ops: List[Drop]) -> str: + """Get the pipette for a tip drop, or raise.""" + assert len(ops) == 1, "only one channel supported for now" + op = ops[0] + assert op.resource.parent is not None, "must not be a floating resource" + pipette_id = self.select_tip_pipette(op.tip, with_tip=True) + if not pipette_id: + raise NoChannelError("No pipette channel of right type with tip available.") + return pipette_id + + def _get_liquid_pipette( + self, ops: Union[List[SingleChannelAspiration], List[SingleChannelDispense]] + ) -> str: + """Get the pipette for an aspirate/dispense, or raise.""" + assert len(ops) == 1, "only one channel supported for now" + pipette_id = self.select_liquid_pipette(ops[0].volume) + if pipette_id is None: + raise NoChannelError("No pipette channel of right type with tip available.") + return pipette_id + + def _set_tip_state(self, pipette_id: str, has_tip: bool): + """Update tip-mounted state for the pipette that was used. + + This method now validates the provided ``pipette_id`` against both the left + and right pipette configurations. It updates the state only if the ID + matches a known, configured pipette; otherwise it raises an error to avoid + silently putting the backend into an inconsistent state. + """ + if self.left_pipette is not None and pipette_id == self.left_pipette["pipetteId"]: + self.left_pipette_has_tip = has_tip + return + + if self.right_pipette is not None and pipette_id == self.right_pipette["pipetteId"]: + self.right_pipette_has_tip = has_tip + return + + raise ValueError(f"Unknown or unconfigured pipette_id {pipette_id!r} in _set_tip_state.") + + async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int]): + """Pick up tips from the specified resource.""" + + pipette_id = self._get_pickup_pipette(ops) + op = ops[0] + + offset_x, offset_y, offset_z = ( + op.offset.x, + op.offset.y, + op.offset.z, + ) + + # define tip rack JIT if it's not already assigned + tip_rack = op.resource.parent + assert isinstance(tip_rack, TipRack), "TipSpot's parent must be a TipRack." + if tip_rack.name not in self._tip_racks: + await self._assign_tip_rack(tip_rack, op.tip) + + offset_z += op.tip.total_tip_length + + self._ot.lh.pick_up_tip( + labware_id=self.get_ot_name(tip_rack.name), + well_name=self.get_ot_name(op.resource.name), + pipette_id=pipette_id, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + ) + + self._set_tip_state(pipette_id, True) + + async def drop_tips(self, ops: List[Drop], use_channels: List[int]): + """Drop tips from the specified resource.""" + + pipette_id = self._get_drop_pipette(ops) + op = ops[0] + + use_fixed_trash = ( + cast(str, self.ot_api_version) >= _OT_DECK_IS_ADDRESSABLE_AREA_VERSION + and op.resource.name == "trash" + ) + if use_fixed_trash: + labware_id = "fixedTrash" + else: + tip_rack = op.resource.parent + assert isinstance(tip_rack, TipRack), "TipSpot's parent must be a TipRack." + if tip_rack.name not in self._tip_racks: + await self._assign_tip_rack(tip_rack, op.tip) + labware_id = self.get_ot_name(tip_rack.name) + + offset_x, offset_y, offset_z = ( + op.offset.x, + op.offset.y, + op.offset.z, + ) + + # ad-hoc offset adjustment that makes it smoother. + offset_z += 10 + + if use_fixed_trash: + self._ot.lh.move_to_addressable_area_for_drop_tip( + pipette_id=pipette_id, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + ) + self._ot.lh.drop_tip_in_place(pipette_id=pipette_id) + else: + self._ot.lh.drop_tip( + labware_id, + well_name=self.get_ot_name(op.resource.name), + pipette_id=pipette_id, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + ) + + self._set_tip_state(pipette_id, False) + + def select_liquid_pipette(self, volume: float) -> Optional[str]: + """Select a pipette based on volume for an aspiration or dispense. + + The volume of the tip mounted on the head must be greater than the volume to aspirate or + dispense. If both pipettes have the same maximum volume, the left pipette is selected. + + Only heads with a tip are considered. + + Args: + volume: The volume to aspirate or dispense. + + Returns: + The id of the pipette, or None if no pipette is available. + """ + + if self.left_pipette is not None: + left_volume = OpentronsOT2Backend.pipette_name2volume[self.left_pipette["name"]] + if left_volume >= volume and self.left_pipette_has_tip: + return cast(str, self.left_pipette["pipetteId"]) + + if self.right_pipette is not None: + right_volume = OpentronsOT2Backend.pipette_name2volume[self.right_pipette["name"]] + if right_volume >= volume and self.right_pipette_has_tip: + return cast(str, self.right_pipette["pipetteId"]) + + return None + + def get_pipette_name(self, pipette_id: str) -> str: + """Get the name of a pipette from its id.""" + + if self.left_pipette is not None and pipette_id == self.left_pipette["pipetteId"]: + return cast(str, self.left_pipette["name"]) + if self.right_pipette is not None and pipette_id == self.right_pipette["pipetteId"]: + return cast(str, self.right_pipette["name"]) + raise ValueError(f"Unknown pipette id: {pipette_id}") + + def _get_default_aspiration_flow_rate(self, pipette_name: str) -> float: + """Get the default aspiration flow rate for the specified pipette in uL/s. + + Data from https://archive.ph/ZUN9f + """ + + return { + "p300_multi_gen2": 94, + "p10_single": 5, + "p10_multi": 5, + "p50_single": 25, + "p50_multi": 25, + "p300_single": 150, + "p300_multi": 150, + "p1000_single": 500, + "p20_single_gen2": 3.78, + "p300_single_gen2": 46.43, + "p1000_single_gen2": 137.35, + "p20_multi_gen2": 7.6, + }[pipette_name] + + def _deck_to_robot_frame(self, location: Coordinate) -> Coordinate: + """Convert a deck-frame coordinate to the OT-2 robot frame. + + pylabrobot positions OT deck slots from the deck plate corner, whereas the OT-2 motion API + expects coordinates in the robot frame whose origin is slot 1's corner. The two frames differ by + slot 1's position in the deck frame, so subtract it. + """ + return location - cast(OTDeck, self.deck).slot_locations[0] + + async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int]): + """Aspirate liquid from the specified resource using pip.""" + + pipette_id = self._get_liquid_pipette(ops) + op = ops[0] + volume = op.volume + + pipette_name = self.get_pipette_name(pipette_id) + flow_rate = op.flow_rate or self._get_default_aspiration_flow_rate(pipette_name) + + location = self._deck_to_robot_frame( + op.resource.get_location_wrt(self.deck, "c", "c", "cavity_bottom") + + op.offset + + Coordinate(z=op.liquid_height or 0) + ) + + await self.move_pipette_head( + location=location, + minimum_z_height=self.traversal_height, + pipette_id=pipette_id, + ) + + if op.mix is not None: + for _ in range(op.mix.repetitions): + self._ot.lh.aspirate_in_place( + volume=op.mix.volume, + flow_rate=op.mix.flow_rate, + pipette_id=pipette_id, + ) + self._ot.lh.dispense_in_place( + volume=op.mix.volume, + flow_rate=op.mix.flow_rate, + pipette_id=pipette_id, + ) + + self._ot.lh.aspirate_in_place( + volume=volume, + flow_rate=flow_rate, + pipette_id=pipette_id, + ) + + traversal_location = self._deck_to_robot_frame( + op.resource.get_location_wrt(self.deck, "c", "c", "cavity_bottom") + op.offset + ) + traversal_location.z = self.traversal_height + await self.move_pipette_head( + location=traversal_location, + minimum_z_height=self.traversal_height, + pipette_id=pipette_id, + ) + + def _get_default_dispense_flow_rate(self, pipette_name: str) -> float: + """Get the default dispense flow rate for the specified pipette. + + Data from https://archive.ph/ZUN9f + + Returns: + The default flow rate in ul/s. + """ + + return { + "p300_multi_gen2": 94, + "p10_single": 10, + "p10_multi": 10, + "p50_single": 50, + "p50_multi": 50, + "p300_single": 300, + "p300_multi": 300, + "p1000_single": 1000, + "p20_single_gen2": 7.56, + "p300_single_gen2": 92.86, + "p1000_single_gen2": 274.7, + "p20_multi_gen2": 7.6, + }[pipette_name] + + async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[int]): + """Dispense liquid from the specified resource using pip.""" + + pipette_id = self._get_liquid_pipette(ops) + op = ops[0] + volume = op.volume + + pipette_name = self.get_pipette_name(pipette_id) + flow_rate = op.flow_rate or self._get_default_dispense_flow_rate(pipette_name) + + location = self._deck_to_robot_frame( + op.resource.get_location_wrt(self.deck, "c", "c", "cavity_bottom") + + op.offset + + Coordinate(z=op.liquid_height or 0) + ) + await self.move_pipette_head( + location=location, + minimum_z_height=self.traversal_height, + pipette_id=pipette_id, + ) + + self._ot.lh.dispense_in_place( + volume=volume, + flow_rate=flow_rate, + pipette_id=pipette_id, + ) + + if op.mix is not None: + for _ in range(op.mix.repetitions): + self._ot.lh.aspirate_in_place( + volume=op.mix.volume, + flow_rate=op.mix.flow_rate, + pipette_id=pipette_id, + ) + self._ot.lh.dispense_in_place( + volume=op.mix.volume, + flow_rate=op.mix.flow_rate, + pipette_id=pipette_id, + ) + + traversal_location = self._deck_to_robot_frame( + op.resource.get_location_wrt(self.deck, "c", "c", "cavity_bottom") + op.offset + ) + traversal_location.z = self.traversal_height + await self.move_pipette_head( + location=traversal_location, + minimum_z_height=self.traversal_height, + pipette_id=pipette_id, + ) + + async def home(self): + self._ot.health.home() + + async def pick_up_tips96(self, pickup: PickupTipRack): + raise NotImplementedError("The Opentrons backend does not support the 96 head.") + + async def drop_tips96(self, drop: DropTipRack): + raise NotImplementedError("The Opentrons backend does not support the 96 head.") + + async def aspirate96( + self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] + ): + raise NotImplementedError("The Opentrons backend does not support the 96 head.") + + async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): + raise NotImplementedError("The Opentrons backend does not support the 96 head.") + + async def pick_up_resource(self, pickup: ResourcePickup): + raise NotImplementedError("The Opentrons backend does not support the robotic arm.") + + async def move_picked_up_resource(self, move: ResourceMove): + raise NotImplementedError("The Opentrons backend does not support the robotic arm.") + + async def drop_resource(self, drop: ResourceDrop): + raise NotImplementedError("The Opentrons backend does not support the robotic arm.") + + async def list_connected_modules(self) -> List[dict]: + """List all connected temperature modules.""" + return cast(List[dict], self._ot.modules.list_connected_modules()) + + def _pipette_id_for_channel(self, channel: int) -> str: + pipettes = [] + if self.left_pipette is not None: + pipettes.append(self.left_pipette["pipetteId"]) + if self.right_pipette is not None: + pipettes.append(self.right_pipette["pipetteId"]) + if channel < 0 or channel >= len(pipettes): + raise NoChannelError(f"Channel {channel} not available on this OT-2 setup.") + return pipettes[channel] + + def _current_channel_position(self, channel: int) -> Tuple[str, Coordinate]: + """Return the pipette id and current coordinate for a given channel.""" + + pipette_id = self._pipette_id_for_channel(channel) + try: + res = self._ot.lh.save_position(pipette_id=pipette_id) + pos = res["data"]["result"]["position"] + current = Coordinate(pos["x"], pos["y"], pos["z"]) + except Exception as exc: # noqa: BLE001 + raise RuntimeError("Failed to query current pipette position") from exc + + return pipette_id, current + + async def prepare_for_manual_channel_operation(self, channel: int): + """Validate channel exists (no-op otherwise for OT-2).""" + + _ = self._pipette_id_for_channel(channel) + + async def move_channel_x(self, channel: int, x: float): + """Move a channel to an absolute x coordinate using savePosition to seed pose.""" + + pipette_id, current = self._current_channel_position(channel) + target = Coordinate(x=x, y=current.y, z=current.z) + await self.move_pipette_head( + location=target, minimum_z_height=self.traversal_height, pipette_id=pipette_id + ) + + async def move_channel_y(self, channel: int, y: float): + """Move a channel to an absolute y coordinate using savePosition to seed pose.""" + + pipette_id, current = self._current_channel_position(channel) + target = Coordinate(x=current.x, y=y, z=current.z) + await self.move_pipette_head( + location=target, minimum_z_height=self.traversal_height, pipette_id=pipette_id + ) + + async def move_channel_z(self, channel: int, z: float): + """Move a channel to an absolute z coordinate using savePosition to seed pose.""" + + pipette_id, current = self._current_channel_position(channel) + target = Coordinate(x=current.x, y=current.y, z=z) + await self.move_pipette_head( + location=target, minimum_z_height=self.traversal_height, pipette_id=pipette_id + ) + + async def move_pipette_head( + self, + location: Coordinate, + speed: Optional[float] = None, + minimum_z_height: Optional[float] = None, + pipette_id: Optional[str] = None, + force_direct: bool = False, + ): + """Move the pipette head to the specified location. When a tip is mounted, the location refers + to the bottom of the tip. If no tip is mounted, the location refers to the bottom of the + pipette head. + + Args: + location: The location to move to. + speed: The speed to move at, in mm/s. + minimum_z_height: The minimum z height to move to. Appears to be broken in the Opentrons API. + pipette_id: The id of the pipette to move. If `"left"` or `"right"`, the left or right + pipette is used. + force_direct: If True, move the pipette head directly in all dimensions. + """ + + if self.left_pipette is not None and pipette_id == "left": + pipette_id = self.left_pipette["pipetteId"] + elif self.right_pipette is not None and pipette_id == "right": + pipette_id = self.right_pipette["pipetteId"] + + if pipette_id is None: + raise ValueError("No pipette id given or left/right pipette not available.") + + self._ot.lh.move_arm( + pipette_id=pipette_id, + location_x=location.x, + location_y=location.y, + location_z=location.z, + minimum_z_height=minimum_z_height, + speed=speed, + force_direct=force_direct, + ) + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + def supports_tip(channel_vol: float, tip_vol: float) -> bool: + if channel_vol == 20: + return tip_vol in {10, 20} + if channel_vol == 300: + return tip_vol in {200, 300} + if channel_vol == 1000: + return tip_vol in {1000} + raise ValueError(f"Unknown channel volume: {channel_vol}") + + if channel_idx == 0: + if self.left_pipette is None: + return False + left_volume = OpentronsOT2Backend.pipette_name2volume[self.left_pipette["name"]] + return supports_tip(left_volume, tip.maximal_volume) + if channel_idx == 1: + if self.right_pipette is None: + return False + right_volume = OpentronsOT2Backend.pipette_name2volume[self.right_pipette["name"]] + return supports_tip(right_volume, tip.maximal_volume) + return False diff --git a/pylabrobot/liquid_handling/backends/opentrons_backend_tests.py b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend_tests.py similarity index 98% rename from pylabrobot/liquid_handling/backends/opentrons_backend_tests.py rename to pylabrobot/legacy/liquid_handling/backends/opentrons_backend_tests.py index 4c2505487da..7f753803de8 100644 --- a/pylabrobot/liquid_handling/backends/opentrons_backend_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend_tests.py @@ -5,13 +5,13 @@ pytest.importorskip("ot_api") -from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.backends.opentrons_backend import ( +from pylabrobot.legacy.liquid_handling import LiquidHandler +from pylabrobot.legacy.liquid_handling.backends.opentrons_backend import ( _OT_DECK_IS_ADDRESSABLE_AREA_VERSION, OpentronsOT2Backend, ) -from pylabrobot.liquid_handling.errors import NoChannelError -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.errors import NoChannelError +from pylabrobot.legacy.liquid_handling.standard import ( Drop, Pickup, SingleChannelAspiration, diff --git a/pylabrobot/liquid_handling/backends/opentrons_chatterbox.py b/pylabrobot/legacy/liquid_handling/backends/opentrons_chatterbox.py similarity index 95% rename from pylabrobot/liquid_handling/backends/opentrons_chatterbox.py rename to pylabrobot/legacy/liquid_handling/backends/opentrons_chatterbox.py index c5b421f4652..628fd67113e 100644 --- a/pylabrobot/liquid_handling/backends/opentrons_chatterbox.py +++ b/pylabrobot/legacy/liquid_handling/backends/opentrons_chatterbox.py @@ -14,8 +14,8 @@ from typing import Dict, List, Optional, Tuple, cast from pylabrobot.io import LOG_LEVEL_IO -from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend -from pylabrobot.liquid_handling.backends.opentrons_backend import ( +from pylabrobot.legacy.liquid_handling.backends.backend import LiquidHandlerBackend +from pylabrobot.legacy.liquid_handling.backends.opentrons_backend import ( _OT_DECK_IS_ADDRESSABLE_AREA_VERSION, OpentronsOT2Backend, ) @@ -111,8 +111,8 @@ class OpentronsOT2ChatterboxBackend(OpentronsOT2Backend): printed and collected in :attr:`commands`. Example: - >>> from pylabrobot.liquid_handling import LiquidHandler - >>> from pylabrobot.liquid_handling.backends import OpentronsOT2ChatterboxBackend + >>> from pylabrobot.legacy.liquid_handling import LiquidHandler + >>> from pylabrobot.legacy.liquid_handling.backends import OpentronsOT2ChatterboxBackend >>> from pylabrobot.resources.opentrons import OTDeck >>> lh = LiquidHandler(backend=OpentronsOT2ChatterboxBackend(), deck=OTDeck()) >>> await lh.setup() diff --git a/pylabrobot/liquid_handling/backends/opentrons_chatterbox_tests.py b/pylabrobot/legacy/liquid_handling/backends/opentrons_chatterbox_tests.py similarity index 97% rename from pylabrobot/liquid_handling/backends/opentrons_chatterbox_tests.py rename to pylabrobot/legacy/liquid_handling/backends/opentrons_chatterbox_tests.py index d9ee5f370de..1866ead93f5 100644 --- a/pylabrobot/liquid_handling/backends/opentrons_chatterbox_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/opentrons_chatterbox_tests.py @@ -6,8 +6,8 @@ import unittest -from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.backends import ( +from pylabrobot.legacy.liquid_handling import LiquidHandler +from pylabrobot.legacy.liquid_handling.backends import ( OpentronsOT2ChatterboxBackend, OpentronsOT2Simulator, ) diff --git a/pylabrobot/liquid_handling/backends/opentrons_simulator.py b/pylabrobot/legacy/liquid_handling/backends/opentrons_simulator.py similarity index 93% rename from pylabrobot/liquid_handling/backends/opentrons_simulator.py rename to pylabrobot/legacy/liquid_handling/backends/opentrons_simulator.py index 914ba848ea3..262c4d3ef60 100644 --- a/pylabrobot/liquid_handling/backends/opentrons_simulator.py +++ b/pylabrobot/legacy/liquid_handling/backends/opentrons_simulator.py @@ -7,9 +7,9 @@ import logging from typing import Dict, List, Optional, Tuple -from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend -from pylabrobot.liquid_handling.backends.opentrons_backend import OpentronsOT2Backend -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.backends.backend import LiquidHandlerBackend +from pylabrobot.legacy.liquid_handling.backends.opentrons_backend import OpentronsOT2Backend +from pylabrobot.legacy.liquid_handling.standard import ( Drop, Pickup, SingleChannelAspiration, @@ -28,8 +28,8 @@ class OpentronsOT2Simulator(OpentronsOT2Backend): ``ot_api`` library. Example: - >>> from pylabrobot.liquid_handling import LiquidHandler - >>> from pylabrobot.liquid_handling.backends import OpentronsOT2Simulator + >>> from pylabrobot.legacy.liquid_handling import LiquidHandler + >>> from pylabrobot.legacy.liquid_handling.backends import OpentronsOT2Simulator >>> from pylabrobot.resources.opentrons import OTDeck >>> lh = LiquidHandler(backend=OpentronsOT2Simulator(), deck=OTDeck()) >>> await lh.setup() diff --git a/pylabrobot/liquid_handling/backends/serializing_backend.py b/pylabrobot/legacy/liquid_handling/backends/serializing_backend.py similarity index 98% rename from pylabrobot/liquid_handling/backends/serializing_backend.py rename to pylabrobot/legacy/liquid_handling/backends/serializing_backend.py index a227f3b9d43..69f3d93656f 100644 --- a/pylabrobot/liquid_handling/backends/serializing_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/serializing_backend.py @@ -1,10 +1,10 @@ from abc import ABCMeta, abstractmethod from typing import Any, Dict, List, Optional, Union, cast -from pylabrobot.liquid_handling.backends.backend import ( +from pylabrobot.legacy.liquid_handling.backends.backend import ( LiquidHandlerBackend, ) -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.standard import ( Drop, DropTipRack, MultiHeadAspirationContainer, diff --git a/pylabrobot/liquid_handling/backends/serializing_backend_tests.py b/pylabrobot/legacy/liquid_handling/backends/serializing_backend_tests.py similarity index 98% rename from pylabrobot/liquid_handling/backends/serializing_backend_tests.py rename to pylabrobot/legacy/liquid_handling/backends/serializing_backend_tests.py index 4d1b38fe89e..833ac238c8a 100644 --- a/pylabrobot/liquid_handling/backends/serializing_backend_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/serializing_backend_tests.py @@ -1,8 +1,8 @@ import unittest from unittest.mock import AsyncMock -from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.backends.serializing_backend import ( +from pylabrobot.legacy.liquid_handling import LiquidHandler +from pylabrobot.legacy.liquid_handling.backends.serializing_backend import ( SerializingBackend, ) from pylabrobot.resources import ( diff --git a/pylabrobot/liquid_handling/backends/tecan/EVO_backend.py b/pylabrobot/legacy/liquid_handling/backends/tecan/EVO_backend.py similarity index 99% rename from pylabrobot/liquid_handling/backends/tecan/EVO_backend.py rename to pylabrobot/legacy/liquid_handling/backends/tecan/EVO_backend.py index c4d9d2f2a9c..afb463bda95 100644 --- a/pylabrobot/liquid_handling/backends/tecan/EVO_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/tecan/EVO_backend.py @@ -11,18 +11,18 @@ ) from pylabrobot.io.usb import USB -from pylabrobot.liquid_handling.backends.backend import ( +from pylabrobot.legacy.liquid_handling.backends.backend import ( LiquidHandlerBackend, ) -from pylabrobot.liquid_handling.backends.tecan.errors import ( +from pylabrobot.legacy.liquid_handling.backends.tecan.errors import ( TecanError, error_code_to_exception, ) -from pylabrobot.liquid_handling.liquid_classes.tecan import ( +from pylabrobot.legacy.liquid_handling.liquid_classes.tecan import ( TecanLiquidClass, get_liquid_class, ) -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.standard import ( Drop, DropTipRack, MultiHeadAspirationContainer, diff --git a/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py b/pylabrobot/legacy/liquid_handling/backends/tecan/EVO_tests.py similarity index 99% rename from pylabrobot/liquid_handling/backends/tecan/EVO_tests.py rename to pylabrobot/legacy/liquid_handling/backends/tecan/EVO_tests.py index 723aebcff44..8819b7b7c3f 100644 --- a/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/tecan/EVO_tests.py @@ -2,12 +2,12 @@ import unittest.mock from unittest.mock import call -from pylabrobot.liquid_handling.backends.tecan.EVO_backend import ( +from pylabrobot.legacy.liquid_handling.backends.tecan.EVO_backend import ( EVOBackend, LiHa, RoMa, ) -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.standard import ( GripDirection, Pickup, ResourceDrop, diff --git a/pylabrobot/legacy/liquid_handling/backends/tecan/__init__.py b/pylabrobot/legacy/liquid_handling/backends/tecan/__init__.py new file mode 100644 index 00000000000..de91df1008f --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/backends/tecan/__init__.py @@ -0,0 +1 @@ +from .EVO_backend import EVOBackend diff --git a/pylabrobot/liquid_handling/backends/tecan/errors.py b/pylabrobot/legacy/liquid_handling/backends/tecan/errors.py similarity index 100% rename from pylabrobot/liquid_handling/backends/tecan/errors.py rename to pylabrobot/legacy/liquid_handling/backends/tecan/errors.py diff --git a/pylabrobot/liquid_handling/channel_positioning.py b/pylabrobot/legacy/liquid_handling/channel_positioning.py similarity index 99% rename from pylabrobot/liquid_handling/channel_positioning.py rename to pylabrobot/legacy/liquid_handling/channel_positioning.py index 047ea7c48f0..8851e2f09e2 100644 --- a/pylabrobot/liquid_handling/channel_positioning.py +++ b/pylabrobot/legacy/liquid_handling/channel_positioning.py @@ -3,7 +3,7 @@ import warnings from typing import Dict, List, Optional, Tuple -from pylabrobot.liquid_handling.errors import ChannelsDoNotFitError +from pylabrobot.legacy.liquid_handling.errors import ChannelsDoNotFitError from pylabrobot.resources.container import Container from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.resource import Resource diff --git a/pylabrobot/liquid_handling/channel_positioning_tests.py b/pylabrobot/legacy/liquid_handling/channel_positioning_tests.py similarity index 96% rename from pylabrobot/liquid_handling/channel_positioning_tests.py rename to pylabrobot/legacy/liquid_handling/channel_positioning_tests.py index 13e6ca7ba2a..dd07cb92ae8 100644 --- a/pylabrobot/liquid_handling/channel_positioning_tests.py +++ b/pylabrobot/legacy/liquid_handling/channel_positioning_tests.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import MagicMock, patch -from pylabrobot.liquid_handling.channel_positioning import ( +from pylabrobot.legacy.liquid_handling.channel_positioning import ( _centers_to_offsets, _distribute_channels, _get_compartments, @@ -13,7 +13,7 @@ compute_nonconsecutive_channel_offsets, required_spacing_between, ) -from pylabrobot.liquid_handling.errors import ChannelsDoNotFitError +from pylabrobot.legacy.liquid_handling.errors import ChannelsDoNotFitError from pylabrobot.resources.container import Container from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.resource import Resource @@ -412,7 +412,7 @@ def _mock_container(self, size_y: float): c.get_absolute_size_y.return_value = size_y return c - @patch("pylabrobot.liquid_handling.channel_positioning.compute_channel_offsets") + @patch("pylabrobot.legacy.liquid_handling.channel_positioning.compute_channel_offsets") def test_even_span_no_center_offset(self, mock_offsets): mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] result = compute_nonconsecutive_channel_offsets(self._mock_container(50.0), [0, 1], self.S) @@ -420,7 +420,7 @@ def test_even_span_no_center_offset(self, mock_offsets): self.assertAlmostEqual(result[0].y, 4.5) self.assertAlmostEqual(result[1].y, -4.5) - @patch("pylabrobot.liquid_handling.channel_positioning.compute_channel_offsets") + @patch("pylabrobot.legacy.liquid_handling.channel_positioning.compute_channel_offsets") def test_odd_span_passes_through_offsets(self, mock_offsets): mock_offsets.return_value = [ Coordinate(0, 9.0, 0), @@ -437,7 +437,7 @@ def test_container_too_small_returns_none(self): compute_nonconsecutive_channel_offsets(self._mock_container(10.0), [0, 1], self.S) ) - @patch("pylabrobot.liquid_handling.channel_positioning.compute_channel_offsets") + @patch("pylabrobot.legacy.liquid_handling.channel_positioning.compute_channel_offsets") def test_non_consecutive_uses_full_physical_span(self, mock_offsets): mock_offsets.return_value = [ Coordinate(0, 10.0, 0), @@ -451,7 +451,7 @@ def test_non_consecutive_uses_full_physical_span(self, mock_offsets): resource=unittest.mock.ANY, num_channels=3, spread="wide", channel_spacings=[9.0] * 3 ) - @patch("pylabrobot.liquid_handling.channel_positioning.compute_channel_offsets") + @patch("pylabrobot.legacy.liquid_handling.channel_positioning.compute_channel_offsets") def test_mixed_spacing_uses_effective(self, mock_offsets): mock_offsets.return_value = [ Coordinate(0, 18.0, 0), diff --git a/pylabrobot/liquid_handling/errors.py b/pylabrobot/legacy/liquid_handling/errors.py similarity index 100% rename from pylabrobot/liquid_handling/errors.py rename to pylabrobot/legacy/liquid_handling/errors.py diff --git a/pylabrobot/legacy/liquid_handling/liquid_classes/__init__.py b/pylabrobot/legacy/liquid_handling/liquid_classes/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/__init__.py b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/__init__.py new file mode 100644 index 00000000000..d0f28ccbfd3 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/__init__.py @@ -0,0 +1,3 @@ +from .base import HamiltonLiquidClass +from .star import get_star_liquid_class +from .vantage import get_vantage_liquid_class diff --git a/pylabrobot/liquid_handling/liquid_classes/hamilton/base.py b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/base.py similarity index 100% rename from pylabrobot/liquid_handling/liquid_classes/hamilton/base.py rename to pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/base.py diff --git a/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/star.py b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/star.py new file mode 100644 index 00000000000..314774fc758 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/star.py @@ -0,0 +1,15005 @@ +from typing import Dict, Optional, Tuple + +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import ( + HamiltonLiquidClass, +) +from pylabrobot.resources.liquid import Liquid + +star_mapping: Dict[ + Tuple[int, bool, bool, bool, Liquid, bool, bool], + HamiltonLiquidClass, +] = {} + + +def get_star_liquid_class( + tip_volume: float, + is_core: bool, + is_tip: bool, + has_filter: bool, + liquid: Liquid, + jet: bool, + blow_out: bool, +) -> Optional[HamiltonLiquidClass]: + """Get the Hamilton STAR liquid class for the given parameters. + + Args: + tip_volume: The volume of the tip in microliters. + is_core: Whether the tip is a core tip. + is_tip: Whether the tip is a tip tip or a needle. + has_filter: Whether the tip has a filter. + liquid: The liquid to be dispensed. + jet: Whether the liquid is dispensed using a jet. + blow_out: This is called "empty" in the Hamilton Liquid editor and liquid class names, but + "blow out" in the firmware documentation. "Empty" in the firmware documentation means fully + emptying the tip, which is the terminology PyLabRobot adopts. Blow_out is the opposite of + partial dispense. + """ + + # Tip volumes from resources (mostly where they have filters) are slightly different from the ones + # in the liquid class mapping, so we need to map them here. If no mapping is found, we use the + # given maximal volume of the tip. + tip_volume = int( + { + 360.0: 300.0, + 1065.0: 1000.0, + 1250.0: 1000.0, + 4367.0: 4000.0, + 5420.0: 5000.0, + }.get(tip_volume, tip_volume) + ) + + return star_mapping.get( + (tip_volume, is_core, is_tip, has_filter, liquid, jet, blow_out), + None, + ) + + +star_mapping[(1000, False, False, False, Liquid.WATER, True, True)] = ( + _1000ulNeedleCRWater_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 520.0, + 50.0: 61.2, + 0.0: 0.0, + 20.0: 22.5, + 100.0: 113.0, + 10.0: 11.1, + 200.0: 214.0, + 1000.0: 1032.0, + }, + aspiration_flow_rate=500.0, + aspiration_mix_flow_rate=500.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=500.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, False, False, Liquid.WATER, True, False)] = ( + _1000ulNeedleCRWater_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 520.0, + 50.0: 62.2, + 0.0: 0.0, + 20.0: 32.0, + 100.0: 115.5, + 1000.0: 1032.0, + }, + aspiration_flow_rate=500.0, + aspiration_mix_flow_rate=500.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=500.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=10.0, +) + + +# +star_mapping[(1000, False, False, False, Liquid.WATER, False, True)] = ( + _1000ulNeedleCRWater_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 50.0: 59.0, + 0.0: 0.0, + 20.0: 25.9, + 10.0: 12.9, + 1000.0: 1000.0, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=50.0, + dispense_mode=5.0, + dispense_mix_flow_rate=50.0, + dispense_air_transport_volume=1.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +# +star_mapping[(1000, False, False, False, Liquid.WATER, False, False)] = ( + _1000ulNeedleCRWater_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 50.0: 55.0, + 0.0: 0.0, + 20.0: 25.9, + 10.0: 12.9, + 1000.0: 1000.0, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=50.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=1.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth Asp. 0.5mm, without pre-rinsing +# - Disp.: jet mode empty tip +# - Pipetting-Volumes jet-dispense between 20 - 1000µl +# +# +# +# Typical performance data under laboratory conditions: +# Volume µl Precision % Trueness % +# 20 7.15 - 5.36 +# 50 2.81 - 1.49 +# 100 2.48 - 1.94 +# 200 1.25 - 0.51 +# 500 0.91 0.02 +# 1000 0.66 - 0.46 +star_mapping[(1000, False, False, False, Liquid.WATER, True, False)] = ( + _1000ulNeedle_Water_DispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 530.0, + 50.0: 56.0, + 0.0: 0.0, + 100.0: 110.0, + 20.0: 22.5, + 1000.0: 1055.0, + 200.0: 214.0, + }, + aspiration_flow_rate=500.0, + aspiration_mix_flow_rate=500.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=10.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=500.0, + dispense_mode=0.0, + dispense_mix_flow_rate=500.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=0.4, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing +# - dispense mode: surface empty tip +# - Pipetting-Volumes surface-dispense between 20 - 50µl +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 10.12 - 4.66 +# 50 3.79 - 1.18 +# +star_mapping[(1000, False, False, False, Liquid.WATER, False, False)] = ( + _1000ulNeedle_Water_DispenseSurface +) = HamiltonLiquidClass( + curve={50.0: 59.0, 0.0: 0.0, 20.0: 25.9, 1000.0: 1000.0}, + aspiration_flow_rate=500.0, + aspiration_mix_flow_rate=500.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=10.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=500.0, + dispense_mode=1.0, + dispense_mix_flow_rate=500.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=0.4, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, False, False, Liquid.WATER, False, True)] = ( + _10ulNeedleCRWater_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.7, + 0.5: 0.5, + 0.0: 0.0, + 1.0: 1.2, + 2.0: 2.4, + 10.0: 11.4, + }, + aspiration_flow_rate=60.0, + aspiration_mix_flow_rate=60.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=60.0, + dispense_mode=5.0, + dispense_mix_flow_rate=60.0, + dispense_air_transport_volume=1.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, False, False, Liquid.WATER, False, False)] = ( + _10ulNeedleCRWater_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 5.0: 5.7, + 0.5: 0.5, + 0.0: 0.0, + 1.0: 1.2, + 2.0: 2.4, + 10.0: 11.4, + }, + aspiration_flow_rate=60.0, + aspiration_mix_flow_rate=60.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=60.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=1.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, True, Liquid.DMSO, True, False)] = ( + _150ul_Piercing_Tip_Filter_DMSO_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={150.0: 150.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, + aspiration_flow_rate=180.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=1.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=2.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, True, Liquid.DMSO, True, True)] = ( + _150ul_Piercing_Tip_Filter_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={150.0: 154.0, 50.0: 52.9, 0.0: 0.0, 20.0: 21.8}, + aspiration_flow_rate=180.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=1.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, True, Liquid.DMSO, False, True)] = ( + _150ul_Piercing_Tip_Filter_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 3.0: 4.5, + 5.0: 6.5, + 150.0: 155.0, + 50.0: 53.7, + 0.0: 0.0, + 10.0: 12.0, + 2.0: 3.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=1.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +# - Volume 20 - 300ul +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing, in case of drops pre-rinsing 3x with Aspiratevolume, +# ( >100ul perhaps 2x or set mix speed to 100ul/s) +# - dispense mode jet empty tip +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 6.68 -2.95 +# 50 1.71 1.93 +# 100 1.67 -0.35 +# 300 0.46 -0.61 +# +star_mapping[(50, False, True, True, Liquid.ETHANOL, True, True)] = ( + _150ul_Piercing_Tip_Filter_Ethanol_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={150.0: 166.0, 50.0: 58.3, 0.0: 0.0, 20.0: 25.5}, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=7.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=7.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +# - Volume 5 - 50ul +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing, in case of drops pre-rinsing 3x with Aspiratevolume, +# ( >100ul perhaps 2x or set mix speed to 100ul/s) +# - dispense mode surface empty tip +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 5 7.96 -0.03 +# 10 7.99 5.88 +# 20 0.95 2.97 +# 50 0.31 -0.10 +# +star_mapping[(50, False, True, True, Liquid.ETHANOL, False, True)] = ( + _150ul_Piercing_Tip_Filter_Ethanol_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 3.0: 5.0, + 5.0: 7.6, + 150.0: 165.0, + 50.0: 56.9, + 0.0: 0.0, + 10.0: 13.2, + 2.0: 3.3, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=7.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=5.0, + dispense_mix_flow_rate=150.0, + dispense_air_transport_volume=7.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing +# - dispense mode surface empty tip +# - Pipetting-Volumes jet-dispense between 5 - 300µl +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 5 3.28 0.86 +# 10 4.88 -0.29 +# 20 2.92 2.68 +# 50 2.44 1.18 +# 100 1.33 1.29 +# 300 1.08 -0.87 +# +# +star_mapping[(50, False, True, True, Liquid.GLYCERIN80, False, True)] = ( + _150ul_Piercing_Tip_Filter_Glycerin80_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 3.0: 4.5, + 5.0: 7.2, + 150.0: 167.5, + 50.0: 60.0, + 0.0: 0.0, + 1.0: 2.7, + 10.0: 13.0, + 2.0: 2.5, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=5.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=10.0, + dispense_mode=5.0, + dispense_mix_flow_rate=50.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=5.0, + dispense_swap_speed=2.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# - without pre-rinsing +# - dispense mode jet empty tip +# - Pipetting-Volumes jet-dispense between 20 - 300µl +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 2.78 -0.05 +# 50 0.89 1.06 +# 100 0.81 0.99 +# 300 1.00 0.65 +# +star_mapping[(50, False, True, True, Liquid.SERUM, True, True)] = ( + _150ul_Piercing_Tip_Filter_Serum_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={150.0: 162.0, 50.0: 55.9, 0.0: 0.0, 20.0: 23.0}, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing +# - dispense mode surface empty tip +# - Pipetting-Volumes surface-dispense between 1 - 50µl +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 1 17.32 3.68 +# 2 16.68 0.24 +# 5 6.30 1.37 +# 10 2.03 5.71 +# 20 1.72 3.91 +# 50 1.39 -0.12 +# +# +star_mapping[(50, False, True, True, Liquid.SERUM, False, True)] = ( + _150ul_Piercing_Tip_Filter_Serum_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 3.0: 3.4, + 5.0: 5.9, + 150.0: 161.5, + 50.0: 56.2, + 0.0: 0.0, + 10.0: 11.6, + 2.0: 2.2, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=5.0, + dispense_mix_flow_rate=150.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, True, Liquid.WATER, True, False)] = ( + _150ul_Piercing_Tip_Filter_Water_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={150.0: 150.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=2.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(50, False, True, True, Liquid.WATER, True, True)] = ( + _150ul_Piercing_Tip_Filter_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 6.6, + 150.0: 159.1, + 50.0: 55.0, + 0.0: 0.0, + 100.0: 107.0, + 1.0: 1.6, + 20.0: 22.9, + 10.0: 12.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=1.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=3.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, True, Liquid.WATER, False, True)] = ( + _150ul_Piercing_Tip_Filter_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 3.0: 3.5, + 5.0: 6.5, + 150.0: 158.1, + 50.0: 54.5, + 0.0: 0.0, + 1.0: 1.6, + 10.0: 11.9, + 2.0: 2.8, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=1.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, False, Liquid.DMSO, True, True)] = ( + _250ul_Piercing_Tip_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={250.0: 255.5, 50.0: 52.9, 0.0: 0.0, 20.0: 21.8}, + aspiration_flow_rate=180.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=1.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, False, Liquid.DMSO, False, True)] = ( + _250ul_Piercing_Tip_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 3.0: 4.2, + 5.0: 6.5, + 250.0: 256.0, + 50.0: 53.7, + 0.0: 0.0, + 10.0: 12.0, + 2.0: 3.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=1.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +# - Volume 20 - 300ul +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing, in case of drops pre-rinsing 3x with Aspiratevolume, +# ( >100ul perhaps 2x or set mix speed to 100ul/s) +# - dispense mode jet empty tip +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 6.68 -2.95 +# 50 1.71 1.93 +# 100 1.67 -0.35 +# 300 0.46 -0.61 +# +star_mapping[(50, False, True, False, Liquid.ETHANOL, True, True)] = ( + _250ul_Piercing_Tip_Ethanol_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={250.0: 270.2, 50.0: 59.2, 0.0: 0.0, 20.0: 27.3}, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=15.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +# - Volume 5 - 50ul +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing, in case of drops pre-rinsing 3x with Aspiratevolume, +# ( >100ul perhaps 2x or set mix speed to 100ul/s) +# - dispense mode surface empty tip +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 5 7.96 -0.03 +# 10 7.99 5.88 +# 20 0.95 2.97 +# 50 0.31 -0.10 +# +star_mapping[(50, False, True, False, Liquid.ETHANOL, False, True)] = ( + _250ul_Piercing_Tip_Ethanol_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 3.0: 5.0, + 5.0: 9.6, + 250.0: 270.5, + 50.0: 58.0, + 0.0: 0.0, + 10.0: 14.8, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=5.0, + dispense_mix_flow_rate=150.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing +# - dispense mode surface empty tip +# - Pipetting-Volumes jet-dispense between 5 - 300µl +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 5 3.28 0.86 +# 10 4.88 -0.29 +# 20 2.92 2.68 +# 50 2.44 1.18 +# 100 1.33 1.29 +# 300 1.08 -0.87 +# +# +star_mapping[(50, False, True, False, Liquid.GLYCERIN80, False, True)] = ( + _250ul_Piercing_Tip_Glycerin80_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 3.0: 4.5, + 5.0: 7.2, + 250.0: 289.0, + 50.0: 65.0, + 0.0: 0.0, + 1.0: 2.7, + 10.0: 13.9, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=5.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=10.0, + dispense_mode=5.0, + dispense_mix_flow_rate=50.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=5.0, + dispense_swap_speed=2.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# - without pre-rinsing +# - dispense mode jet empty tip +# - Pipetting-Volumes jet-dispense between 20 - 300µl +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 2.78 -0.05 +# 50 0.89 1.06 +# 100 0.81 0.99 +# 300 1.00 0.65 +# +star_mapping[(50, False, True, False, Liquid.SERUM, True, True)] = ( + _250ul_Piercing_Tip_Serum_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={250.0: 265.0, 50.0: 56.4, 0.0: 0.0, 20.0: 23.0}, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing +# - dispense mode surface empty tip +# - Pipetting-Volumes surface-dispense between 1 - 50µl +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 1 17.32 3.68 +# 2 16.68 0.24 +# 5 6.30 1.37 +# 10 2.03 5.71 +# 20 1.72 3.91 +# 50 1.39 -0.12 +# +# +star_mapping[(50, False, True, False, Liquid.SERUM, False, True)] = ( + _250ul_Piercing_Tip_Serum_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 3.0: 3.4, + 5.0: 5.9, + 250.0: 264.2, + 50.0: 56.2, + 0.0: 0.0, + 10.0: 11.6, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=5.0, + dispense_mix_flow_rate=150.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, False, Liquid.WATER, True, True)] = ( + _250ul_Piercing_Tip_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 6.6, + 250.0: 260.0, + 50.0: 55.0, + 0.0: 0.0, + 100.0: 107.0, + 1.0: 1.6, + 20.0: 22.5, + 10.0: 12.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=1.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=3.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, False, Liquid.WATER, False, True)] = ( + _250ul_Piercing_Tip_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 3.0: 4.0, + 5.0: 6.5, + 250.0: 259.0, + 50.0: 55.1, + 0.0: 0.0, + 1.0: 1.6, + 10.0: 12.6, + 2.0: 2.8, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=1.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +# - Volume 10 - 300ul +# - submerge depth: Asp. 0.5mm +# - without pre-rinsing, in case of drops pre-rinsing 1-3x with Aspiratevolume, +# ( >100ul perhaps less than 2x or set mix speed to 100ul/s) +# - dispense mode jet empty tip +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 10 7.29 0.79 +# 20 5.85 -0.66 +# 50 2.57 0.82 +# 100 1.04 0.05 +# 300 0.63 -0.07 +# +star_mapping[(300, False, False, False, Liquid.ACETONITRIL80WATER20, True, False)] = ( + _300ulNeedleAcetonitril80Water20DispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 310.0, + 50.0: 57.8, + 0.0: 0.0, + 100.0: 106.5, + 20.0: 26.8, + 10.0: 16.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=15.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=0.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, False, False, Liquid.WATER, True, True)] = ( + _300ulNeedleCRWater_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 313.0, + 50.0: 53.5, + 0.0: 0.0, + 100.0: 104.0, + 20.0: 22.3, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, False, False, Liquid.WATER, True, False)] = ( + _300ulNeedleCRWater_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 313.0, + 50.0: 59.5, + 0.0: 0.0, + 100.0: 109.0, + 20.0: 29.3, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, False, False, Liquid.WATER, False, True)] = ( + _300ulNeedleCRWater_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 308.4, + 5.0: 6.8, + 50.0: 52.3, + 0.0: 0.0, + 100.0: 102.9, + 20.0: 22.3, + 1.0: 2.3, + 200.0: 205.8, + 10.0: 11.7, + 2.0: 3.0, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=50.0, + dispense_mode=5.0, + dispense_mix_flow_rate=50.0, + dispense_air_transport_volume=1.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, False, False, Liquid.WATER, False, False)] = ( + _300ulNeedleCRWater_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 308.4, + 5.0: 6.8, + 50.0: 52.3, + 0.0: 0.0, + 100.0: 102.9, + 20.0: 22.3, + 1.0: 2.3, + 200.0: 205.8, + 10.0: 11.7, + 2.0: 3.0, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=50.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=1.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# - without pre-rinsing +# - dispense mode jet empty tip +# - Pipetting-Volumes jet-dispense between 20 - 300µl +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 2.21 0.57 +# 50 1.53 0.23 +# 100 0.55 -0.01 +# 300 0.71 0.39 +# +star_mapping[(300, False, False, False, Liquid.DIMETHYLSULFOXID, True, False)] = ( + _300ulNeedleDMSODispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 317.0, + 50.0: 53.5, + 0.0: 0.0, + 100.0: 106.5, + 20.0: 21.3, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing +# - dispense mode surface empty tip +# - Pipetting-Volumes surface-dispense between 1 - 50µl +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 5 5.97 1.26 +# 10 2.53 1.22 +# 20 3.67 2.60 +# 50 1.32 -1.05 +# +# +star_mapping[(300, False, False, False, Liquid.DIMETHYLSULFOXID, False, False)] = ( + _300ulNeedleDMSODispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 6.0, + 50.0: 52.3, + 0.0: 0.0, + 20.0: 22.3, + 10.0: 11.4, + 2.0: 2.5, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=1.0, + dispense_mix_flow_rate=150.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# - Volume 20 - 300ul +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing, in case of drops pre-rinsing 3x with Aspiratevolume, +# ( >100ul perhaps 2x or set mix speed to 100ul/s) +# - dispense mode jet empty tip +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 6.68 -2.95 +# 50 1.71 1.93 +# 100 1.67 -0.35 +# 300 0.46 -0.61 +# +star_mapping[(300, False, False, False, Liquid.ETHANOL, True, False)] = ( + _300ulNeedleEtOHDispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 317.0, + 50.0: 57.8, + 0.0: 0.0, + 100.0: 109.0, + 20.0: 25.3, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=15.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +# - Volume 5 - 50ul +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing, in case of drops pre-rinsing 3x with Aspiratevolume, +# ( >100ul perhaps 2x or set mix speed to 100ul/s) +# - dispense mode surface empty tip +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 5 7.96 -0.03 +# 10 7.99 5.88 +# 20 0.95 2.97 +# 50 0.31 -0.10 +# +star_mapping[(300, False, False, False, Liquid.ETHANOL, False, False)] = ( + _300ulNeedleEtOHDispenseSurface +) = HamiltonLiquidClass( + curve={5.0: 7.2, 50.0: 55.0, 0.0: 0.0, 20.0: 24.5, 10.0: 13.1}, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=1.0, + dispense_mix_flow_rate=150.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing +# - dispense mode surface empty tip +# - Pipetting-Volumes jet-dispense between 5 - 300µl +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 5 3.28 0.86 +# 10 4.88 -0.29 +# 20 2.92 2.68 +# 50 2.44 1.18 +# 100 1.33 1.29 +# 300 1.08 -0.87 +# +# +star_mapping[(300, False, False, False, Liquid.GLYCERIN80, False, False)] = ( + _300ulNeedleGlycerin80DispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 325.0, + 5.0: 8.0, + 50.0: 61.3, + 0.0: 0.0, + 100.0: 117.0, + 20.0: 26.0, + 1.0: 2.7, + 10.0: 13.9, + 2.0: 4.2, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=50.0, + dispense_mode=1.0, + dispense_mix_flow_rate=50.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# - without pre-rinsing +# - dispense mode jet empty tip +# - Pipetting-Volumes jet-dispense between 20 - 300µl +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 2.78 -0.05 +# 50 0.89 1.06 +# 100 0.81 0.99 +# 300 1.00 0.65 +# +star_mapping[(300, False, False, False, Liquid.SERUM, True, False)] = ( + _300ulNeedleSerumDispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 313.0, + 50.0: 53.5, + 0.0: 0.0, + 100.0: 105.0, + 20.0: 21.3, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing +# - dispense mode surface empty tip +# - Pipetting-Volumes surface-dispense between 1 - 50µl +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 1 17.32 3.68 +# 2 16.68 0.24 +# 5 6.30 1.37 +# 10 2.03 5.71 +# 20 1.72 3.91 +# 50 1.39 -0.12 +# +# +star_mapping[(300, False, False, False, Liquid.SERUM, False, False)] = ( + _300ulNeedleSerumDispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 6.0, + 50.0: 52.3, + 0.0: 0.0, + 20.0: 22.3, + 1.0: 2.2, + 10.0: 11.9, + 2.0: 3.2, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=1.0, + dispense_mix_flow_rate=150.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# - without pre-rinsing +# - dispense mode jet empty tip +# - Pipetting-Volumes jet-dispense between 20 - 300µl +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 2.78 -0.05 +# 50 0.89 1.06 +# 100 0.81 0.99 +# 300 1.00 0.65 +# +star_mapping[(300, False, False, False, Liquid.SERUM, True, False)] = ( + _300ulNeedle_Serum_DispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 313.0, + 50.0: 53.5, + 0.0: 0.0, + 100.0: 105.0, + 20.0: 21.3, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=0.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing +# - dispense mode surface empty tip +# - Pipetting-Volumes surface-dispense between 1 - 50µl +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 1 17.32 3.68 +# 2 16.68 0.24 +# 5 6.30 1.37 +# 10 2.03 5.71 +# 20 1.72 3.91 +# 50 1.39 -0.12 +# +# +star_mapping[(300, False, False, False, Liquid.SERUM, False, False)] = ( + _300ulNeedle_Serum_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 350.0, + 5.0: 6.0, + 50.0: 52.3, + 0.0: 0.0, + 20.0: 22.3, + 1.0: 2.2, + 10.0: 11.9, + 2.0: 3.2, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=1.0, + dispense_mix_flow_rate=150.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# - without pre-rinsing +# - dispense mode jet empty tip +# - Pipetting-Volumes jet-dispense between 20 - 300µl +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 0.50 2.26 +# 50 0.30 0.65 +# 100 0.22 1.15 +# 200 0.16 0.55 +# 300 0.17 0.35 +# +star_mapping[(300, False, False, False, Liquid.WATER, True, False)] = ( + _300ulNeedle_Water_DispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 313.0, + 50.0: 53.5, + 0.0: 0.0, + 100.0: 105.0, + 20.0: 22.3, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=0.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing +# - dispense mode surface empty tip +# - Pipetting-Volumes jet-dispense between 1 - 20µl +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 1 11.17 - 6.64 +# 2 4.50 1.95 +# 5 0.38 0.50 +# 10 0.94 0.73 +# 20 0.63 0.73 +# +# +star_mapping[(300, False, False, False, Liquid.WATER, False, False)] = ( + _300ulNeedle_Water_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 308.4, + 5.0: 6.5, + 50.0: 52.3, + 0.0: 0.0, + 100.0: 102.9, + 20.0: 22.3, + 1.0: 1.1, + 200.0: 205.8, + 10.0: 12.0, + 2.0: 2.1, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=1.0, + dispense_mix_flow_rate=150.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=0.4, + dispense_stop_back_volume=0.0, +) + + +# Liquid class for washing rocket tips with CO-RE 384 head in 96 DC wash station. +star_mapping[(300, True, True, False, Liquid.WATER, False, False)] = ( + _300ul_RocketTip_384COREHead_96Washer_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 330.0, + 5.0: 6.3, + 0.5: 0.9, + 50.0: 55.1, + 0.0: 0.0, + 1.0: 1.6, + 20.0: 23.2, + 100.0: 107.2, + 2.0: 2.8, + 10.0: 11.9, + 200.0: 211.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=150.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=150.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=5.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# Evaluation +star_mapping[(300, True, True, False, Liquid.DMSO, True, False)] = ( + _300ul_RocketTip_384COREHead_DMSO_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 300.0: 300.0, + 150.0: 150.0, + 50.0: 50.0, + 0.0: 0.0, + 100.0: 100.0, + 20.0: 20.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=7.5, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=120.0, + dispense_stop_back_volume=10.0, +) + + +# Evaluation +star_mapping[(300, True, True, False, Liquid.DMSO, True, True)] = ( + _300ul_RocketTip_384COREHead_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 303.5, + 0.0: 0.0, + 100.0: 105.8, + 200.0: 209.5, + 10.0: 11.4, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +# Evaluation +star_mapping[(300, True, True, False, Liquid.DMSO, False, True)] = ( + _300ul_RocketTip_384COREHead_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 308.0, + 0.0: 0.0, + 100.0: 105.5, + 200.0: 209.0, + 10.0: 12.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=80.0, + dispense_mode=5.0, + dispense_mix_flow_rate=80.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# Evaluation +star_mapping[(300, True, True, False, Liquid.WATER, True, False)] = ( + _300ul_RocketTip_384COREHead_Water_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 300.0: 309.0, + 0.0: 0.0, + 100.0: 106.5, + 20.0: 22.3, + 200.0: 207.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=7.5, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=20.0, +) + + +# Evaluation +star_mapping[(300, True, True, False, Liquid.WATER, True, True)] = ( + _300ul_RocketTip_384COREHead_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 309.0, + 0.0: 0.0, + 100.0: 106.5, + 20.0: 22.3, + 200.0: 207.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +# Evaluation +star_mapping[(300, True, True, False, Liquid.WATER, False, True)] = ( + _300ul_RocketTip_384COREHead_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 314.3, + 0.0: 0.0, + 100.0: 109.0, + 200.0: 214.7, + 10.0: 12.7, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=160.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(30, True, True, False, Liquid.DMSO, True, True)] = ( + _30ulTip_384COREHead_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={5.0: 5.0, 15.0: 15.3, 30.0: 30.7, 0.0: 0.0, 1.0: 1.0}, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(30, True, True, False, Liquid.DMSO, False, True)] = ( + _30ulTip_384COREHead_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={5.0: 4.9, 15.0: 15.1, 30.0: 30.0, 0.0: 0.0, 1.0: 0.9}, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=50.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(30, True, True, False, Liquid.ETHANOL, True, True)] = ( + _30ulTip_384COREHead_EtOH_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={5.0: 6.54, 15.0: 18.36, 30.0: 33.8, 0.0: 0.0, 1.0: 1.8}, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=3.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(30, True, True, False, Liquid.ETHANOL, False, True)] = ( + _30ulTip_384COREHead_EtOH_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={5.0: 6.2, 15.0: 16.9, 30.0: 33.1, 0.0: 0.0, 1.0: 1.5}, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=50.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(30, True, True, False, Liquid.GLYCERIN80, False, True)] = ( + _30ulTip_384COREHead_Glyzerin80_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 6.3, + 0.5: 0.9, + 40.0: 44.0, + 0.0: 0.0, + 20.0: 22.2, + 1.0: 1.6, + 10.0: 11.9, + 2.0: 2.8, + }, + aspiration_flow_rate=150.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=2.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=100.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=2.0, + dispense_swap_speed=2.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(30, True, True, False, Liquid.WATER, True, True)] = ( + _30ulTip_384COREHead_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={5.0: 6.0, 15.0: 16.5, 30.0: 32.3, 0.0: 0.0, 1.0: 1.6}, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(30, True, True, False, Liquid.WATER, False, True)] = ( + _30ulTip_384COREHead_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={5.0: 5.6, 15.0: 15.9, 30.0: 31.3, 0.0: 0.0, 1.0: 1.2}, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=50.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(30, True, True, False, Liquid.WATER, False, False)] = ( + _30ulTip_384COREWasher_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 6.3, + 0.5: 0.9, + 40.0: 44.0, + 0.0: 0.0, + 1.0: 1.6, + 20.0: 22.2, + 2.0: 2.8, + 10.0: 11.9, + }, + aspiration_flow_rate=10.0, + aspiration_mix_flow_rate=30.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=15.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=12.0, + dispense_mode=5.0, + dispense_mix_flow_rate=30.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=15.0, + dispense_swap_speed=100.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(4000, False, True, False, Liquid.DMSO, True, False)] = ( + _4mlTF_DMSO_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 3500.0: 3715.0, + 500.0: 631.0, + 2500.0: 2691.0, + 1500.0: 1667.0, + 4000.0: 4224.0, + 3000.0: 3202.0, + 0.0: 0.0, + 2000.0: 2179.0, + 100.0: 211.0, + 1000.0: 1151.0, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=500.0, + aspiration_air_transport_volume=20.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=1000.0, + dispense_mode=2.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=20.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=400.0, + dispense_stop_back_volume=20.0, +) + + +star_mapping[(4000, False, True, False, Liquid.DMSO, True, True)] = ( + _4mlTF_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 540.0, + 50.0: 61.5, + 4000.0: 4102.0, + 3000.0: 3083.0, + 0.0: 0.0, + 2000.0: 2070.0, + 100.0: 116.5, + 1000.0: 1060.0, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=500.0, + aspiration_air_transport_volume=20.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=1000.0, + dispense_mode=3.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=20.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=400.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(4000, False, True, False, Liquid.DMSO, False, True)] = ( + _4mlTF_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 536.5, + 50.0: 62.3, + 4000.0: 4128.0, + 3000.0: 3109.0, + 0.0: 0.0, + 2000.0: 2069.0, + 100.0: 116.6, + 1000.0: 1054.0, + 10.0: 15.5, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=500.0, + aspiration_air_transport_volume=20.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=500.0, + dispense_mode=5.0, + dispense_mix_flow_rate=500.0, + dispense_air_transport_volume=20.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=5.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=500.0, + dispense_stop_back_volume=0.0, +) + + +# First two times mixing with max volume. +star_mapping[(4000, False, True, False, Liquid.ETHANOL, True, False)] = ( + _4mlTF_EtOH_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 300.0: 300.0, + 3500.0: 3500.0, + 500.0: 500.0, + 2500.0: 2500.0, + 1500.0: 1500.0, + 4000.0: 4000.0, + 3000.0: 3000.0, + 0.0: 0.0, + 2000.0: 2000.0, + 100.0: 100.0, + 1000.0: 1000.0, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=2000.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=1000.0, + dispense_mode=2.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(4000, False, True, False, Liquid.ETHANOL, True, True)] = ( + _4mlTF_EtOH_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 563.0, + 50.0: 72.0, + 4000.0: 4215.0, + 3000.0: 3190.0, + 0.0: 0.0, + 2000.0: 2178.0, + 100.0: 127.5, + 1000.0: 1095.0, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=30.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=30.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=1000.0, + dispense_mode=3.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=30.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=30.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(4000, False, True, False, Liquid.ETHANOL, False, True)] = ( + _4mlTF_EtOH_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 555.0, + 50.0: 68.0, + 4000.0: 4177.0, + 3000.0: 3174.0, + 0.0: 0.0, + 2000.0: 2151.0, + 100.0: 123.5, + 1000.0: 1085.0, + 10.0: 18.6, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=30.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=30.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=1000.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=30.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=30.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=30.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(4000, False, True, False, Liquid.GLYCERIN80, True, True)] = ( + _4mlTF_Glycerin80_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 599.0, + 50.0: 89.0, + 4000.0: 4223.0, + 3000.0: 3211.0, + 0.0: 0.0, + 2000.0: 2195.0, + 100.0: 140.0, + 1000.0: 1159.0, + }, + aspiration_flow_rate=1200.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=30.0, + aspiration_blow_out_volume=100.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=500.0, + dispense_mode=3.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=50.0, + dispense_blow_out_volume=100.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(4000, False, True, False, Liquid.GLYCERIN80, False, True)] = ( + _4mlTF_Glycerin80_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 555.0, + 50.0: 71.0, + 4000.0: 4135.0, + 3000.0: 3122.0, + 0.0: 0.0, + 2000.0: 2101.0, + 100.0: 129.0, + 1000.0: 1083.0, + 10.0: 16.0, + }, + aspiration_flow_rate=1000.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=70.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=5.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=50.0, + dispense_blow_out_volume=70.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(4000, False, True, False, Liquid.WATER, True, False)] = ( + _4mlTF_Water_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 4000.0: 4160.0, + 3000.0: 3160.0, + 0.0: 0.0, + 2000.0: 2160.0, + 100.0: 214.0, + 1000.0: 1148.0, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=500.0, + aspiration_air_transport_volume=20.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=1000.0, + dispense_mode=2.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=20.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=400.0, + dispense_stop_back_volume=20.0, +) + + +star_mapping[(4000, False, True, False, Liquid.WATER, True, True)] = ( + _4mlTF_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 551.8, + 50.0: 66.4, + 4000.0: 4165.0, + 3000.0: 3148.0, + 0.0: 0.0, + 2000.0: 2128.0, + 100.0: 122.7, + 1000.0: 1082.0, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=500.0, + aspiration_air_transport_volume=20.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=1000.0, + dispense_mode=3.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=20.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=400.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(4000, False, True, False, Liquid.WATER, False, True)] = ( + _4mlTF_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 547.0, + 50.0: 65.5, + 4000.0: 4145.0, + 3000.0: 3135.0, + 0.0: 0.0, + 2000.0: 2125.0, + 100.0: 120.9, + 1000.0: 1075.0, + 10.0: 14.5, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=500.0, + aspiration_air_transport_volume=20.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=500.0, + dispense_mode=5.0, + dispense_mix_flow_rate=500.0, + dispense_air_transport_volume=20.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=5.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=500.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.DMSO, True, True)] = ( + _50ulTip_384COREHead_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 52.0, 0.0: 0.0, 20.0: 21.1, 10.0: 10.5}, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.DMSO, False, True)] = ( + _50ulTip_384COREHead_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.0, + 50.0: 51.1, + 30.0: 30.7, + 0.0: 0.0, + 1.0: 0.9, + 10.0: 10.1, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=50.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.ETHANOL, True, True)] = ( + _50ulTip_384COREHead_EtOH_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 6.54, + 15.0: 18.36, + 50.0: 53.0, + 30.0: 33.8, + 0.0: 0.0, + 1.0: 1.8, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=3.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.ETHANOL, False, True)] = ( + _50ulTip_384COREHead_EtOH_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 6.2, + 15.0: 16.9, + 0.5: 1.0, + 50.0: 54.0, + 30.0: 33.1, + 0.0: 0.0, + 1.0: 1.5, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=2.0, + aspiration_swap_speed=6.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=50.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=2.0, + dispense_swap_speed=6.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.GLYCERIN80, False, True)] = ( + _50ulTip_384COREHead_Glycerin80_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.6, + 0.5: 0.65, + 50.0: 55.0, + 0.0: 0.0, + 30.0: 31.5, + 1.0: 1.2, + 10.0: 10.9, + }, + aspiration_flow_rate=30.0, + aspiration_mix_flow_rate=30.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=20.0, + dispense_mode=5.0, + dispense_mix_flow_rate=20.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=2.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.WATER, True, True)] = ( + _50ulTip_384COREHead_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 53.6, 0.0: 0.0, 20.0: 22.4, 10.0: 11.9}, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.WATER, False, True)] = ( + _50ulTip_384COREHead_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.5, + 50.0: 52.2, + 30.0: 31.5, + 0.0: 0.0, + 1.0: 1.2, + 10.0: 11.3, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=2.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=20.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=2.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.WATER, False, False)] = ( + _50ulTip_384COREWasher_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 6.3, + 0.5: 0.9, + 50.0: 55.0, + 40.0: 44.0, + 0.0: 0.0, + 20.0: 22.2, + 1.0: 1.6, + 10.0: 11.9, + 2.0: 2.8, + }, + aspiration_flow_rate=20.0, + aspiration_mix_flow_rate=30.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=15.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=25.0, + dispense_mode=5.0, + dispense_mix_flow_rate=30.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=15.0, + dispense_swap_speed=100.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.DMSO, True, True)] = ( + _50ulTip_conductive_384COREHead_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.2, + 50.0: 50.6, + 30.0: 30.4, + 0.0: 0.0, + 1.0: 0.9, + 20.0: 21.1, + 10.0: 9.3, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.DMSO, True, True)] = ( + _50ulTip_conductive_384COREHead_DMSO_DispenseJet_Empty_below5ul +) = HamiltonLiquidClass( + curve={ + 5.0: 5.2, + 50.0: 50.6, + 30.0: 30.4, + 0.0: 0.0, + 1.0: 0.9, + 20.0: 21.1, + 10.0: 9.3, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=5.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=240.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=5.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.DMSO, True, False)] = ( + _50ulTip_conductive_384COREHead_DMSO_DispenseJet_Part +) = HamiltonLiquidClass( + curve={50.0: 50.0, 0.0: 0.0, 10.0: 10.0}, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=5.0, +) + + +star_mapping[(50, True, True, False, Liquid.DMSO, False, True)] = ( + _50ulTip_conductive_384COREHead_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 0.1: 0.05, + 0.25: 0.1, + 5.0: 4.95, + 0.5: 0.22, + 50.0: 50.0, + 30.0: 30.6, + 0.0: 0.0, + 1.0: 0.74, + 10.0: 9.95, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=50.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.ETHANOL, True, True)] = ( + _50ulTip_conductive_384COREHead_EtOH_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 6.85, + 15.0: 18.36, + 50.0: 54.3, + 30.0: 33.6, + 0.0: 0.0, + 1.0: 1.5, + 10.0: 12.1, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=3.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.ETHANOL, True, True)] = ( + _50ulTip_conductive_384COREHead_EtOH_DispenseJet_Empty_below5ul +) = HamiltonLiquidClass( + curve={ + 5.0: 6.85, + 15.0: 18.36, + 50.0: 54.3, + 30.0: 33.6, + 0.0: 0.0, + 1.0: 1.5, + 10.0: 12.1, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=5.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=240.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=3.0, + dispense_blow_out_volume=5.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.ETHANOL, True, False)] = ( + _50ulTip_conductive_384COREHead_EtOH_DispenseJet_Part +) = HamiltonLiquidClass( + curve={50.0: 50.0, 0.0: 0.0, 10.0: 10.0}, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=3.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=3.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=2.0, +) + + +star_mapping[(50, True, True, False, Liquid.ETHANOL, False, True)] = ( + _50ulTip_conductive_384COREHead_EtOH_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 0.25: 0.3, + 5.0: 6.1, + 0.5: 0.65, + 15.0: 16.9, + 50.0: 52.7, + 30.0: 32.1, + 0.0: 0.0, + 1.0: 1.35, + 10.0: 11.3, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=2.0, + aspiration_swap_speed=6.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=50.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=2.0, + dispense_swap_speed=6.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.GLYCERIN80, False, True)] = ( + _50ulTip_conductive_384COREHead_Glycerin80_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 0.25: 0.05, + 5.0: 5.5, + 0.5: 0.3, + 50.0: 51.9, + 30.0: 31.8, + 0.0: 0.0, + 1.0: 1.0, + 10.0: 10.9, + }, + aspiration_flow_rate=30.0, + aspiration_mix_flow_rate=30.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=20.0, + dispense_mode=5.0, + dispense_mix_flow_rate=20.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=2.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.WATER, True, True)] = ( + _50ulTip_conductive_384COREHead_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.67, + 0.5: 0.27, + 50.0: 51.9, + 30.0: 31.5, + 0.0: 0.0, + 1.0: 1.06, + 20.0: 20.0, + 10.0: 10.9, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=2.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=2.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.WATER, True, True)] = ( + _50ulTip_conductive_384COREHead_Water_DispenseJet_Empty_below5ul +) = HamiltonLiquidClass( + curve={ + 5.0: 5.67, + 0.5: 0.27, + 50.0: 51.9, + 30.0: 31.5, + 0.0: 0.0, + 1.0: 1.06, + 20.0: 20.0, + 10.0: 10.9, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=5.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=240.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=5.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.WATER, True, False)] = ( + _50ulTip_conductive_384COREHead_Water_DispenseJet_Part +) = HamiltonLiquidClass( + curve={50.0: 50.0, 0.0: 0.0, 10.0: 10.0}, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=2.0, +) + + +star_mapping[(50, True, True, False, Liquid.WATER, False, True)] = ( + _50ulTip_conductive_384COREHead_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 0.1: 0.1, + 0.25: 0.15, + 5.0: 5.6, + 0.5: 0.45, + 50.0: 51.0, + 30.0: 31.0, + 0.0: 0.0, + 1.0: 0.98, + 10.0: 10.7, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=2.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=20.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=2.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.WATER, False, False)] = ( + _50ulTip_conductive_384COREWasher_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 6.3, + 0.5: 0.9, + 50.0: 55.0, + 40.0: 44.0, + 0.0: 0.0, + 1.0: 1.6, + 20.0: 22.2, + 65.0: 65.0, + 10.0: 11.9, + 2.0: 2.8, + }, + aspiration_flow_rate=20.0, + aspiration_mix_flow_rate=30.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=15.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=25.0, + dispense_mode=5.0, + dispense_mix_flow_rate=30.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=15.0, + dispense_swap_speed=100.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(5000, False, True, False, Liquid.DMSO, True, False)] = ( + _5mlT_DMSO_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 4500.0: 4606.0, + 3500.0: 3591.0, + 500.0: 525.0, + 2500.0: 2576.0, + 1500.0: 1559.0, + 5000.0: 5114.0, + 4000.0: 4099.0, + 3000.0: 3083.0, + 0.0: 0.0, + 2000.0: 2068.0, + 100.0: 105.0, + 1000.0: 1044.0, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=20.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=1000.0, + dispense_mode=2.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=20.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=400.0, + dispense_stop_back_volume=20.0, +) + + +star_mapping[(5000, False, True, False, Liquid.DMSO, True, True)] = _5mlT_DMSO_DispenseJet_Empty = ( + HamiltonLiquidClass( + curve={ + 500.0: 540.0, + 50.0: 62.0, + 5000.0: 5095.0, + 4000.0: 4075.0, + 0.0: 0.0, + 3000.0: 3065.0, + 100.0: 117.0, + 2000.0: 2060.0, + 1000.0: 1060.0, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=500.0, + aspiration_air_transport_volume=20.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=1000.0, + dispense_mode=3.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=20.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=400.0, + dispense_stop_back_volume=0.0, + ) +) + + +star_mapping[(5000, False, True, False, Liquid.DMSO, False, True)] = ( + _5mlT_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 535.0, + 50.0: 60.3, + 5000.0: 5090.0, + 4000.0: 4078.0, + 0.0: 0.0, + 3000.0: 3066.0, + 100.0: 115.0, + 2000.0: 2057.0, + 10.0: 12.5, + 1000.0: 1054.0, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=500.0, + aspiration_air_transport_volume=20.0, + aspiration_blow_out_volume=20.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=500.0, + dispense_mode=5.0, + dispense_mix_flow_rate=500.0, + dispense_air_transport_volume=20.0, + dispense_blow_out_volume=20.0, + dispense_swap_speed=5.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=500.0, + dispense_stop_back_volume=0.0, +) + + +# First two times mixing with max volume. +star_mapping[(5000, False, True, False, Liquid.ETHANOL, True, False)] = ( + _5mlT_EtOH_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 300.0: 312.0, + 4500.0: 4573.0, + 3500.0: 3560.0, + 500.0: 519.0, + 2500.0: 2551.0, + 1500.0: 1542.0, + 5000.0: 5081.0, + 4000.0: 4066.0, + 3000.0: 3056.0, + 0.0: 0.0, + 2000.0: 2047.0, + 100.0: 104.0, + 1000.0: 1033.0, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=2000.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=1000.0, + dispense_mode=2.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(5000, False, True, False, Liquid.ETHANOL, True, True)] = ( + _5mlT_EtOH_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 563.0, + 50.0: 72.0, + 5000.0: 5230.0, + 4000.0: 4215.0, + 0.0: 0.0, + 3000.0: 3190.0, + 100.0: 129.5, + 2000.0: 2166.0, + 1000.0: 1095.0, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=30.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=30.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=1000.0, + dispense_mode=3.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=30.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=30.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(5000, False, True, False, Liquid.ETHANOL, False, True)] = ( + _5mlT_EtOH_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 555.0, + 50.0: 68.0, + 5000.0: 5204.0, + 4000.0: 4200.0, + 0.0: 0.0, + 3000.0: 3180.0, + 100.0: 123.5, + 2000.0: 2160.0, + 10.0: 22.0, + 1000.0: 1085.0, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=30.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=30.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=1000.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=30.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=30.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=30.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(5000, False, True, False, Liquid.GLYCERIN80, True, True)] = ( + _5mlT_Glycerin80_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 597.0, + 50.0: 89.0, + 5000.0: 5240.0, + 4000.0: 4220.0, + 0.0: 0.0, + 3000.0: 3203.0, + 100.0: 138.0, + 2000.0: 2195.0, + 1000.0: 1166.0, + }, + aspiration_flow_rate=1200.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=30.0, + aspiration_blow_out_volume=100.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=500.0, + dispense_mode=3.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=50.0, + dispense_blow_out_volume=100.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(5000, False, True, False, Liquid.GLYCERIN80, False, True)] = ( + _5mlT_Glycerin80_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 555.0, + 50.0: 71.0, + 5000.0: 5135.0, + 4000.0: 4115.0, + 0.0: 0.0, + 3000.0: 3127.0, + 100.0: 127.0, + 2000.0: 2115.0, + 10.0: 15.5, + 1000.0: 1075.0, + }, + aspiration_flow_rate=1000.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=70.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=5.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=50.0, + dispense_blow_out_volume=70.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(5000, False, True, False, Liquid.WATER, True, False)] = ( + _5mlT_Water_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 5000.0: 5030.0, + 4000.0: 4040.0, + 0.0: 0.0, + 3000.0: 3050.0, + 100.0: 104.0, + 2000.0: 2050.0, + 1000.0: 1040.0, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=500.0, + aspiration_air_transport_volume=20.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=1000.0, + dispense_mode=2.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=20.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=400.0, + dispense_stop_back_volume=20.0, +) + + +star_mapping[(5000, False, True, False, Liquid.WATER, True, True)] = ( + _5mlT_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 551.8, + 50.0: 66.4, + 5000.0: 5180.0, + 4000.0: 4165.0, + 0.0: 0.0, + 3000.0: 3148.0, + 100.0: 122.7, + 2000.0: 2128.0, + 1000.0: 1082.0, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=500.0, + aspiration_air_transport_volume=20.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=1000.0, + dispense_mode=3.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=20.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=400.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(5000, False, True, False, Liquid.WATER, False, True)] = ( + _5mlT_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 547.0, + 50.0: 65.5, + 5000.0: 5145.0, + 4000.0: 4145.0, + 0.0: 0.0, + 3000.0: 3130.0, + 100.0: 120.9, + 2000.0: 2125.0, + 10.0: 15.1, + 1000.0: 1075.0, + }, + aspiration_flow_rate=2000.0, + aspiration_mix_flow_rate=500.0, + aspiration_air_transport_volume=20.0, + aspiration_blow_out_volume=20.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=500.0, + dispense_mode=5.0, + dispense_mix_flow_rate=500.0, + dispense_air_transport_volume=20.0, + dispense_blow_out_volume=20.0, + dispense_swap_speed=5.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=500.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 250 +star_mapping[(1000, False, False, False, Liquid.WATER, True, False)] = ( + HighNeedle_Water_DispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 527.3, + 50.0: 56.8, + 0.0: 0.0, + 100.0: 110.4, + 20.0: 24.7, + 1000.0: 1046.5, + 200.0: 214.6, + 10.0: 13.2, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=500.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=350.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, False, False, Liquid.WATER, True, True)] = ( + HighNeedle_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 527.3, + 50.0: 56.8, + 0.0: 0.0, + 100.0: 110.4, + 20.0: 24.7, + 1000.0: 1046.5, + 200.0: 214.6, + 10.0: 13.2, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=500.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=350.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, False, False, Liquid.WATER, True, False)] = ( + HighNeedle_Water_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 527.3, + 50.0: 56.8, + 0.0: 0.0, + 100.0: 110.4, + 20.0: 24.7, + 1000.0: 1046.5, + 200.0: 214.6, + 10.0: 13.2, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=500.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=350.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 120 +star_mapping[(1000, False, False, False, Liquid.WATER, False, False)] = ( + HighNeedle_Water_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 50.0: 53.1, + 0.0: 0.0, + 20.0: 22.3, + 1000.0: 1000.0, + 10.0: 10.8, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=20.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=20.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, False, False, Liquid.WATER, False, True)] = ( + HighNeedle_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 50.0: 53.1, + 0.0: 0.0, + 20.0: 22.3, + 1000.0: 1000.0, + 10.0: 10.8, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=20.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=20.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, False, False, Liquid.WATER, False, False)] = ( + HighNeedle_Water_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 50.0: 53.1, + 0.0: 0.0, + 20.0: 22.3, + 1000.0: 1000.0, + 10.0: 10.8, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=20.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth Asp. 0.5mm +# - without pre-rinsing +# - Dispense: jet mode empty tip +# - Pipetting-Volumes jet-dispense between 20-1000µl +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 0.57 2.84 +# 50 0.30 0.27 +# 100 0.32 0.54 +# 500 0.13 -0.06 +# 1000 0.11 0.17 +star_mapping[(1000, False, True, False, Liquid.ACETONITRIL80WATER20, True, False)] = ( + HighVolumeAcetonitril80Water20DispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 514.5, + 50.0: 57.5, + 0.0: 0.0, + 20.0: 25.0, + 100.0: 110.5, + 1000.0: 1020.8, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=30.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=100.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth Asp. 2mm, without pre-rinsing +# - Disp.: jet mode empty tip +# - Pipetting-Volumes jet-dispense between 20-1000µl +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 1.04 - 2.68 +# 50 0.66 1.53 +# 100 0.20 0.09 +# 200 0.22 0.71 +# 500 0.14 0.01 +# 1000 0.17 0.02 +star_mapping[(1000, False, True, False, Liquid.ACETONITRILE, True, False)] = ( + HighVolumeAcetonitrilDispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 526.5, + 250.0: 269.0, + 50.0: 60.5, + 0.0: 0.0, + 20.0: 25.5, + 100.0: 112.7, + 1000.0: 1045.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=30.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=100.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.ACETONITRILE, True, True)] = ( + HighVolumeAcetonitrilDispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 526.5, + 250.0: 269.0, + 50.0: 60.5, + 0.0: 0.0, + 100.0: 112.7, + 20.0: 25.5, + 1000.0: 1045.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=30.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.ACETONITRILE, True, False)] = ( + HighVolumeAcetonitrilDispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 526.5, + 250.0: 269.0, + 50.0: 60.5, + 0.0: 0.0, + 100.0: 112.7, + 20.0: 25.5, + 1000.0: 1045.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=30.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=10.0, +) + + +# - submerge depth: Asp. 2mm +# Disp. 2mm +# - without pre-rinsing +# - dispense mode surface empty tip +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 10 2.06 0.63 +# 20 0.59 1.63 +# 50 0.41 2.27 +# 100 0.25 0.40 +# 200 0.18 0.69 +# 500 0.23 0.04 +# 1000 0.22 0.05 +star_mapping[(1000, False, True, False, Liquid.ACETONITRILE, False, False)] = ( + HighVolumeAcetonitrilDispenseSurface +) = HamiltonLiquidClass( + curve={ + 500.0: 525.4, + 250.0: 267.0, + 50.0: 57.6, + 0.0: 0.0, + 20.0: 23.8, + 100.0: 111.2, + 10.0: 12.1, + 1000.0: 1048.8, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=0.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.ACETONITRILE, False, True)] = ( + HighVolumeAcetonitrilDispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 525.4, + 250.0: 267.0, + 50.0: 57.6, + 0.0: 0.0, + 100.0: 111.2, + 20.0: 23.8, + 1000.0: 1048.8, + 10.0: 12.1, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=0.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.ACETONITRILE, False, False)] = ( + HighVolumeAcetonitrilDispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 525.4, + 250.0: 267.0, + 50.0: 57.6, + 0.0: 0.0, + 100.0: 111.2, + 20.0: 23.8, + 1000.0: 1048.8, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=0.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# - Submerge depth: Aspiration 2.0mm +# (bei Schaumbildung durch mischen/vorbenetzen evtl.5mm, LLD-Erkennung) +# - Mischen 3-5 x 950µl, mix position 0.5mm, je nach Volumen im Tube +star_mapping[(1000, False, True, False, Liquid.BLOOD, True, False)] = HighVolumeBloodDispenseJet = ( + HamiltonLiquidClass( + curve={ + 500.0: 536.3, + 250.0: 275.6, + 50.0: 59.8, + 0.0: 0.0, + 20.0: 26.2, + 100.0: 115.3, + 10.0: 12.2, + 1000.0: 1061.6, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=300.0, + dispense_stop_back_volume=0.0, + ) +) + + +# - submerge depth Asp. 5mm, (build airbubbles with mix) +# - 5 x pre-rinsing/mix, with 1000ul, mix position 1mm +# - Disp. mode jet empty tip +# - Pipettingvolume jet-dispense from 10µl - 200µl +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 10 2.95 0.35 +# 20 0.69 0.07 +# 50 0.40 0.46 +# 100 0.23 0.93 +# 200 0.15 0.41 +# +star_mapping[(1000, False, True, False, Liquid.BRAINHOMOGENATE, True, False)] = ( + HighVolumeBrainHomogenateDispenseJet +) = HamiltonLiquidClass( + curve={ + 50.0: 57.9, + 0.0: 0.0, + 20.0: 25.3, + 100.0: 111.3, + 10.0: 14.2, + 200.0: 214.5, + 1000.0: 1038.6, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=0.0, + dispense_mix_flow_rate=500.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth Asp. 1mm, pLLD very high +# - 3 x pre-rinsing, with probevolume or 1 x pre-rinsing with 1000ul, +# mix position 1mm (mix flow rate is intentional low) +# - Disp. mode jet empty tip +# - Pipettingvolume jet-dispense from 400µl - 1000µl, small volumes 20-100ul drops faster out, +# because the channel is not enough saturated +# - To protect, the distance from Asp. to Disp. should be as short as possible, +# because Chloroform could be drop out in a long way! +# - a break time after dispense with about 10s time counter, makes sure the drop which residue +# after dispense drops back into the probetube +# - some droplets on tip after dispense are also with more air transport volume not avoidable +# - sometimes it helps to use Filtertips +# - Correction Curve is taken from MeOH Liqiudclass +# +# +# +star_mapping[(1000, False, True, False, Liquid.CHLOROFORM, True, False)] = ( + HighVolumeChloroformDispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 520.5, + 250.0: 269.0, + 50.0: 62.9, + 0.0: 0.0, + 100.0: 116.3, + 1000.0: 1030.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=0.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=30.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=100.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +# - ohne vorbenetzen, gleicher Tip +# - Aspiration submerge depth 1.0mm +# - Prealiquot equal to Aliquotvolume, jet mode part volume +# - Aliquot, jet mode part volume +# - Postaliquot equal to Aliquotvolume, jet mode empty tip +# +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 50 (12 Aliquots) 0.22 -4.84 +# 100 ( 9 Aliquots) 0.25 -4.81 +# +# +star_mapping[(1000, False, True, False, Liquid.DMSO, True, False)] = HighVolumeDMSOAliquotJet = ( + HamiltonLiquidClass( + curve={ + 500.0: 500.0, + 250.0: 250.0, + 0.0: 0.0, + 30.0: 30.0, + 20.0: 20.0, + 100.0: 100.0, + 10.0: 10.0, + 750.0: 750.0, + 1000.0: 1000.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=10.0, + ) +) + + +star_mapping[(1000, True, True, True, Liquid.DMSO, True, True)] = ( + HighVolumeFilter_96COREHead1000ul_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 508.2, + 0.0: 0.0, + 20.0: 21.7, + 100.0: 101.7, + 1000.0: 1017.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, True, True, True, Liquid.DMSO, False, True)] = ( + HighVolumeFilter_96COREHead1000ul_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 512.5, + 0.0: 0.0, + 100.0: 105.8, + 10.0: 12.7, + 1000.0: 1024.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, True, True, True, Liquid.WATER, True, True)] = ( + HighVolumeFilter_96COREHead1000ul_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 524.0, + 0.0: 0.0, + 20.0: 24.0, + 100.0: 109.2, + 1000.0: 1040.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, True, True, True, Liquid.WATER, False, True)] = ( + HighVolumeFilter_96COREHead1000ul_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 522.0, + 0.0: 0.0, + 100.0: 108.3, + 1000.0: 1034.0, + 10.0: 12.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# - ohne vorbenetzen, gleicher Tip +# - Aspiration submerge depth 1.0mm +# - Prealiquot equal to Aliquotvolume, jet mode part volume +# - Aliquot, jet mode part volume +# - Postaliquot equal to Aliquotvolume, jet mode empty tip +# +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 50 (12 Aliquots) 0.22 -4.84 +# 100 ( 9 Aliquots) 0.25 -4.81 +# +# +star_mapping[(1000, False, True, True, Liquid.DMSO, True, False)] = ( + HighVolumeFilter_DMSO_AliquotDispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 500.0, + 250.0: 250.0, + 30.0: 30.0, + 0.0: 0.0, + 100.0: 100.0, + 20.0: 20.0, + 1000.0: 1000.0, + 750.0: 750.0, + 10.0: 10.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 250 +star_mapping[(1000, False, True, True, Liquid.DMSO, True, False)] = ( + HighVolumeFilter_DMSO_DispenseJet +) = HamiltonLiquidClass( + curve={ + 5.0: 5.1, + 500.0: 511.2, + 250.0: 256.2, + 50.0: 52.2, + 0.0: 0.0, + 20.0: 21.3, + 100.0: 103.4, + 10.0: 10.7, + 1000.0: 1021.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.DMSO, True, True)] = ( + HighVolumeFilter_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 511.2, + 5.0: 5.1, + 250.0: 256.2, + 50.0: 52.2, + 0.0: 0.0, + 100.0: 103.4, + 20.0: 21.3, + 1000.0: 1021.0, + 10.0: 10.7, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.DMSO, True, False)] = ( + HighVolumeFilter_DMSO_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 517.2, + 0.0: 0.0, + 100.0: 109.5, + 20.0: 27.0, + 1000.0: 1027.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 120 +star_mapping[(1000, False, True, True, Liquid.DMSO, False, False)] = ( + HighVolumeFilter_DMSO_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 500.0: 514.3, + 250.0: 259.0, + 50.0: 54.4, + 0.0: 0.0, + 20.0: 22.8, + 100.0: 105.8, + 10.0: 12.1, + 1000.0: 1024.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.DMSO, False, True)] = ( + HighVolumeFilter_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 514.3, + 250.0: 259.0, + 50.0: 54.4, + 0.0: 0.0, + 100.0: 105.8, + 20.0: 22.8, + 1000.0: 1024.5, + 10.0: 12.1, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# +star_mapping[(1000, False, True, True, Liquid.DMSO, False, False)] = ( + HighVolumeFilter_DMSO_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 514.3, + 250.0: 259.0, + 50.0: 54.4, + 0.0: 0.0, + 100.0: 105.8, + 20.0: 22.8, + 1000.0: 1024.5, + 10.0: 12.1, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 250, Stop back volume = 0 +star_mapping[(1000, False, True, True, Liquid.ETHANOL, True, False)] = ( + HighVolumeFilter_EtOH_DispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 534.8, + 250.0: 273.0, + 50.0: 62.9, + 0.0: 0.0, + 20.0: 27.8, + 100.0: 116.3, + 10.0: 15.8, + 1000.0: 1053.9, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.ETHANOL, True, True)] = ( + HighVolumeFilter_EtOH_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 534.8, + 250.0: 273.0, + 50.0: 62.9, + 0.0: 0.0, + 100.0: 116.3, + 20.0: 27.8, + 1000.0: 1053.9, + 10.0: 15.8, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.ETHANOL, True, False)] = ( + HighVolumeFilter_EtOH_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 534.8, + 250.0: 273.0, + 50.0: 62.9, + 0.0: 0.0, + 100.0: 116.3, + 20.0: 27.8, + 1000.0: 1053.9, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=5.0, +) + + +# V1.1: Set mix flow rate to 120 +star_mapping[(1000, False, True, True, Liquid.ETHANOL, False, False)] = ( + HighVolumeFilter_EtOH_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 500.0: 528.4, + 250.0: 269.2, + 50.0: 61.2, + 0.0: 0.0, + 20.0: 27.6, + 100.0: 114.0, + 10.0: 15.7, + 1000.0: 1044.3, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.ETHANOL, False, True)] = ( + HighVolumeFilter_EtOH_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 528.4, + 250.0: 269.2, + 50.0: 61.2, + 0.0: 0.0, + 100.0: 114.0, + 20.0: 27.6, + 1000.0: 1044.3, + 10.0: 15.7, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.ETHANOL, False, False)] = ( + HighVolumeFilter_EtOH_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 528.4, + 250.0: 269.2, + 50.0: 61.2, + 0.0: 0.0, + 100.0: 114.0, + 20.0: 27.6, + 1000.0: 1044.3, + 10.0: 15.7, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 200 +star_mapping[(1000, False, True, True, Liquid.GLYCERIN80, True, False)] = ( + HighVolumeFilter_Glycerin80_DispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 537.8, + 250.0: 277.0, + 50.0: 63.3, + 0.0: 0.0, + 20.0: 28.0, + 100.0: 118.8, + 10.0: 15.2, + 1000.0: 1060.0, + }, + aspiration_flow_rate=200.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=0.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 200 +star_mapping[(1000, False, True, True, Liquid.GLYCERIN80, True, True)] = ( + HighVolumeFilter_Glycerin80_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 537.8, + 250.0: 277.0, + 50.0: 63.3, + 0.0: 0.0, + 100.0: 118.8, + 20.0: 28.0, + 1000.0: 1060.0, + 10.0: 15.2, + }, + aspiration_flow_rate=200.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 120 +star_mapping[(1000, False, True, True, Liquid.GLYCERIN80, False, False)] = ( + HighVolumeFilter_Glycerin80_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 500.0: 513.5, + 250.0: 257.2, + 50.0: 55.0, + 0.0: 0.0, + 20.0: 22.7, + 100.0: 105.5, + 10.0: 12.2, + 1000.0: 1027.2, + }, + aspiration_flow_rate=150.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.5, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.GLYCERIN80, False, True)] = ( + HighVolumeFilter_Glycerin80_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 513.5, + 250.0: 257.2, + 50.0: 55.0, + 0.0: 0.0, + 100.0: 105.5, + 20.0: 22.7, + 1000.0: 1027.2, + 10.0: 12.2, + }, + aspiration_flow_rate=150.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.5, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.GLYCERIN80, False, False)] = ( + HighVolumeFilter_Glycerin80_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 513.5, + 250.0: 257.2, + 50.0: 55.0, + 0.0: 0.0, + 100.0: 105.5, + 20.0: 22.7, + 1000.0: 1027.2, + 10.0: 12.2, + }, + aspiration_flow_rate=150.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.5, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 250 +star_mapping[(1000, False, True, True, Liquid.SERUM, True, False)] = ( + HighVolumeFilter_Serum_AliquotDispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 500.0, + 250.0: 250.0, + 30.0: 30.0, + 0.0: 0.0, + 100.0: 100.0, + 20.0: 20.0, + 1000.0: 1000.0, + 750.0: 750.0, + 10.0: 10.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=300.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 250 +star_mapping[(1000, False, True, True, Liquid.SERUM, True, False)] = ( + HighVolumeFilter_Serum_AliquotJet +) = HamiltonLiquidClass( + curve={ + 500.0: 500.0, + 250.0: 250.0, + 0.0: 0.0, + 30.0: 30.0, + 20.0: 20.0, + 100.0: 100.0, + 10.0: 10.0, + 750.0: 750.0, + 1000.0: 1000.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=300.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 250, Settling time = 0 +star_mapping[(1000, False, True, True, Liquid.SERUM, True, False)] = ( + HighVolumeFilter_Serum_DispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 525.3, + 250.0: 266.6, + 50.0: 57.9, + 0.0: 0.0, + 20.0: 24.2, + 100.0: 111.3, + 10.0: 12.2, + 1000.0: 1038.6, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.SERUM, True, True)] = ( + HighVolumeFilter_Serum_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 525.3, + 250.0: 266.6, + 50.0: 57.9, + 0.0: 0.0, + 100.0: 111.3, + 20.0: 24.2, + 1000.0: 1038.6, + 10.0: 12.2, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.SERUM, True, False)] = ( + HighVolumeFilter_Serum_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 525.3, + 0.0: 0.0, + 100.0: 111.3, + 20.0: 27.3, + 1000.0: 1046.6, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 120 +star_mapping[(1000, False, True, True, Liquid.SERUM, False, False)] = ( + HighVolumeFilter_Serum_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 500.0: 517.5, + 250.0: 261.9, + 50.0: 55.9, + 0.0: 0.0, + 20.0: 23.2, + 100.0: 108.2, + 10.0: 11.8, + 1000.0: 1026.7, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.SERUM, False, True)] = ( + HighVolumeFilter_Serum_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 517.5, + 250.0: 261.9, + 50.0: 55.9, + 0.0: 0.0, + 100.0: 108.2, + 20.0: 23.2, + 1000.0: 1026.7, + 10.0: 11.8, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.SERUM, False, False)] = ( + HighVolumeFilter_Serum_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 523.5, + 0.0: 0.0, + 100.0: 111.2, + 20.0: 23.2, + 1000.0: 1038.7, + 10.0: 11.8, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 250 +star_mapping[(1000, False, True, True, Liquid.WATER, True, False)] = ( + HighVolumeFilter_Water_AliquotDispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 500.0, + 250.0: 250.0, + 30.0: 30.0, + 0.0: 0.0, + 100.0: 100.0, + 20.0: 20.0, + 1000.0: 1000.0, + 750.0: 750.0, + 10.0: 10.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 250 +star_mapping[(1000, False, True, True, Liquid.WATER, True, False)] = ( + HighVolumeFilter_Water_AliquotJet +) = HamiltonLiquidClass( + curve={ + 500.0: 500.0, + 250.0: 250.0, + 0.0: 0.0, + 30.0: 30.0, + 20.0: 20.0, + 100.0: 100.0, + 10.0: 10.0, + 750.0: 750.0, + 1000.0: 1000.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 250 +star_mapping[(1000, False, True, True, Liquid.WATER, True, False)] = ( + HighVolumeFilter_Water_DispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 521.7, + 50.0: 57.2, + 0.0: 0.0, + 20.0: 24.6, + 100.0: 109.6, + 10.0: 13.3, + 200.0: 212.9, + 1000.0: 1034.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.WATER, True, True)] = ( + HighVolumeFilter_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 521.7, + 50.0: 57.2, + 0.0: 0.0, + 100.0: 109.6, + 20.0: 24.6, + 1000.0: 1034.0, + 200.0: 212.9, + 10.0: 13.3, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.WATER, True, False)] = ( + HighVolumeFilter_Water_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 521.7, + 50.0: 57.2, + 0.0: 0.0, + 100.0: 109.6, + 20.0: 27.0, + 1000.0: 1034.0, + 200.0: 212.9, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=20.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 120, Clot retract height = 0 +star_mapping[(1000, False, True, True, Liquid.WATER, False, False)] = ( + HighVolumeFilter_Water_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 500.0: 518.3, + 50.0: 56.3, + 0.0: 0.0, + 20.0: 23.9, + 100.0: 108.3, + 10.0: 12.5, + 200.0: 211.0, + 1000.0: 1028.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.WATER, False, True)] = ( + HighVolumeFilter_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 518.3, + 50.0: 56.3, + 0.0: 0.0, + 20.0: 23.9, + 100.0: 108.3, + 10.0: 12.5, + 200.0: 211.0, + 1000.0: 1028.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, True, Liquid.WATER, False, False)] = ( + HighVolumeFilter_Water_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 518.3, + 50.0: 56.3, + 0.0: 0.0, + 100.0: 108.3, + 20.0: 23.9, + 1000.0: 1028.5, + 200.0: 211.0, + 10.0: 12.7, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=30.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth Asp. 2mm +# - 3 x pre-rinsing, with probevolume, mix position 1mm (mix flow rate is intentional low) +# - Disp. mode jet empty tip +# - Pipettingvolume jet-dispense from 50µl - 1000µl +# - To protect, the distance from Asp. to Disp. should be as short as possible, +# because MeOH could be drop out in a long way! +# - some droplets on tip after dispense are also with more air transport volume not avoidable +# - sometimes it helps to use Filtertips +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 50 0.61 - 1.88 +# 100 1.16 3.02 +# 200 0.55 1.87 +# 500 0.49 - 0.17 +# 1000 0.55 0.712 +# +star_mapping[(1000, False, True, False, Liquid.METHANOL, True, False)] = ( + HighVolumeMeOHDispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 520.5, + 250.0: 269.0, + 50.0: 62.9, + 0.0: 0.0, + 100.0: 116.3, + 1000.0: 1030.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=0.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=30.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=100.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth Asp. 2mm +# - 3 x pre-rinsing, with probevolume, mix position 1mm (mix flow rate is intentional low) +# 200 -1000µl 2x is enough +# - Disp. mode jet empty tip +# - Pipettingvolume jet-dispense from 50µl - 1000µl +# - To protect, the distance from Asp. to Disp. should be as short as possible, +# because MeOH could be drop out in a long way! +# - some droplets on tip after dispense are also with more air transport volume not avoidable +# - sometimes it helps to use Filtertips +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 10 3.71 - 5.23 +# 20 3.12 - 2.27 +# 50 3.97 1.85 +# 100 0.54 1.10 +# 200 0.48 0.18 +# 500 0.17 0.22 +# 1000 0.75 0.29 +star_mapping[(1000, False, True, False, Liquid.METHANOL, False, False)] = ( + HighVolumeMeOHDispenseSurface +) = HamiltonLiquidClass( + curve={ + 500.0: 518.0, + 50.0: 61.3, + 0.0: 0.0, + 20.0: 29.3, + 100.0: 111.0, + 10.0: 19.3, + 200.0: 215.0, + 1000.0: 1030.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=0.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=50.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth Asp. 2mm +# - without pre-rinsing +# - Disp. mode jet empty tip +# - Pipettingvolume jet-dispense from 20µl - 1000µl +# +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 1.45 - 4.76 +# 50 0.59 0.08 +# 100 0.24 0.85 +# 200 0.14 0.06 +# 500 0.12 - 0.07 +# 1000 0.16 0.08 +star_mapping[(1000, False, True, False, Liquid.METHANOL70WATER030, True, False)] = ( + HighVolumeMeOHH2ODispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 528.5, + 250.0: 269.0, + 50.0: 60.5, + 0.0: 0.0, + 100.0: 114.3, + 1000.0: 1050.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=0.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=50.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=100.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +# - use pLLD +# - submerge depth>: Asp. 0.5 mm +# Disp. 1.0 mm (surface) +# - without pre-rinsing +# - dispense mode jet empty tip +# +# +# Typical performance data under laboratory conditions: +# +# (Liquid adapting with parameters like DMSO, correctioncurve like Glycerin80%) +# tested two volumes +# +# Volume µl Precision % Trueness % +# 20 2.85 2.92 +# 200 0.14 0.59 +# +star_mapping[(1000, False, True, False, Liquid.OCTANOL, True, False)] = ( + HighVolumeOctanol100DispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 537.8, + 250.0: 277.0, + 50.0: 63.3, + 0.0: 0.0, + 20.0: 28.0, + 100.0: 118.8, + 10.0: 15.2, + 1000.0: 1060.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=350.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +# - use pLLD +# - submerge depth>: Asp. 0.5 mm +# Disp. 1.0 mm (surface) +# - without pre-rinsing +# - dispense mode surface empty tip +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 10 2.47 - 6.09 +# 20 0.90 1.77 +# 50 0.45 3.14 +# 100 1.07 1.23 +# 200 0.30 1.30 +# 500 0.31 0.01 +# 1000 0.33 0.01 +star_mapping[(1000, False, True, False, Liquid.OCTANOL, False, False)] = ( + HighVolumeOctanol100DispenseSurface +) = HamiltonLiquidClass( + curve={ + 500.0: 531.3, + 250.0: 265.0, + 50.0: 54.4, + 0.0: 0.0, + 20.0: 23.3, + 100.0: 108.8, + 10.0: 12.1, + 1000.0: 1058.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, True, True, False, Liquid.DMSO, True, False)] = ( + HighVolume_96COREHead1000ul_DMSO_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 500.0: 524.0, + 0.0: 0.0, + 100.0: 107.2, + 20.0: 24.0, + 1000.0: 1025.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=20.0, +) + + +star_mapping[(1000, True, True, False, Liquid.DMSO, True, True)] = ( + HighVolume_96COREHead1000ul_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 508.2, + 0.0: 0.0, + 100.0: 101.7, + 20.0: 21.7, + 1000.0: 1017.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, True, True, False, Liquid.DMSO, False, True)] = ( + HighVolume_96COREHead1000ul_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 512.5, + 0.0: 0.0, + 100.0: 105.8, + 1000.0: 1024.5, + 10.0: 12.7, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# to prevent drop's, mix 2x with e.g. 500ul +star_mapping[(1000, True, True, False, Liquid.ETHANOL, True, False)] = ( + HighVolume_96COREHead1000ul_EtOH_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 300.0: 300.0, + 500.0: 500.0, + 0.0: 0.0, + 100.0: 100.0, + 20.0: 20.0, + 1000.0: 1000.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=4.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=400.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(1000, True, True, False, Liquid.ETHANOL, True, True)] = ( + HighVolume_96COREHead1000ul_EtOH_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 516.5, + 0.0: 0.0, + 100.0: 108.3, + 20.0: 24.0, + 1000.0: 1027.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +# to prevent drop's, mix 2x with e.g. 500ul +star_mapping[(1000, True, True, False, Liquid.ETHANOL, False, True)] = ( + HighVolume_96COREHead1000ul_EtOH_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 516.5, + 0.0: 0.0, + 100.0: 107.0, + 1000.0: 1027.0, + 10.0: 14.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=150.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=5.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=5.0, + dispense_mix_flow_rate=150.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=5.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, True, True, False, Liquid.GLYCERIN80, False, True)] = ( + HighVolume_96COREHead1000ul_Glycerin80_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 522.0, + 0.0: 0.0, + 100.0: 115.3, + 1000.0: 1034.0, + 10.0: 12.5, + }, + aspiration_flow_rate=150.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.5, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, True, True, False, Liquid.WATER, True, False)] = ( + HighVolume_96COREHead1000ul_Water_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 500.0: 524.0, + 0.0: 0.0, + 100.0: 107.2, + 20.0: 24.0, + 1000.0: 1025.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(1000, True, True, False, Liquid.WATER, True, True)] = ( + HighVolume_96COREHead1000ul_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 524.0, + 0.0: 0.0, + 100.0: 107.2, + 20.0: 24.0, + 1000.0: 1025.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, True, True, False, Liquid.WATER, False, True)] = ( + HighVolume_96COREHead1000ul_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 522.0, + 0.0: 0.0, + 100.0: 108.3, + 1000.0: 1034.0, + 10.0: 12.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# Liquid class for wash high volume tips with CO-RE 96 Head in CO-RE 96 Head Washer. +star_mapping[(1000, True, True, False, Liquid.WATER, False, False)] = ( + HighVolume_Core96Washer_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 500.0: 520.0, + 50.0: 56.3, + 0.0: 0.0, + 100.0: 110.0, + 20.0: 23.9, + 1000.0: 1050.0, + 200.0: 212.0, + 10.0: 12.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=220.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=220.0, + dispense_mode=5.0, + dispense_mix_flow_rate=220.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=5.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# - ohne vorbenetzen, gleicher Tip +# - Aspiration submerge depth 1.0mm +# - Prealiquot equal to Aliquotvolume, jet mode part volume +# - Aliquot, jet mode part volume +# - Postaliquot equal to Aliquotvolume, jet mode empty tip +# +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 50 (12 Aliquots) 0.22 -4.84 +# 100 ( 9 Aliquots) 0.25 -4.81 +# +# +star_mapping[(1000, False, True, False, Liquid.DMSO, True, False)] = ( + HighVolume_DMSO_AliquotDispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 500.0, + 250.0: 250.0, + 30.0: 30.0, + 0.0: 0.0, + 100.0: 100.0, + 20.0: 20.0, + 1000.0: 1000.0, + 750.0: 750.0, + 10.0: 10.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 250 +star_mapping[(1000, False, True, False, Liquid.DMSO, True, False)] = HighVolume_DMSO_DispenseJet = ( + HamiltonLiquidClass( + curve={ + 5.0: 5.1, + 500.0: 511.2, + 250.0: 256.2, + 50.0: 52.2, + 0.0: 0.0, + 20.0: 21.3, + 100.0: 103.4, + 10.0: 10.7, + 1000.0: 1021.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, + ) +) + + +star_mapping[(1000, False, True, False, Liquid.DMSO, True, True)] = ( + HighVolume_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 511.2, + 5.0: 5.1, + 250.0: 256.2, + 50.0: 52.2, + 0.0: 0.0, + 100.0: 103.4, + 20.0: 21.3, + 1000.0: 1021.0, + 10.0: 10.7, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.DMSO, True, False)] = ( + HighVolume_DMSO_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 520.2, + 0.0: 0.0, + 100.0: 112.0, + 20.0: 27.0, + 1000.0: 1031.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=5.0, +) + + +# V1.1: Set mix flow rate to 120 +star_mapping[(1000, False, True, False, Liquid.DMSO, False, False)] = ( + HighVolume_DMSO_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 500.0: 514.3, + 250.0: 259.0, + 50.0: 54.4, + 0.0: 0.0, + 20.0: 22.8, + 100.0: 105.8, + 10.0: 12.1, + 1000.0: 1024.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.DMSO, False, True)] = ( + HighVolume_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 514.3, + 250.0: 259.0, + 50.0: 54.4, + 0.0: 0.0, + 100.0: 105.8, + 20.0: 22.8, + 1000.0: 1024.5, + 10.0: 12.1, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.DMSO, False, False)] = ( + HighVolume_DMSO_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 514.3, + 250.0: 259.0, + 50.0: 54.4, + 0.0: 0.0, + 100.0: 105.8, + 20.0: 22.8, + 1000.0: 1024.5, + 10.0: 12.4, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set Stop back volume to 0 +star_mapping[(1000, False, True, False, Liquid.ETHANOL, True, False)] = ( + HighVolume_EtOH_DispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 534.8, + 250.0: 273.0, + 50.0: 62.9, + 0.0: 0.0, + 20.0: 27.8, + 100.0: 116.3, + 10.0: 15.8, + 1000.0: 1053.9, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=0.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.ETHANOL, True, True)] = ( + HighVolume_EtOH_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 534.8, + 250.0: 273.0, + 50.0: 62.9, + 0.0: 0.0, + 100.0: 116.3, + 20.0: 27.8, + 1000.0: 1053.9, + 10.0: 15.8, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.ETHANOL, True, False)] = ( + HighVolume_EtOH_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 529.0, + 50.0: 62.9, + 0.0: 0.0, + 100.0: 114.5, + 20.0: 27.8, + 1000.0: 1053.9, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=5.0, +) + + +star_mapping[(1000, False, True, False, Liquid.ETHANOL, False, False)] = ( + HighVolume_EtOH_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 500.0: 528.4, + 250.0: 269.2, + 50.0: 61.2, + 0.0: 0.0, + 100.0: 114.0, + 20.0: 27.6, + 1000.0: 1044.3, + 10.0: 15.7, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.ETHANOL, False, True)] = ( + HighVolume_EtOH_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 528.4, + 250.0: 269.2, + 50.0: 61.2, + 0.0: 0.0, + 20.0: 27.6, + 100.0: 114.0, + 10.0: 15.7, + 1000.0: 1044.3, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.ETHANOL, False, False)] = ( + HighVolume_EtOH_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 528.4, + 250.0: 269.2, + 50.0: 61.2, + 0.0: 0.0, + 100.0: 114.0, + 20.0: 27.6, + 1000.0: 1044.3, + 10.0: 14.7, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 200 +star_mapping[(1000, False, True, False, Liquid.GLYCERIN80, True, False)] = ( + HighVolume_Glycerin80_DispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 537.8, + 250.0: 277.0, + 50.0: 63.3, + 0.0: 0.0, + 20.0: 28.0, + 100.0: 118.8, + 10.0: 15.2, + 1000.0: 1060.0, + }, + aspiration_flow_rate=200.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=0.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.GLYCERIN80, True, True)] = ( + HighVolume_Glycerin80_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 537.8, + 250.0: 277.0, + 50.0: 63.3, + 0.0: 0.0, + 100.0: 118.8, + 20.0: 28.0, + 1000.0: 1060.0, + 10.0: 15.2, + }, + aspiration_flow_rate=200.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 120 +star_mapping[(1000, False, True, False, Liquid.GLYCERIN80, False, False)] = ( + HighVolume_Glycerin80_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 500.0: 513.5, + 250.0: 257.2, + 50.0: 55.0, + 0.0: 0.0, + 20.0: 22.7, + 100.0: 105.5, + 10.0: 12.2, + 1000.0: 1027.2, + }, + aspiration_flow_rate=150.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.5, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.GLYCERIN80, False, True)] = ( + HighVolume_Glycerin80_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 513.5, + 250.0: 257.2, + 50.0: 55.0, + 0.0: 0.0, + 100.0: 105.5, + 20.0: 22.7, + 1000.0: 1027.2, + 10.0: 12.2, + }, + aspiration_flow_rate=150.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.5, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.GLYCERIN80, False, False)] = ( + HighVolume_Glycerin80_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 513.5, + 250.0: 257.2, + 50.0: 55.0, + 0.0: 0.0, + 100.0: 105.5, + 20.0: 22.7, + 1000.0: 1027.2, + 10.0: 12.2, + }, + aspiration_flow_rate=150.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.5, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 250 +star_mapping[(1000, False, True, False, Liquid.SERUM, True, False)] = ( + HighVolume_Serum_AliquotDispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 500.0, + 250.0: 250.0, + 30.0: 30.0, + 0.0: 0.0, + 100.0: 100.0, + 20.0: 20.0, + 1000.0: 1000.0, + 750.0: 750.0, + 10.0: 10.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=300.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 250 +star_mapping[(1000, False, True, False, Liquid.SERUM, True, False)] = ( + HighVolume_Serum_AliquotJet +) = HamiltonLiquidClass( + curve={ + 500.0: 500.0, + 250.0: 250.0, + 0.0: 0.0, + 30.0: 30.0, + 20.0: 20.0, + 100.0: 100.0, + 10.0: 10.0, + 750.0: 750.0, + 1000.0: 1000.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=300.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 250, settling time = 0 +star_mapping[(1000, False, True, False, Liquid.SERUM, True, False)] = ( + HighVolume_Serum_DispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 525.3, + 250.0: 266.6, + 50.0: 57.9, + 0.0: 0.0, + 20.0: 24.2, + 100.0: 111.3, + 10.0: 12.2, + 1000.0: 1038.6, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.SERUM, True, True)] = ( + HighVolume_Serum_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 525.3, + 250.0: 266.6, + 50.0: 57.9, + 0.0: 0.0, + 100.0: 111.3, + 20.0: 24.2, + 1000.0: 1038.6, + 10.0: 12.2, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.SERUM, True, False)] = ( + HighVolume_Serum_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 525.3, + 0.0: 0.0, + 100.0: 111.3, + 20.0: 27.3, + 1000.0: 1046.6, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 120 +star_mapping[(1000, False, True, False, Liquid.SERUM, False, False)] = ( + HighVolume_Serum_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 500.0: 517.5, + 250.0: 261.9, + 50.0: 55.9, + 0.0: 0.0, + 20.0: 23.2, + 100.0: 108.2, + 10.0: 11.8, + 1000.0: 1026.7, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.SERUM, False, True)] = ( + HighVolume_Serum_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 517.5, + 250.0: 261.9, + 50.0: 55.9, + 0.0: 0.0, + 100.0: 108.2, + 20.0: 23.2, + 1000.0: 1026.7, + 10.0: 11.8, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.SERUM, False, False)] = ( + HighVolume_Serum_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 50.0: 55.9, + 0.0: 0.0, + 100.0: 108.2, + 20.0: 23.2, + 1000.0: 1037.7, + 10.0: 11.8, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 250 +star_mapping[(1000, False, True, False, Liquid.WATER, True, False)] = ( + HighVolume_Water_AliquotDispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 500.0, + 250.0: 250.0, + 30.0: 30.0, + 0.0: 0.0, + 100.0: 100.0, + 20.0: 20.0, + 1000.0: 1000.0, + 750.0: 750.0, + 10.0: 10.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 250 +star_mapping[(1000, False, True, False, Liquid.WATER, True, False)] = ( + HighVolume_Water_AliquotJet +) = HamiltonLiquidClass( + curve={ + 500.0: 500.0, + 250.0: 250.0, + 0.0: 0.0, + 30.0: 30.0, + 20.0: 20.0, + 100.0: 100.0, + 10.0: 10.0, + 750.0: 750.0, + 1000.0: 1000.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 250 +star_mapping[(1000, False, True, False, Liquid.WATER, True, False)] = ( + HighVolume_Water_DispenseJet +) = HamiltonLiquidClass( + curve={ + 500.0: 521.7, + 50.0: 57.2, + 0.0: 0.0, + 20.0: 24.6, + 100.0: 109.6, + 10.0: 13.3, + 200.0: 212.9, + 1000.0: 1034.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=0.0, + dispense_mix_flow_rate=250.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.WATER, True, True)] = ( + HighVolume_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 521.7, + 50.0: 57.2, + 0.0: 0.0, + 100.0: 109.6, + 20.0: 24.6, + 1000.0: 1034.0, + 200.0: 212.9, + 10.0: 13.3, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=40.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=40.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.WATER, True, False)] = ( + HighVolume_Water_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 521.7, + 0.0: 0.0, + 100.0: 109.6, + 20.0: 26.9, + 1000.0: 1040.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=300.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=18.0, +) + + +# V1.1: Set mix flow rate to 120, clot retract height = 0 +star_mapping[(1000, False, True, False, Liquid.WATER, False, False)] = ( + HighVolume_Water_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 500.0: 518.3, + 50.0: 56.3, + 0.0: 0.0, + 20.0: 23.9, + 100.0: 108.3, + 10.0: 12.5, + 200.0: 211.0, + 1000.0: 1028.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.WATER, False, True)] = ( + HighVolume_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 500.0: 518.3, + 50.0: 56.3, + 0.0: 0.0, + 100.0: 108.3, + 20.0: 23.9, + 1000.0: 1028.5, + 200.0: 211.0, + 10.0: 12.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=120.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(1000, False, True, False, Liquid.WATER, False, False)] = ( + HighVolume_Water_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 500.0: 518.3, + 50.0: 56.3, + 0.0: 0.0, + 100.0: 108.3, + 20.0: 23.9, + 1000.0: 1036.5, + 200.0: 211.0, + 10.0: 12.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=120.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=50.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# - without pre-rinsing +# - submerge depth Asp. 1mm +# - for Disp. in empty PCR-Plate from 1µl up +# - fix height from bottom between 0.5-0.7mm +# - dispense mode jet empty tip +# - also with higher DNA concentration +star_mapping[(10, False, False, False, Liquid.DNA_TRIS_EDTA, True, False)] = ( + LowNeedleDNADispenseJet +) = HamiltonLiquidClass( + curve={ + 5.0: 5.7, + 0.5: 1.0, + 50.0: 53.0, + 0.0: 0.0, + 20.0: 22.1, + 1.0: 1.5, + 10.0: 10.8, + 2.0: 2.7, + }, + aspiration_flow_rate=80.0, + aspiration_mix_flow_rate=80.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=0.0, + dispense_mix_flow_rate=80.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.5, +) + + +# - without pre-rinsing +# - submerge depth Asp. 1mm +# - for Disp. in empty PCR-Plate/on empty Plate from 1µl up +# - fix height from bottom between 0.5-0.7mm +# - also with higher DNA concentration +star_mapping[(10, False, False, False, Liquid.DNA_TRIS_EDTA, False, False)] = ( + LowNeedleDNADispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 5.7, + 0.5: 1.0, + 50.0: 53.0, + 0.0: 0.0, + 20.0: 22.1, + 1.0: 1.5, + 10.0: 10.8, + 2.0: 2.7, + }, + aspiration_flow_rate=80.0, + aspiration_mix_flow_rate=80.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=1.0, + dispense_mix_flow_rate=80.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 60 +star_mapping[(10, False, False, False, Liquid.WATER, False, False)] = ( + LowNeedle_SysFlWater_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 35.0: 35.6, + 60.0: 62.7, + 50.0: 51.3, + 40.0: 40.9, + 30.0: 30.0, + 0.0: 0.0, + 31.0: 31.4, + 32.0: 32.7, + }, + aspiration_flow_rate=60.0, + aspiration_mix_flow_rate=60.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=100.0, + dispense_mode=1.0, + dispense_mix_flow_rate=60.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, False, False, Liquid.WATER, True, False)] = LowNeedle_Water_DispenseJet = ( + HamiltonLiquidClass( + curve={50.0: 52.7, 30.0: 31.7, 0.0: 0.0, 20.0: 20.5, 10.0: 10.3}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=15.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=0.0, + ) +) + + +star_mapping[(10, False, False, False, Liquid.WATER, True, True)] = ( + LowNeedle_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 70.0: 70.0, + 50.0: 52.7, + 30.0: 31.7, + 0.0: 0.0, + 20.0: 20.5, + 10.0: 10.3, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=15.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, False, False, Liquid.WATER, True, False)] = ( + LowNeedle_Water_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 70.0: 70.0, + 50.0: 52.7, + 30.0: 31.7, + 0.0: 0.0, + 20.0: 20.5, + 10.0: 10.3, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=15.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 60 +star_mapping[(10, False, False, False, Liquid.WATER, False, False)] = ( + LowNeedle_Water_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 5.0, + 0.5: 0.5, + 50.0: 50.0, + 0.0: 0.0, + 20.0: 20.5, + 1.0: 1.0, + 10.0: 10.0, + 2.0: 2.0, + }, + aspiration_flow_rate=60.0, + aspiration_mix_flow_rate=60.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=100.0, + dispense_mode=1.0, + dispense_mix_flow_rate=60.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, False, False, Liquid.WATER, False, True)] = ( + LowNeedle_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.0, + 0.5: 0.5, + 70.0: 70.0, + 50.0: 50.0, + 0.0: 0.0, + 20.0: 20.5, + 1.0: 1.0, + 10.0: 10.0, + 2.0: 2.0, + }, + aspiration_flow_rate=60.0, + aspiration_mix_flow_rate=60.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=100.0, + dispense_mode=5.0, + dispense_mix_flow_rate=60.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, False, False, Liquid.WATER, False, False)] = ( + LowNeedle_Water_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 5.0: 5.0, + 0.5: 0.5, + 70.0: 70.0, + 50.0: 50.0, + 0.0: 0.0, + 20.0: 20.5, + 1.0: 1.0, + 10.0: 10.0, + 2.0: 2.0, + }, + aspiration_flow_rate=60.0, + aspiration_mix_flow_rate=60.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=100.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, True, True, True, Liquid.DMSO, False, True)] = ( + LowVolumeFilter_96COREHead1000ul_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={5.0: 5.3, 0.0: 0.0, 1.0: 0.8, 10.0: 10.0}, + aspiration_flow_rate=25.0, + aspiration_mix_flow_rate=25.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=35.0, + dispense_mode=5.0, + dispense_mix_flow_rate=35.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=25.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, True, True, True, Liquid.WATER, False, True)] = ( + LowVolumeFilter_96COREHead1000ul_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={5.0: 5.8, 0.0: 0.0, 1.0: 1.0, 10.0: 10.0}, + aspiration_flow_rate=25.0, + aspiration_mix_flow_rate=25.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=35.0, + dispense_mode=5.0, + dispense_mix_flow_rate=35.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=25.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, True, True, True, Liquid.DMSO, False, True)] = ( + LowVolumeFilter_96COREHead_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={5.0: 5.1, 0.0: 0.0, 1.0: 0.8, 10.0: 10.0}, + aspiration_flow_rate=25.0, + aspiration_mix_flow_rate=25.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=35.0, + dispense_mode=5.0, + dispense_mix_flow_rate=35.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=25.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, True, True, True, Liquid.DMSO, False, False)] = ( + LowVolumeFilter_96COREHead_DMSO_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={5.0: 5.7, 0.0: 0.0, 1.0: 1.5, 10.0: 10.3}, + aspiration_flow_rate=25.0, + aspiration_mix_flow_rate=25.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=35.0, + dispense_mode=4.0, + dispense_mix_flow_rate=35.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=25.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, True, True, True, Liquid.WATER, False, True)] = ( + LowVolumeFilter_96COREHead_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={5.0: 5.6, 0.0: 0.0, 1.0: 1.2, 10.0: 10.0}, + aspiration_flow_rate=25.0, + aspiration_mix_flow_rate=25.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=35.0, + dispense_mode=5.0, + dispense_mix_flow_rate=35.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=25.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, True, True, True, Liquid.WATER, False, False)] = ( + LowVolumeFilter_96COREHead_Water_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={5.0: 5.8, 0.0: 0.0, 1.0: 1.5, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 75 +star_mapping[(10, False, True, True, Liquid.DMSO, False, False)] = ( + LowVolumeFilter_DMSO_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 5.9, + 0.5: 0.8, + 15.0: 16.4, + 0.0: 0.0, + 1.0: 1.4, + 2.0: 2.6, + 10.0: 11.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=1.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=56.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, True, Liquid.DMSO, False, True)] = ( + LowVolumeFilter_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.9, + 0.5: 0.8, + 0.0: 0.0, + 1.0: 1.4, + 10.0: 10.0, + 2.0: 2.6, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, True, Liquid.DMSO, False, False)] = ( + LowVolumeFilter_DMSO_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={5.0: 5.9, 0.0: 0.0, 1.0: 1.4, 10.0: 10.0, 2.0: 2.6}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 75 +star_mapping[(10, False, True, True, Liquid.ETHANOL, False, False)] = ( + LowVolumeFilter_EtOH_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 8.4, + 0.5: 1.9, + 0.0: 0.0, + 1.0: 2.7, + 2.0: 4.1, + 10.0: 13.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=1.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, True, Liquid.ETHANOL, False, True)] = ( + LowVolumeFilter_EtOH_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={5.0: 6.6, 0.0: 0.0, 1.0: 1.8, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, True, Liquid.ETHANOL, False, False)] = ( + LowVolumeFilter_EtOH_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={5.0: 6.4, 0.0: 0.0, 1.0: 1.8, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 10 +star_mapping[(10, False, True, True, Liquid.GLYCERIN, False, False)] = ( + LowVolumeFilter_Glycerin_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 6.5, + 0.5: 1.4, + 15.0: 17.0, + 0.0: 0.0, + 1.0: 2.0, + 2.0: 3.2, + 10.0: 11.8, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=10.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=10.0, + dispense_mode=1.0, + dispense_mix_flow_rate=10.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=5.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=2.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, True, Liquid.GLYCERIN80, False, True)] = ( + LowVolumeFilter_Glycerin_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={5.0: 6.5, 0.0: 0.0, 1.0: 0.6, 10.0: 10.0}, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=10.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=5.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=10.0, + dispense_mode=5.0, + dispense_mix_flow_rate=10.0, + dispense_air_transport_volume=1.0, + dispense_blow_out_volume=5.0, + dispense_swap_speed=2.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=2.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 75 +star_mapping[(10, False, True, True, Liquid.WATER, False, False)] = ( + LowVolumeFilter_Water_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 6.0, + 0.5: 0.8, + 15.0: 16.7, + 0.0: 0.0, + 1.0: 1.4, + 2.0: 2.6, + 10.0: 11.5, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=1.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=56.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, True, Liquid.WATER, False, True)] = ( + LowVolumeFilter_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 6.0, + 0.5: 0.8, + 0.0: 0.0, + 1.0: 1.4, + 10.0: 10.0, + 2.0: 2.6, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, True, Liquid.WATER, False, False)] = ( + LowVolumeFilter_Water_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={5.0: 5.9, 0.0: 0.0, 1.0: 1.2, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +# - Volume 0.5 - 10ul +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing +# - dispense mode surface empty tip +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 0.5 5.77 12.44 +# 1.0 3.65 4.27 +# 2.0 2.18 2.27 +# 5.0 1.08 -1.29 +# 10.0 0.62 0.53 +# +star_mapping[(10, False, True, False, Liquid.PLASMA, False, False)] = ( + LowVolumePlasmaDispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 5.9, + 0.5: 0.8, + 0.0: 0.0, + 1.0: 1.4, + 10.0: 11.5, + 2.0: 2.6, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.5, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=1.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.5, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, False, Liquid.PLASMA, False, True)] = ( + LowVolumePlasmaDispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.6, + 0.5: 0.2, + 0.0: 0.0, + 1.0: 0.9, + 10.0: 11.3, + 2.0: 2.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.5, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.5, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, False, Liquid.PLASMA, False, False)] = ( + LowVolumePlasmaDispenseSurface_Part +) = HamiltonLiquidClass( + curve={5.0: 5.9, 0.0: 0.0, 1.0: 1.3, 10.0: 11.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# - Volume 0.5 - 10ul +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing +# - dispense mode surface empty tip +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 0.5 5.77 12.44 +# 1.0 3.65 4.27 +# 2.0 2.18 2.27 +# 5.0 1.08 -1.29 +# 10.0 0.62 0.53 +# +star_mapping[(10, False, True, False, Liquid.SERUM, False, False)] = ( + LowVolumeSerumDispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 5.6, + 0.5: 0.2, + 0.0: 0.0, + 1.0: 0.9, + 10.0: 11.3, + 2.0: 2.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.5, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=1.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.5, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, False, Liquid.SERUM, False, True)] = ( + LowVolumeSerumDispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.6, + 0.5: 0.2, + 0.0: 0.0, + 1.0: 0.9, + 10.0: 11.3, + 2.0: 2.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.5, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.5, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, False, Liquid.SERUM, False, False)] = ( + LowVolumeSerumDispenseSurface_Part +) = HamiltonLiquidClass( + curve={5.0: 5.9, 0.0: 0.0, 1.0: 1.3, 10.0: 11.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, True, True, False, Liquid.DMSO, False, True)] = ( + LowVolume_96COREHead1000ul_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={5.0: 5.3, 0.0: 0.0, 1.0: 0.8, 10.0: 10.6}, + aspiration_flow_rate=25.0, + aspiration_mix_flow_rate=25.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=35.0, + dispense_mode=5.0, + dispense_mix_flow_rate=35.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=25.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, True, True, False, Liquid.WATER, False, True)] = ( + LowVolume_96COREHead1000ul_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={5.0: 5.8, 0.0: 0.0, 1.0: 1.0, 10.0: 11.2}, + aspiration_flow_rate=25.0, + aspiration_mix_flow_rate=25.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=35.0, + dispense_mode=5.0, + dispense_mix_flow_rate=35.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=25.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, True, True, False, Liquid.DMSO, False, True)] = ( + LowVolume_96COREHead_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={5.0: 5.1, 0.0: 0.0, 1.0: 0.8, 10.0: 10.3}, + aspiration_flow_rate=25.0, + aspiration_mix_flow_rate=25.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=35.0, + dispense_mode=5.0, + dispense_mix_flow_rate=35.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=25.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, True, True, False, Liquid.DMSO, False, False)] = ( + LowVolume_96COREHead_DMSO_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={5.0: 5.9, 0.0: 0.0, 1.0: 1.5, 10.0: 11.0}, + aspiration_flow_rate=25.0, + aspiration_mix_flow_rate=25.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=35.0, + dispense_mode=4.0, + dispense_mix_flow_rate=35.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=25.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, True, True, False, Liquid.WATER, False, True)] = ( + LowVolume_96COREHead_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={5.0: 5.8, 0.0: 0.0, 1.0: 1.3, 10.0: 11.1}, + aspiration_flow_rate=25.0, + aspiration_mix_flow_rate=25.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=35.0, + dispense_mode=5.0, + dispense_mix_flow_rate=35.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=25.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, True, True, False, Liquid.WATER, False, False)] = ( + LowVolume_96COREHead_Water_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={5.0: 5.7, 0.0: 0.0, 1.0: 1.4, 10.0: 10.8}, + aspiration_flow_rate=25.0, + aspiration_mix_flow_rate=25.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=35.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=25.0, + dispense_stop_back_volume=0.0, +) + + +# Liquid class for wash low volume tips with CO-RE 96 Head in CO-RE 96 Head Washer. +star_mapping[(10, True, True, False, Liquid.WATER, False, False)] = ( + LowVolume_Core96Washer_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 6.0, + 0.5: 0.8, + 0.0: 0.0, + 1.0: 1.4, + 10.0: 15.0, + 2.0: 2.6, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=150.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=1.0, + dispense_mix_flow_rate=150.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=5.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=56.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 75 +star_mapping[(10, False, True, False, Liquid.DMSO, False, False)] = ( + LowVolume_DMSO_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 5.9, + 15.0: 16.4, + 0.5: 0.8, + 0.0: 0.0, + 1.0: 1.4, + 10.0: 11.2, + 2.0: 2.6, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=1.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=56.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, False, Liquid.DMSO, False, True)] = ( + LowVolume_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.9, + 0.5: 0.8, + 0.0: 0.0, + 1.0: 1.4, + 10.0: 11.2, + 2.0: 2.6, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, False, Liquid.DMSO, False, False)] = ( + LowVolume_DMSO_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={5.0: 5.9, 0.0: 0.0, 1.0: 1.4, 10.0: 11.2, 2.0: 2.6}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 75 +star_mapping[(10, False, True, False, Liquid.ETHANOL, False, False)] = ( + LowVolume_EtOH_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 8.4, + 0.5: 1.9, + 0.0: 0.0, + 1.0: 2.7, + 10.0: 13.0, + 2.0: 4.1, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=1.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, False, Liquid.ETHANOL, False, True)] = ( + LowVolume_EtOH_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={5.0: 7.3, 0.0: 0.0, 1.0: 2.4, 10.0: 13.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, False, Liquid.ETHANOL, False, False)] = ( + LowVolume_EtOH_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={5.0: 7.0, 0.0: 0.0, 1.0: 2.4, 10.0: 13.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 10 +star_mapping[(10, False, True, False, Liquid.GLYCERIN, False, False)] = ( + LowVolume_Glycerin_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 6.5, + 15.0: 17.0, + 0.5: 1.4, + 0.0: 0.0, + 1.0: 2.0, + 10.0: 11.8, + 2.0: 3.2, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=10.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=10.0, + dispense_mode=1.0, + dispense_mix_flow_rate=10.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=5.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=2.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 75 +star_mapping[(10, False, True, False, Liquid.WATER, False, False)] = ( + LowVolume_Water_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 6.0, + 15.0: 16.7, + 0.5: 0.8, + 0.0: 0.0, + 1.0: 1.4, + 10.0: 11.5, + 2.0: 2.6, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=1.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=56.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, True, True, False, Liquid.WATER, False, False)] = ( + LowVolume_Water_DispenseSurface96Head +) = HamiltonLiquidClass( + curve={5.0: 6.0, 0.0: 0.0, 1.0: 1.0, 10.0: 11.5}, + aspiration_flow_rate=25.0, + aspiration_mix_flow_rate=25.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=35.0, + dispense_mode=1.0, + dispense_mix_flow_rate=35.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=25.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, True, True, False, Liquid.WATER, False, True)] = ( + LowVolume_Water_DispenseSurfaceEmpty96Head +) = HamiltonLiquidClass( + curve={5.0: 6.0, 0.0: 0.0, 1.0: 1.0, 10.0: 10.9}, + aspiration_flow_rate=25.0, + aspiration_mix_flow_rate=25.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=35.0, + dispense_mode=5.0, + dispense_mix_flow_rate=35.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=25.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, True, True, False, Liquid.WATER, False, False)] = ( + LowVolume_Water_DispenseSurfacePart96Head +) = HamiltonLiquidClass( + curve={5.0: 6.0, 0.0: 0.0, 1.0: 1.0, 10.0: 10.9}, + aspiration_flow_rate=25.0, + aspiration_mix_flow_rate=25.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=35.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=25.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, False, Liquid.WATER, False, True)] = ( + LowVolume_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 6.0, + 0.5: 0.8, + 0.0: 0.0, + 1.0: 1.4, + 10.0: 11.5, + 2.0: 2.6, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(10, False, True, False, Liquid.WATER, False, False)] = ( + LowVolume_Water_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={5.0: 5.9, 0.0: 0.0, 1.0: 1.2, 10.0: 11.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +# Under laboratory conditions: +# +# Settings for aliquots: +# +# Prealiquot: Postaliquot: Aliquots: +# 20ul 20ul 13 x 20ul +# 50ul 50ul 4 x 50ul +# 50ul 50ul 2 x 100 ul +# +# 12 x 20ul = approximately 19.2 ul +# 4 x 50 ul = approximately 48.1 ul +# 2 x 100 ul = approximately 95.3 ul +star_mapping[(300, True, True, True, Liquid.DMSO, True, False)] = ( + SlimTipFilter_96COREHead1000ul_DMSO_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={300.0: 300.0, 50.0: 50.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0}, + aspiration_flow_rate=200.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=18.0, +) + + +star_mapping[(300, True, True, True, Liquid.DMSO, True, True)] = ( + SlimTipFilter_96COREHead1000ul_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 312.3, + 50.0: 55.3, + 0.0: 0.0, + 100.0: 107.7, + 20.0: 22.4, + 200.0: 210.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=20.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=20.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.DMSO, False, True)] = ( + SlimTipFilter_96COREHead1000ul_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 311.9, + 50.0: 54.1, + 0.0: 0.0, + 100.0: 107.5, + 20.0: 22.5, + 10.0: 11.1, + 200.0: 209.4, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=5.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=1.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# Under laboratory conditions: +# +# Settings for aliquots: +# +# Prealiquot: Postaliquot: Aliquots: +# 20ul 20ul 13 x 20ul +# 50ul 50ul 4 x 50ul +# 50ul 50ul 2 x 100 ul +# +# 12 x 20ul = approximately 19.6 ul +# 4 x 50 ul = approximately 48.9 ul +# 2 x 100 ul = approximately 97.2 ul +# +star_mapping[(300, True, True, True, Liquid.WATER, True, False)] = ( + SlimTipFilter_96COREHead1000ul_Water_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 300.0: 300.0, + 50.0: 50.0, + 30.0: 30.0, + 0.0: 0.0, + 100.0: 100.0, + 20.0: 20.0, + }, + aspiration_flow_rate=200.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(300, True, True, True, Liquid.WATER, True, True)] = ( + SlimTipFilter_96COREHead1000ul_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 317.0, + 50.0: 55.8, + 0.0: 0.0, + 100.0: 109.4, + 20.0: 22.7, + 200.0: 213.7, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=20.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=230.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=20.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.WATER, False, True)] = ( + SlimTipFilter_96COREHead1000ul_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 318.7, + 50.0: 54.9, + 0.0: 0.0, + 100.0: 110.4, + 10.0: 11.7, + 200.0: 210.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=5.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# Under laboratory conditions: +# +# Settings for aliquots: +# +# Prealiquot: Postaliquot: Aliquots: +# 20ul 20ul 13 x 20ul +# 50ul 50ul 4 x 50ul +# 50ul 50ul 2 x 100 ul +# +# 12 x 20ul = approximately 19.1 ul +# 4 x 50 ul = approximately 48.3 ul +# 2 x 100 ul = approximately 95.7 ul +# +star_mapping[(300, True, True, True, Liquid.DMSO, True, False)] = ( + SlimTipFilter_DMSO_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={300.0: 300.0, 50.0: 50.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0}, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=18.0, +) + + +star_mapping[(300, True, True, True, Liquid.DMSO, True, True)] = ( + SlimTipFilter_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 309.5, + 50.0: 54.4, + 0.0: 0.0, + 100.0: 106.4, + 20.0: 22.1, + 200.0: 208.2, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.DMSO, False, True)] = ( + SlimTipFilter_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 309.7, + 5.0: 5.6, + 50.0: 53.8, + 0.0: 0.0, + 100.0: 105.4, + 20.0: 22.2, + 10.0: 11.3, + 200.0: 207.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=5.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=1.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# Under laboratory conditions: +# +# Settings for aliquots: +# +# Prealiquot: Postaliquot: Aliquots: +# 20ul 20ul 12 x 20ul +# 50ul 50ul 4 x 50ul +# 50ul 50ul 2 x 100 ul +# +# 12 x 20ul = approximately 21.3 ul +# 4 x 50 ul = approximately 54.3 ul +# 2 x 100 ul = approximately 105.2 ul +star_mapping[(300, True, True, True, Liquid.ETHANOL, True, False)] = ( + SlimTipFilter_EtOH_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 300.0: 300.0, + 50.0: 50.0, + 0.0: 0.0, + 100.0: 100.0, + 20.0: 20.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(300, True, True, True, Liquid.ETHANOL, True, True)] = ( + SlimTipFilter_EtOH_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 320.4, + 50.0: 57.2, + 0.0: 0.0, + 100.0: 110.5, + 20.0: 24.5, + 200.0: 215.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.ETHANOL, False, True)] = ( + SlimTipFilter_EtOH_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 313.9, + 50.0: 55.4, + 0.0: 0.0, + 100.0: 107.7, + 20.0: 23.2, + 10.0: 12.4, + 200.0: 210.6, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=2.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=5.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=2.0, + dispense_swap_speed=50.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.GLYCERIN80, False, True)] = ( + SlimTipFilter_Glycerin_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 312.0, + 50.0: 55.0, + 0.0: 0.0, + 100.0: 107.8, + 20.0: 22.9, + 10.0: 11.8, + 200.0: 210.0, + }, + aspiration_flow_rate=30.0, + aspiration_mix_flow_rate=30.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=30.0, + dispense_mode=5.0, + dispense_mix_flow_rate=30.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=2.0, + dispense_stop_back_volume=0.0, +) + + +# Under laboratory conditions: +# +# Settings for aliquots: +# +# Prealiquot: Postaliquot: Aliquots: +# 20ul 20ul 13 x 20ul +# 50ul 50ul 4 x 50ul +# 50ul 50ul 2 x 100 ul +# +# 12 x 20ul = approximately 19.6 ul +# 4 x 50 ul = approximately 49.2 ul +# 2 x 100 ul = approximately 97.5 ul +star_mapping[(300, True, True, True, Liquid.WATER, True, False)] = ( + SlimTipFilter_Water_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 300.0: 300.0, + 50.0: 50.0, + 30.0: 30.0, + 0.0: 0.0, + 100.0: 100.0, + 20.0: 20.0, + }, + aspiration_flow_rate=200.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=3.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=3.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(300, True, True, True, Liquid.WATER, True, True)] = ( + SlimTipFilter_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 317.2, + 50.0: 55.6, + 0.0: 0.0, + 100.0: 108.6, + 20.0: 22.6, + 200.0: 212.8, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.WATER, False, True)] = ( + SlimTipFilter_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 314.1, + 5.0: 6.2, + 50.0: 54.7, + 0.0: 0.0, + 100.0: 108.0, + 20.0: 22.7, + 10.0: 11.9, + 200.0: 211.3, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=5.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# Under laboratory conditions: +# +# Settings for aliquots: +# +# Prealiquot: Postaliquot: Aliquots: +# 20ul 20ul 13 x 20ul +# 50ul 50ul 4 x 50ul +# 50ul 50ul 2 x 100 ul +# +# 12 x 20ul = approximately 19.2 ul +# 4 x 50 ul = approximately 48.1 ul +# 2 x 100 ul = approximately 95.3 ul +star_mapping[(300, True, True, False, Liquid.DMSO, True, False)] = ( + SlimTip_96COREHead1000ul_DMSO_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={300.0: 300.0, 50.0: 50.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0}, + aspiration_flow_rate=200.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=18.0, +) + + +star_mapping[(300, True, True, False, Liquid.DMSO, True, True)] = ( + SlimTip_96COREHead1000ul_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 313.8, + 50.0: 55.8, + 0.0: 0.0, + 100.0: 109.2, + 20.0: 23.1, + 200.0: 212.7, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.DMSO, False, True)] = ( + SlimTip_96COREHead1000ul_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 312.9, + 50.0: 54.1, + 0.0: 0.0, + 20.0: 22.5, + 100.0: 108.8, + 200.0: 210.9, + 10.0: 11.1, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=5.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=1.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# Under laboratory conditions: +# +# Settings for aliquots: +# +# Prealiquot: Postaliquot: Aliquots: +# 20ul 20ul 10 x 20ul +# 50ul 50ul 4 x 50ul +# 50ul 50ul 2 x 100 ul +# +# 12 x 20ul = approximately 21.8 ul +# 4 x 50 ul = approximately 53.6 ul +# 2 x 100 ul = approximately 105.2 ul +star_mapping[(300, True, True, False, Liquid.ETHANOL, True, False)] = ( + SlimTip_96COREHead1000ul_EtOH_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 300.0: 300.0, + 50.0: 50.0, + 0.0: 0.0, + 100.0: 100.0, + 20.0: 20.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=80.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(300, True, True, False, Liquid.ETHANOL, True, True)] = ( + SlimTip_96COREHead1000ul_EtOH_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 326.2, + 50.0: 58.8, + 0.0: 0.0, + 100.0: 112.7, + 20.0: 25.0, + 200.0: 218.2, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.ETHANOL, False, True)] = ( + SlimTip_96COREHead1000ul_EtOH_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 320.3, + 50.0: 56.7, + 0.0: 0.0, + 100.0: 109.5, + 10.0: 12.4, + 200.0: 213.9, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=2.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=5.0, + dispense_mix_flow_rate=150.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=2.0, + dispense_swap_speed=50.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.GLYCERIN80, False, True)] = ( + SlimTip_96COREHead1000ul_Glycerin80_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 319.3, + 50.0: 58.2, + 0.0: 0.0, + 100.0: 112.1, + 20.0: 23.9, + 10.0: 12.1, + 200.0: 216.9, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=50.0, + dispense_mode=5.0, + dispense_mix_flow_rate=50.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=2.0, + dispense_stop_back_volume=0.0, +) + + +# Under laboratory conditions: +# +# Settings for aliquots: +# +# Prealiquot: Postaliquot: Aliquots: +# 20ul 20ul 13 x 20ul +# 50ul 50ul 4 x 50ul +# 50ul 50ul 2 x 100 ul +# +# 12 x 20ul = approximately 19.6 ul +# 4 x 50 ul = approximately 48.9 ul +# 2 x 100 ul = approximately 97.2 ul +# +star_mapping[(300, True, True, False, Liquid.WATER, True, False)] = ( + SlimTip_96COREHead1000ul_Water_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 300.0: 300.0, + 50.0: 50.0, + 30.0: 30.0, + 0.0: 0.0, + 100.0: 100.0, + 20.0: 20.0, + }, + aspiration_flow_rate=200.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, True, True)] = ( + SlimTip_96COREHead1000ul_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 315.0, + 50.0: 55.5, + 0.0: 0.0, + 100.0: 107.2, + 20.0: 22.8, + 200.0: 211.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, False, True)] = ( + SlimTip_96COREHead1000ul_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 322.7, + 50.0: 56.4, + 0.0: 0.0, + 100.0: 110.4, + 10.0: 11.9, + 200.0: 215.5, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=5.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# Under laboratory conditions: +# +# Settings for aliquots: +# +# Prealiquot: Postaliquot: Aliquots: +# 20ul 20ul 13 x 20ul +# 50ul 50ul 4 x 50ul +# 50ul 50ul 2 x 100 ul +# +# 12 x 20ul = approximately 19.1 ul +# 4 x 50 ul = approximately 48.3 ul +# 2 x 100 ul = approximately 95.7 ul +# +star_mapping[(300, True, True, False, Liquid.DMSO, True, False)] = ( + SlimTip_DMSO_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={300.0: 300.0, 50.0: 50.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0}, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=18.0, +) + + +star_mapping[(300, True, True, False, Liquid.DMSO, True, True)] = SlimTip_DMSO_DispenseJet_Empty = ( + HamiltonLiquidClass( + curve={ + 300.0: 309.5, + 50.0: 54.7, + 0.0: 0.0, + 100.0: 107.2, + 20.0: 22.5, + 200.0: 209.7, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, + ) +) + + +star_mapping[(300, True, True, False, Liquid.DMSO, False, True)] = ( + SlimTip_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 310.2, + 5.0: 5.6, + 50.0: 54.1, + 0.0: 0.0, + 100.0: 106.2, + 20.0: 22.5, + 10.0: 11.3, + 200.0: 208.7, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=5.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=1.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# Under laboratory conditions: +# +# Settings for aliquots: +# +# Prealiquot: Postaliquot: Aliquots: +# 20ul 20ul 12 x 20ul +# 50ul 50ul 4 x 50ul +# 50ul 50ul 2 x 100 ul +# +# 12 x 20ul = approximately 21.3 ul +# 4 x 50 ul = approximately 54.3 ul +# 2 x 100 ul = approximately 105.2 ul +star_mapping[(300, True, True, False, Liquid.ETHANOL, True, False)] = ( + SlimTip_EtOH_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 300.0: 300.0, + 50.0: 50.0, + 0.0: 0.0, + 100.0: 100.0, + 20.0: 20.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(300, True, True, False, Liquid.ETHANOL, True, True)] = ( + SlimTip_EtOH_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 323.4, + 50.0: 57.2, + 0.0: 0.0, + 100.0: 110.5, + 20.0: 24.7, + 200.0: 211.9, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.ETHANOL, False, True)] = ( + SlimTip_EtOH_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 312.9, + 5.0: 6.2, + 50.0: 55.4, + 0.0: 0.0, + 100.0: 107.7, + 20.0: 23.2, + 10.0: 11.9, + 200.0: 210.6, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=2.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=5.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=2.0, + dispense_swap_speed=50.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.GLYCERIN80, False, True)] = ( + SlimTip_Glycerin_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 313.3, + 5.0: 6.0, + 50.0: 55.7, + 0.0: 0.0, + 100.0: 107.8, + 20.0: 22.9, + 10.0: 11.5, + 200.0: 210.0, + }, + aspiration_flow_rate=30.0, + aspiration_mix_flow_rate=30.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=30.0, + dispense_mode=5.0, + dispense_mix_flow_rate=30.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=2.0, + dispense_stop_back_volume=0.0, +) + + +# Under laboratory conditions: +# +# Settings for aliquots: +# +# Prealiquot: Postaliquot: Aliquots: +# 20ul 20ul 13 x 20ul +# 50ul 50ul 4 x 50ul +# 50ul 50ul 2 x 100 ul +# +# 12 x 20ul = approximately 19.6 ul +# 4 x 50 ul = approximately 50.0 ul +# 2 x 100 ul = approximately 98.4 ul +star_mapping[(300, True, True, False, Liquid.SERUM, True, False)] = ( + SlimTip_Serum_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={300.0: 300.0, 50.0: 50.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0}, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=3.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(300, True, True, False, Liquid.SERUM, True, True)] = ( + SlimTip_Serum_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 321.5, + 50.0: 56.0, + 0.0: 0.0, + 100.0: 109.7, + 20.0: 22.8, + 200.0: 215.7, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.SERUM, False, True)] = ( + SlimTip_Serum_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 320.2, + 5.0: 5.5, + 50.0: 55.4, + 0.0: 0.0, + 20.0: 22.6, + 100.0: 109.7, + 200.0: 214.9, + 10.0: 11.3, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=1.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=5.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=1.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# Under laboratory conditions: +# +# Settings for aliquots: +# +# Prealiquot: Postaliquot: Aliquots: +# 20ul 20ul 13 x 20ul +# 50ul 50ul 4 x 50ul +# 50ul 50ul 2 x 100 ul +# +# 12 x 20ul = approximately 19.6 ul +# 4 x 50 ul = approximately 49.2 ul +# 2 x 100 ul = approximately 97.5 ul +star_mapping[(300, True, True, False, Liquid.WATER, True, False)] = ( + SlimTip_Water_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 300.0: 300.0, + 50.0: 50.0, + 30.0: 30.0, + 0.0: 0.0, + 100.0: 100.0, + 20.0: 20.0, + }, + aspiration_flow_rate=200.0, + aspiration_mix_flow_rate=200.0, + aspiration_air_transport_volume=3.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=3.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, True, True)] = ( + SlimTip_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 317.2, + 50.0: 55.6, + 0.0: 0.0, + 20.0: 22.6, + 100.0: 108.6, + 200.0: 212.8, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, False, True)] = ( + SlimTip_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 317.1, + 5.0: 6.2, + 50.0: 55.1, + 0.0: 0.0, + 100.0: 108.0, + 20.0: 22.9, + 10.0: 11.9, + 200.0: 213.0, + }, + aspiration_flow_rate=250.0, + aspiration_mix_flow_rate=250.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=5.0, + dispense_mix_flow_rate=200.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 80 +# V1.2: Stop back volume = 0 (previous value: 15) +star_mapping[(300, False, False, False, Liquid.WATER, True, False)] = ( + StandardNeedle_Water_DispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 311.2, + 50.0: 51.3, + 0.0: 0.0, + 100.0: 103.4, + 20.0: 19.5, + }, + aspiration_flow_rate=80.0, + aspiration_mix_flow_rate=80.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=0.0, + dispense_mix_flow_rate=80.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, False, False, Liquid.WATER, True, True)] = ( + StandardNeedle_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 311.2, + 50.0: 51.3, + 0.0: 0.0, + 100.0: 103.4, + 20.0: 19.5, + }, + aspiration_flow_rate=80.0, + aspiration_mix_flow_rate=80.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, False, False, Liquid.WATER, True, False)] = ( + StandardNeedle_Water_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 311.2, + 50.0: 51.3, + 0.0: 0.0, + 100.0: 103.4, + 20.0: 19.5, + }, + aspiration_flow_rate=80.0, + aspiration_mix_flow_rate=80.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, False, False, Liquid.WATER, False, False)] = ( + StandardNeedle_Water_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 308.4, + 5.0: 6.5, + 50.0: 52.3, + 0.0: 0.0, + 100.0: 102.9, + 20.0: 22.3, + 1.0: 1.1, + 200.0: 205.8, + 10.0: 12.0, + 2.0: 2.1, + }, + aspiration_flow_rate=80.0, + aspiration_mix_flow_rate=80.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=1.0, + dispense_mix_flow_rate=80.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, False, False, Liquid.WATER, False, True)] = ( + StandardNeedle_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 308.4, + 5.0: 6.5, + 50.0: 52.3, + 0.0: 0.0, + 100.0: 102.9, + 20.0: 22.3, + 1.0: 1.1, + 200.0: 205.8, + 10.0: 12.0, + 2.0: 2.1, + }, + aspiration_flow_rate=80.0, + aspiration_mix_flow_rate=80.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=5.0, + dispense_mix_flow_rate=80.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, False, False, Liquid.WATER, False, False)] = ( + StandardNeedle_Water_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 308.4, + 5.0: 6.5, + 50.0: 52.3, + 0.0: 0.0, + 100.0: 102.9, + 20.0: 22.3, + 1.0: 1.1, + 200.0: 205.8, + 10.0: 12.0, + 2.0: 2.1, + }, + aspiration_flow_rate=80.0, + aspiration_mix_flow_rate=80.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# - set Air transport volume to 25ul +# - set Correction 200.0, from 220.0 back to 217.0 (V 1.0) +# +# - submerge depth: Asp. 1mm +# - without pre-rinsing +# - dispense mode jet empty tip +# +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 0.50 2.26 +# 50 0.30 0.65 +# 100 0.22 1.15 +# 200 0.16 0.55 +# 300 0.17 0.35 +# +star_mapping[(300, False, True, False, Liquid.ACETONITRILE, True, False)] = ( + StandardVolumeAcetonitrilDispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 326.2, + 50.0: 57.3, + 0.0: 0.0, + 100.0: 111.5, + 20.0: 24.6, + 200.0: 217.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=25.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.ACETONITRILE, True, True)] = ( + StandardVolumeAcetonitrilDispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 326.2, + 50.0: 57.3, + 0.0: 0.0, + 100.0: 111.5, + 20.0: 24.6, + 200.0: 217.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=25.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.ACETONITRILE, True, False)] = ( + StandardVolumeAcetonitrilDispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 321.2, 50.0: 57.3, 0.0: 0.0, 100.0: 110.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=10.0, +) + + +# - submerge depth: Asp. 2mm +# Disp. 2mm +# - without pre-rinsing +# - dispense mode surface empty tip +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 1 11.17 - 6.64 +# 2 4.50 1.95 +# 5 0.38 0.50 +# 10 0.94 0.73 +# 20 0.63 0.73 +# 50 0.39 1.28 +# 100 0.28 0.94 +# 200 0.65 0.65 +# 300 0.21 0.88 +# +star_mapping[(300, False, True, False, Liquid.ACETONITRILE, False, False)] = ( + StandardVolumeAcetonitrilDispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 328.0, + 5.0: 6.8, + 50.0: 58.5, + 0.0: 0.0, + 100.0: 112.7, + 20.0: 24.8, + 1.0: 1.3, + 200.0: 220.0, + 10.0: 13.0, + 2.0: 3.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=1.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=50.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.ACETONITRILE, False, True)] = ( + StandardVolumeAcetonitrilDispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 328.0, + 5.0: 6.8, + 50.0: 58.5, + 0.0: 0.0, + 100.0: 112.7, + 20.0: 24.8, + 1.0: 1.3, + 200.0: 220.0, + 10.0: 13.0, + 2.0: 3.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=1.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=50.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.ACETONITRILE, False, False)] = ( + StandardVolumeAcetonitrilDispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 328.0, + 5.0: 7.3, + 0.0: 0.0, + 100.0: 112.7, + 10.0: 13.5, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=1.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=20.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=50.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# - ohne vorbenetzen, gleicher Tip +# - Aspiration submerge depth 1.0mm +# - Prealiquot equal to Aliquotvolume, jet mode part volume +# - Aliquot, jet mode part volume +# - Postaliquot equal to Aliquotvolume, jet mode empty tip +# +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 (12 Aliquots) 2.53 -2.97 +# 50 ( 4 Aliquots) 0.84 -2.57 +# +star_mapping[(300, False, True, False, Liquid.DMSO, True, False)] = StandardVolumeDMSOAliquotJet = ( + HamiltonLiquidClass( + curve={350.0: 350.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=0.3, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=10.0, + ) +) + + +# - Volume 5 - 300ul +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - pre-rinsing 3x with Aspiratevolume, ( >100ul perhaps 2x or set mix speed to 100ul/s) +# - dispense mode surface empty tip +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 3.51 3.16 +# 50 1.19 1.09 +# 100 0.76 0.42 +# 200 0.53 0.08 +# 300 0.54 0.22 +# +star_mapping[(300, False, True, False, Liquid.ETHANOL, False, False)] = ( + StandardVolumeEtOHDispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 309.2, + 50.0: 54.8, + 0.0: 0.0, + 100.0: 106.5, + 20.0: 23.7, + 200.0: 208.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=3.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=1.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=0.4, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.ETHANOL, False, True)] = ( + StandardVolumeEtOHDispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 309.2, + 50.0: 54.8, + 0.0: 0.0, + 100.0: 106.5, + 20.0: 23.7, + 200.0: 208.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=3.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.ETHANOL, False, False)] = ( + StandardVolumeEtOHDispenseSurface_Part +) = HamiltonLiquidClass( + curve={300.0: 315.2, 0.0: 0.0, 100.0: 108.5, 20.0: 23.7}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=3.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.DMSO, True, True)] = ( + StandardVolumeFilter_96COREHead1000ul_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 302.5, + 0.0: 0.0, + 100.0: 101.0, + 20.0: 20.4, + 200.0: 201.5, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.DMSO, False, True)] = ( + StandardVolumeFilter_96COREHead1000ul_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 306.0, + 0.0: 0.0, + 100.0: 104.3, + 200.0: 205.0, + 10.0: 12.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.WATER, True, True)] = ( + StandardVolumeFilter_96COREHead1000ul_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 0.0: 0.0, + 100.0: 107.2, + 20.0: 23.2, + 200.0: 211.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.WATER, False, True)] = ( + StandardVolumeFilter_96COREHead1000ul_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 0.0: 0.0, + 100.0: 107.2, + 200.0: 210.0, + 10.0: 11.9, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.DMSO, True, True)] = ( + StandardVolumeFilter_96COREHead_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 303.5, + 0.0: 0.0, + 100.0: 101.8, + 10.0: 10.2, + 200.0: 200.5, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.DMSO, True, False)] = ( + StandardVolumeFilter_96COREHead_DMSO_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 305.0, + 0.0: 0.0, + 100.0: 103.6, + 10.0: 11.5, + 200.0: 206.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(300, True, True, True, Liquid.DMSO, False, True)] = ( + StandardVolumeFilter_96COREHead_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 303.0, + 0.0: 0.0, + 100.0: 101.3, + 10.0: 10.6, + 200.0: 202.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.DMSO, False, False)] = ( + StandardVolumeFilter_96COREHead_DMSO_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 303.0, + 0.0: 0.0, + 100.0: 101.3, + 10.0: 10.1, + 200.0: 202.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.WATER, True, True)] = ( + StandardVolumeFilter_96COREHead_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 309.0, + 0.0: 0.0, + 20.0: 22.3, + 100.0: 104.2, + 10.0: 11.9, + 200.0: 207.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.WATER, True, False)] = ( + StandardVolumeFilter_96COREHead_Water_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 309.0, + 0.0: 0.0, + 20.0: 22.3, + 100.0: 104.2, + 10.0: 11.9, + 200.0: 207.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(300, True, True, True, Liquid.WATER, False, True)] = ( + StandardVolumeFilter_96COREHead_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 306.3, + 0.0: 0.0, + 100.0: 104.5, + 10.0: 11.9, + 200.0: 205.7, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, True, Liquid.WATER, False, False)] = ( + StandardVolumeFilter_96COREHead_Water_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 304.0, + 0.0: 0.0, + 100.0: 105.3, + 10.0: 11.9, + 200.0: 205.7, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# - ohne vorbenetzen, gleicher Tip +# - Aspiration submerge depth 1.0mm +# - Prealiquot equal to Aliquotvolume, jet mode part volume +# - Aliquot, jet mode part volume +# - Postaliquot equal to Aliquotvolume, jet mode empty tip +# +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 (12 Aliquots) 2.53 -2.97 +# 50 ( 4 Aliquots) 0.84 -2.57 +# +star_mapping[(300, False, True, True, Liquid.DMSO, True, False)] = ( + StandardVolumeFilter_DMSO_AliquotDispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 300.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, True, Liquid.DMSO, True, False)] = ( + StandardVolumeFilter_DMSO_DispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 304.6, + 50.0: 51.1, + 0.0: 0.0, + 20.0: 20.7, + 100.0: 101.8, + 200.0: 203.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, True, Liquid.DMSO, True, True)] = ( + StandardVolumeFilter_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 304.6, + 50.0: 51.1, + 0.0: 0.0, + 100.0: 101.8, + 20.0: 20.7, + 200.0: 203.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, True, Liquid.DMSO, True, False)] = ( + StandardVolumeFilter_DMSO_DispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 315.6, 0.0: 0.0, 100.0: 112.8, 20.0: 29.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(300, False, True, True, Liquid.DMSO, False, False)] = ( + StandardVolumeFilter_DMSO_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 308.8, + 5.0: 6.6, + 50.0: 52.9, + 0.0: 0.0, + 1.0: 1.8, + 20.0: 22.1, + 100.0: 103.8, + 2.0: 3.0, + 10.0: 11.9, + 200.0: 205.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=1.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, True, Liquid.DMSO, False, True)] = ( + StandardVolumeFilter_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 308.8, + 5.0: 6.6, + 50.0: 52.9, + 0.0: 0.0, + 1.0: 1.8, + 20.0: 22.1, + 100.0: 103.8, + 2.0: 3.0, + 10.0: 11.9, + 200.0: 205.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, True, Liquid.DMSO, False, False)] = ( + StandardVolumeFilter_DMSO_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 306.8, + 5.0: 6.4, + 50.0: 52.9, + 0.0: 0.0, + 100.0: 103.8, + 20.0: 22.1, + 10.0: 11.9, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 100, Stop back volume=0 +star_mapping[(300, False, True, True, Liquid.ETHANOL, True, False)] = ( + StandardVolumeFilter_EtOH_DispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 310.2, + 50.0: 55.8, + 0.0: 0.0, + 20.0: 24.6, + 100.0: 107.5, + 200.0: 209.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, True, Liquid.ETHANOL, True, True)] = ( + StandardVolumeFilter_EtOH_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 310.2, + 50.0: 55.8, + 0.0: 0.0, + 100.0: 107.5, + 20.0: 24.6, + 200.0: 209.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, True, Liquid.ETHANOL, True, False)] = ( + StandardVolumeFilter_EtOH_DispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 317.2, 0.0: 0.0, 100.0: 110.5, 20.0: 25.6}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=5.0, +) + + +# V1.1: Set mix flow rate to 20, dispense settling time=0, Stop back volume=0 +star_mapping[(300, False, True, True, Liquid.GLYCERIN, True, False)] = ( + StandardVolumeFilter_Glycerin_DispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 309.0, + 50.0: 53.6, + 0.0: 0.0, + 20.0: 22.3, + 100.0: 104.9, + 200.0: 207.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=20.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=20.0, + dispense_mode=0.0, + dispense_mix_flow_rate=20.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 20, dispense settling time=0, Stop back volume=0 +star_mapping[(300, False, True, True, Liquid.GLYCERIN80, True, True)] = ( + StandardVolumeFilter_Glycerin_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 309.0, + 50.0: 53.6, + 0.0: 0.0, + 100.0: 104.9, + 20.0: 22.3, + 200.0: 207.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=20.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=20.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 10 +star_mapping[(300, False, True, True, Liquid.GLYCERIN, False, False)] = ( + StandardVolumeFilter_Glycerin_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 307.9, + 5.0: 6.5, + 50.0: 53.6, + 0.0: 0.0, + 20.0: 22.5, + 100.0: 105.7, + 2.0: 3.2, + 10.0: 12.0, + 200.0: 207.0, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=10.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=10.0, + dispense_mode=1.0, + dispense_mix_flow_rate=10.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=2.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, True, Liquid.GLYCERIN80, False, True)] = ( + StandardVolumeFilter_Glycerin_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 307.9, + 5.0: 6.5, + 50.0: 53.6, + 0.0: 0.0, + 100.0: 105.7, + 20.0: 22.5, + 200.0: 207.0, + 10.0: 12.0, + 2.0: 3.2, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=10.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=10.0, + dispense_mode=5.0, + dispense_mix_flow_rate=10.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=2.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, True, Liquid.GLYCERIN80, False, False)] = ( + StandardVolumeFilter_Glycerin_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 307.9, + 5.0: 6.1, + 0.0: 0.0, + 100.0: 104.7, + 200.0: 207.0, + 10.0: 11.5, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=10.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=10.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=2.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, True, Liquid.SERUM, True, False)] = ( + StandardVolumeFilter_Serum_AliquotDispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 300.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, True, Liquid.SERUM, True, False)] = ( + StandardVolumeFilter_Serum_AliquotJet +) = HamiltonLiquidClass( + curve={300.0: 300.0, 0.0: 0.0, 30.0: 30.0, 20.0: 20.0, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, True, Liquid.SERUM, True, False)] = ( + StandardVolumeFilter_Serum_DispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 315.2, + 50.0: 55.6, + 0.0: 0.0, + 20.0: 23.2, + 100.0: 108.1, + 200.0: 212.1, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, True, Liquid.SERUM, True, True)] = ( + StandardVolumeFilter_Serum_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 315.2, + 50.0: 55.6, + 0.0: 0.0, + 100.0: 108.1, + 20.0: 23.2, + 200.0: 212.1, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, True, Liquid.SERUM, True, False)] = ( + StandardVolumeFilter_Serum_DispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 315.2, 0.0: 0.0, 100.0: 111.5, 20.0: 29.2}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=5.0, +) + + +star_mapping[(300, False, True, True, Liquid.SERUM, False, False)] = ( + StandardVolumeFilter_Serum_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 313.4, + 5.0: 6.3, + 50.0: 54.9, + 0.0: 0.0, + 20.0: 23.0, + 100.0: 107.1, + 2.0: 2.6, + 10.0: 12.0, + 200.0: 210.5, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=1.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, True, Liquid.SERUM, False, False)] = ( + StandardVolumeFilter_Serum_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={300.0: 313.4, 0.0: 0.0, 100.0: 109.1, 10.0: 12.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, True, Liquid.WATER, True, False)] = ( + StandardVolumeFilter_Water_AliquotDispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 300.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, True, Liquid.WATER, True, False)] = ( + StandardVolumeFilter_Water_AliquotJet +) = HamiltonLiquidClass( + curve={300.0: 300.0, 0.0: 0.0, 30.0: 30.0, 20.0: 20.0, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=0.3, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, True, Liquid.WATER, True, False)] = ( + StandardVolumeFilter_Water_DispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 50.0: 55.1, + 0.0: 0.0, + 20.0: 23.2, + 100.0: 107.2, + 200.0: 211.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, True, Liquid.WATER, True, True)] = ( + StandardVolumeFilter_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 50.0: 55.1, + 0.0: 0.0, + 100.0: 107.2, + 20.0: 23.2, + 200.0: 211.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, True, Liquid.WATER, True, False)] = ( + StandardVolumeFilter_Water_DispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 313.5, 0.0: 0.0, 100.0: 110.2, 20.0: 27.2}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, True, Liquid.WATER, False, False)] = ( + StandardVolumeFilter_Water_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 5.0: 6.3, + 0.5: 0.9, + 50.0: 55.1, + 0.0: 0.0, + 1.0: 1.6, + 20.0: 23.2, + 100.0: 107.2, + 2.0: 2.8, + 10.0: 11.9, + 200.0: 211.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, True, Liquid.WATER, False, True)] = ( + StandardVolumeFilter_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 5.0: 6.3, + 0.5: 0.9, + 50.0: 55.1, + 0.0: 0.0, + 100.0: 107.2, + 20.0: 23.2, + 1.0: 1.6, + 200.0: 211.0, + 10.0: 11.9, + 2.0: 2.8, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, True, Liquid.WATER, False, False)] = ( + StandardVolumeFilter_Water_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 5.0: 6.5, + 50.0: 55.1, + 0.0: 0.0, + 20.0: 23.2, + 100.0: 107.2, + 10.0: 11.9, + 200.0: 211.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth Asp. 1mm +# - 3x pre-rinsing with probevolume +# mix position 0mm (mix flow rate is intentional low) +# - Disp. mode jet empty tip +# - Pipettingvolume jet-dispense from 20µl - 300µl +# - To protect, the distance from Asp. to Disp. should be as short as possible( about 12slot), +# because MeOH could drop out in a long way! +# - some droplets on tip after dispense are also with more air transport volume not avoidable +# - sometimes it helps to use Filtertips +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 0.61 0.57 +# 50 1.21 0.87 +# 100 0.63 0.47 +# 200 0.56 0.07 +# 300 0.54 1.12 +# +star_mapping[(300, False, True, False, Liquid.METHANOL, True, False)] = ( + StandardVolumeMeOHDispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 336.0, + 50.0: 63.0, + 0.0: 0.0, + 100.0: 119.5, + 20.0: 28.3, + 200.0: 230.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=30.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=0.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=0.0, + dispense_mix_flow_rate=30.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth Asp. 2mm +# - 5x pre-rinsing with probevolume 5-50µl, 3x pre-rinsing with probevolume >100µl, +# mix position 1mm (mix flow rate is intentional low) +# - Disp. mode jet empty tip +# - Pipettingvolume surface-dispense from 5µl - 300µl +# - To protect, the distance from Asp. to Disp. should be as short as possible( about 12slot), +# because MeOH could drop out in a long way! +# - some droplets on tip after dispense are also with more air transport volume not avoidable +# - sometimes it helps to use Filtertips +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 5 13.22 5.95 +# 10 2.08 1.00 +# 20 1.52 0.58 +# 50 0.63 0.51 +# 100 0.66 0.26 +# 200 0.51 0.59 +# 300 0.81 0.22 +# +star_mapping[(300, False, True, False, Liquid.METHANOL, False, False)] = ( + StandardVolumeMeOHDispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 310.2, + 5.0: 8.0, + 50.0: 55.8, + 0.0: 0.0, + 100.0: 107.5, + 20.0: 24.6, + 200.0: 209.2, + 10.0: 14.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=30.0, + aspiration_air_transport_volume=10.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=0.1, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=30.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=50.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# - use pLLD +# - submerge depth>: Asp. 0.5 mm +# Disp. 1.0 mm (surface) +# - without pre-rinsing +# - dispense mode jet empty tip +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 0.94 0.94 +# 50 0.74 1.20 +# 100 1.39 1.37 +# 200 0.29 0.17 +# 300 0.16 0.80 +# +star_mapping[(300, False, True, False, Liquid.OCTANOL, True, False)] = ( + StandardVolumeOctanol100DispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 319.3, + 50.0: 56.6, + 0.0: 0.0, + 100.0: 109.9, + 20.0: 23.8, + 200.0: 216.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.5, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +# - use pLLD +# - submerge depth>: Asp. 0.5 mm +# Disp. 1.0 mm (surface) +# - without pre-rinsing +# - dispense mode surface empty tip +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 1 7.45 9.13 +# 2 3.99 1.51 +# 5 1.95 1.64 +# 10 0.51 3.81 +# 20 0.34 - 3.95 +# 50 2.74 1.38 +# 100 0.29 1.04 +# 200 0.02 0.12 +# 300 0.11 0.29 +# +star_mapping[(300, False, True, False, Liquid.OCTANOL, False, False)] = ( + StandardVolumeOctanol100DispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 315.0, + 5.0: 6.6, + 50.0: 55.9, + 0.0: 0.0, + 100.0: 106.8, + 20.0: 22.1, + 1.0: 0.8, + 200.0: 212.0, + 10.0: 12.6, + 2.0: 3.7, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.5, + aspiration_over_aspirate_volume=1.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=1.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 2mm +# Disp. 2mm +# - without pre-rinsing +# - dispense mode surface empty tip +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 1 4.67 0.55 +# 5 3.98 2.77 +# 10 1.99 4.39 +# +# +star_mapping[(300, False, True, False, Liquid.PBS_BUFFER, False, False)] = ( + StandardVolumePBSDispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 5.0: 7.5, + 50.0: 55.1, + 0.0: 0.0, + 100.0: 107.2, + 20.0: 23.2, + 1.0: 2.6, + 200.0: 211.0, + 10.0: 12.8, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=5.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=5.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# - submerge depth: Asp. 0.5 mm +# - without pre-rinsing +# - dispense mode jet empty tip +# +# +# +# +# LC-Plasma is a copy from Serumclass, Plasma has the same Parameters and Correctioncurve! +# +# Typical performance data under laboratory conditions: +# (2 Volumes measured as control) +# +# Volume µl Precision % Trueness % +# 100 0.08 1.09 +# 200 0.09 0.91 +# +star_mapping[(300, False, True, False, Liquid.PLASMA, True, False)] = ( + StandardVolumePlasmaDispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 315.2, + 50.0: 55.6, + 0.0: 0.0, + 100.0: 108.1, + 20.0: 23.2, + 200.0: 212.1, + 10.0: 12.3, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=0.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.PLASMA, True, True)] = ( + StandardVolumePlasmaDispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 315.2, + 50.0: 55.6, + 0.0: 0.0, + 20.0: 23.2, + 100.0: 108.1, + 200.0: 212.1, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.PLASMA, True, False)] = ( + StandardVolumePlasmaDispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 315.2, 0.0: 0.0, 20.0: 29.2, 100.0: 111.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=5.0, +) + + +# - submerge depth: Asp. 0.5mm +# Disp. 0.5mm +# - without pre-rinsing +# - dispense mode surface empty tip +# +# +# LC-Plasma is a copy from Serumclass, Plasma has the same Parameters and Correctioncurve! +# +# Typical performance data under laboratory conditions: +# (3 Volumes measured as control) +# +# Volume µl Precision % Trueness % +# 10 2.09 4.37 +# 20 1.16 3.52 +# 60 0.55 2.06 +# +# +star_mapping[(300, False, True, False, Liquid.PLASMA, False, False)] = ( + StandardVolumePlasmaDispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 313.4, + 5.0: 6.3, + 50.0: 54.9, + 0.0: 0.0, + 100.0: 107.1, + 20.0: 23.0, + 200.0: 210.5, + 10.0: 12.0, + 2.0: 2.6, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=1.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.PLASMA, False, True)] = ( + StandardVolumePlasmaDispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 313.4, + 5.0: 6.3, + 50.0: 54.9, + 0.0: 0.0, + 20.0: 23.0, + 100.0: 107.1, + 2.0: 2.6, + 10.0: 12.0, + 200.0: 210.5, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.PLASMA, False, False)] = ( + StandardVolumePlasmaDispenseSurface_Part +) = HamiltonLiquidClass( + curve={300.0: 313.4, 5.0: 6.8, 0.0: 0.0, 100.0: 109.1, 10.0: 12.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.DMSO, True, False)] = ( + StandardVolume_96COREHead1000ul_DMSO_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 300.0: 300.0, + 150.0: 150.0, + 50.0: 50.0, + 0.0: 0.0, + 20.0: 20.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=20.0, +) + + +star_mapping[(300, True, True, False, Liquid.DMSO, True, True)] = ( + StandardVolume_96COREHead1000ul_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 302.5, + 0.0: 0.0, + 100.0: 101.0, + 20.0: 20.4, + 200.0: 201.5, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.DMSO, False, True)] = ( + StandardVolume_96COREHead1000ul_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 306.0, + 0.0: 0.0, + 100.0: 104.3, + 200.0: 205.0, + 10.0: 12.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, True, False)] = ( + StandardVolume_96COREHead1000ul_Water_DispenseJet_Aliquot +) = HamiltonLiquidClass( + curve={ + 300.0: 300.0, + 150.0: 150.0, + 50.0: 50.0, + 0.0: 0.0, + 20.0: 20.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, True, True)] = ( + StandardVolume_96COREHead1000ul_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 0.0: 0.0, + 100.0: 107.2, + 20.0: 23.2, + 200.0: 207.5, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, False, True)] = ( + StandardVolume_96COREHead1000ul_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 0.0: 0.0, + 100.0: 107.2, + 200.0: 210.0, + 10.0: 11.9, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.DMSO, True, True)] = ( + StandardVolume_96COREHead_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 303.5, + 0.0: 0.0, + 100.0: 101.8, + 10.0: 10.2, + 200.0: 200.5, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.DMSO, True, False)] = ( + StandardVolume_96COREHead_DMSO_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 306.0, + 0.0: 0.0, + 100.0: 105.6, + 10.0: 12.2, + 200.0: 207.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(300, True, True, False, Liquid.DMSO, False, True)] = ( + StandardVolume_96COREHead_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 303.0, + 0.0: 0.0, + 100.0: 101.3, + 10.0: 10.6, + 200.0: 202.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.DMSO, False, False)] = ( + StandardVolume_96COREHead_DMSO_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 303.0, + 0.0: 0.0, + 100.0: 101.3, + 10.0: 10.1, + 200.0: 202.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, True, True)] = ( + StandardVolume_96COREHead_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 309.0, + 0.0: 0.0, + 20.0: 22.3, + 100.0: 104.2, + 10.0: 11.9, + 200.0: 207.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, True, False)] = ( + StandardVolume_96COREHead_Water_DispenseJet_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 309.0, + 0.0: 0.0, + 20.0: 22.3, + 100.0: 104.2, + 10.0: 11.9, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, False, True)] = ( + StandardVolume_96COREHead_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 306.3, + 0.0: 0.0, + 100.0: 104.5, + 10.0: 11.9, + 200.0: 205.7, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, False, False)] = ( + StandardVolume_96COREHead_Water_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 304.0, + 0.0: 0.0, + 100.0: 105.3, + 10.0: 11.9, + 200.0: 205.7, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# Liquid class for wash standard volume tips with CO-RE 96 Head in CO-RE 96 Head Washer. +star_mapping[(300, True, True, False, Liquid.WATER, False, False)] = ( + StandardVolume_Core96Washer_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 330.0, + 5.0: 6.3, + 0.5: 0.9, + 50.0: 55.1, + 0.0: 0.0, + 100.0: 107.2, + 20.0: 23.2, + 1.0: 1.6, + 200.0: 211.0, + 10.0: 11.9, + 2.0: 2.8, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=150.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=100.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=150.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=5.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +# - ohne vorbenetzen, gleicher Tip +# - Aspiration submerge depth 1.0mm +# - Prealiquot equal to Aliquotvolume, jet mode part volume +# - Aliquot, jet mode part volume +# - Postaliquot equal to Aliquotvolume, jet mode empty tip +# +# +# +# +# +# Typical performance data under laboratory conditions: +# +# Volume µl Precision % Trueness % +# 20 (12 Aliquots) 2.53 -2.97 +# 50 ( 4 Aliquots) 0.84 -2.57 +# +star_mapping[(300, False, True, False, Liquid.DMSO, True, False)] = ( + StandardVolume_DMSO_AliquotDispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 300.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=250.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, False, Liquid.DMSO, True, False)] = ( + StandardVolume_DMSO_DispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 304.6, + 350.0: 355.2, + 50.0: 51.1, + 0.0: 0.0, + 100.0: 101.8, + 20.0: 20.7, + 200.0: 203.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.DMSO, True, True)] = ( + StandardVolume_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 304.6, + 50.0: 51.1, + 0.0: 0.0, + 20.0: 20.7, + 100.0: 101.8, + 200.0: 203.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.DMSO, True, False)] = ( + StandardVolume_DMSO_DispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 320.0, 0.0: 0.0, 20.0: 30.5, 100.0: 116.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=10.0, +) + + +star_mapping[(300, False, True, False, Liquid.DMSO, False, False)] = ( + StandardVolume_DMSO_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 308.8, + 5.0: 6.6, + 50.0: 52.9, + 350.0: 360.5, + 0.0: 0.0, + 1.0: 1.8, + 20.0: 22.1, + 100.0: 103.8, + 2.0: 3.0, + 10.0: 11.9, + 200.0: 205.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=1.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.DMSO, False, True)] = ( + StandardVolume_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 308.8, + 5.0: 6.6, + 50.0: 52.9, + 0.0: 0.0, + 1.0: 1.8, + 20.0: 22.1, + 100.0: 103.8, + 2.0: 3.0, + 10.0: 11.9, + 200.0: 205.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.DMSO, False, False)] = ( + StandardVolume_DMSO_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 308.8, + 5.0: 6.4, + 50.0: 52.9, + 0.0: 0.0, + 20.0: 22.1, + 100.0: 103.8, + 10.0: 11.9, + 200.0: 205.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 100, stop back volume = 0 +star_mapping[(300, False, True, False, Liquid.ETHANOL, True, False)] = ( + StandardVolume_EtOH_DispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 310.2, + 350.0: 360.5, + 50.0: 55.8, + 0.0: 0.0, + 100.0: 107.5, + 20.0: 24.6, + 200.0: 209.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.ETHANOL, True, True)] = ( + StandardVolume_EtOH_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 310.2, + 50.0: 55.8, + 0.0: 0.0, + 20.0: 24.6, + 100.0: 107.5, + 200.0: 209.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.ETHANOL, True, False)] = ( + StandardVolume_EtOH_DispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 317.2, 0.0: 0.0, 20.0: 25.6, 100.0: 110.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=5.0, +) + + +# V1.1: Set mix flow rate to 20, dispense settling time=0, stop back volume = 0 +star_mapping[(300, False, True, False, Liquid.GLYCERIN, True, False)] = ( + StandardVolume_Glycerin_DispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 309.0, + 350.0: 360.0, + 50.0: 53.6, + 0.0: 0.0, + 100.0: 104.9, + 20.0: 22.3, + 200.0: 207.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=20.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=20.0, + dispense_mode=0.0, + dispense_mix_flow_rate=20.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 20, dispense settling time=0, stop back volume = 0 +star_mapping[(300, False, True, False, Liquid.GLYCERIN80, True, True)] = ( + StandardVolume_Glycerin_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 309.0, + 50.0: 53.6, + 0.0: 0.0, + 100.0: 104.9, + 20.0: 22.3, + 200.0: 207.2, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=20.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=20.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=20.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 10 +star_mapping[(300, False, True, False, Liquid.GLYCERIN, False, False)] = ( + StandardVolume_Glycerin_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 307.9, + 5.0: 6.5, + 350.0: 358.4, + 50.0: 53.6, + 0.0: 0.0, + 100.0: 105.7, + 20.0: 22.5, + 200.0: 207.0, + 10.0: 12.0, + 2.0: 3.2, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=10.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=10.0, + dispense_mode=1.0, + dispense_mix_flow_rate=10.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=2.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.GLYCERIN80, False, True)] = ( + StandardVolume_Glycerin_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 307.9, + 5.0: 6.5, + 50.0: 53.6, + 0.0: 0.0, + 100.0: 105.7, + 20.0: 22.5, + 200.0: 207.0, + 10.0: 12.0, + 2.0: 3.2, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=10.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=10.0, + dispense_mode=5.0, + dispense_mix_flow_rate=10.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=2.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.GLYCERIN80, False, False)] = ( + StandardVolume_Glycerin_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 307.9, + 5.0: 6.2, + 50.0: 53.6, + 0.0: 0.0, + 100.0: 105.7, + 20.0: 22.5, + 200.0: 207.0, + 10.0: 12.0, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=10.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=2.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=10.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=2.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, False, Liquid.SERUM, True, False)] = ( + StandardVolume_Serum_AliquotDispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 300.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, False, Liquid.SERUM, True, False)] = ( + StandardVolume_Serum_AliquotJet +) = HamiltonLiquidClass( + curve={350.0: 350.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=250.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, False, Liquid.SERUM, True, False)] = ( + StandardVolume_Serum_DispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 315.2, + 50.0: 55.6, + 0.0: 0.0, + 100.0: 108.1, + 20.0: 23.2, + 200.0: 212.1, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.SERUM, True, True)] = ( + StandardVolume_Serum_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 315.2, + 50.0: 55.6, + 0.0: 0.0, + 20.0: 23.2, + 100.0: 108.1, + 200.0: 212.1, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.SERUM, True, False)] = ( + StandardVolume_Serum_DispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 315.2, 0.0: 0.0, 20.0: 29.2, 100.0: 111.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=10.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=5.0, +) + + +star_mapping[(300, False, True, False, Liquid.SERUM, False, False)] = ( + StandardVolume_Serum_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 313.4, + 5.0: 6.3, + 50.0: 54.9, + 0.0: 0.0, + 20.0: 23.0, + 100.0: 107.1, + 2.0: 2.6, + 10.0: 12.0, + 200.0: 210.5, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=1.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.SERUM, False, True)] = ( + StandardVolume_Serum_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 313.4, + 5.0: 6.3, + 50.0: 54.9, + 0.0: 0.0, + 20.0: 23.0, + 100.0: 107.1, + 2.0: 2.6, + 10.0: 12.0, + 200.0: 210.5, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.SERUM, False, False)] = ( + StandardVolume_Serum_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={300.0: 313.4, 5.0: 6.8, 0.0: 0.0, 100.0: 109.1, 10.0: 12.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=15.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=10.0, + dispense_stop_back_volume=0.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, False, Liquid.WATER, True, False)] = ( + StandardVolume_Water_AliquotDispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 300.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, False, Liquid.WATER, True, False)] = ( + StandardVolume_Water_AliquotJet +) = HamiltonLiquidClass( + curve={350.0: 350.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=200.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=0.3, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, False, Liquid.WATER, True, False)] = ( + StandardVolume_Water_DispenseJet +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 350.0: 364.3, + 50.0: 55.1, + 0.0: 0.0, + 100.0: 107.2, + 20.0: 23.2, + 200.0: 211.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=0.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, True, True)] = ( + StandardVolume_Water_DispenseJetEmpty96Head +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 0.0: 0.0, + 100.0: 107.2, + 200.0: 205.3, + 10.0: 11.9, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, True, False)] = ( + StandardVolume_Water_DispenseJetPart96Head +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 0.0: 0.0, + 100.0: 107.2, + 200.0: 205.3, + 10.0: 11.9, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.WATER, True, True)] = ( + StandardVolume_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 50.0: 55.1, + 0.0: 0.0, + 20.0: 23.2, + 100.0: 107.2, + 200.0: 211.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.WATER, True, False)] = ( + StandardVolume_Water_DispenseJet_Part +) = HamiltonLiquidClass( + curve={300.0: 313.5, 0.0: 0.0, 20.0: 28.2, 100.0: 111.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=2.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=150.0, + dispense_stop_back_volume=10.0, +) + + +# V1.1: Set mix flow rate to 100 +star_mapping[(300, False, True, False, Liquid.WATER, False, False)] = ( + StandardVolume_Water_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 5.0: 6.3, + 0.5: 0.9, + 350.0: 364.3, + 50.0: 55.1, + 0.0: 0.0, + 100.0: 107.2, + 20.0: 23.2, + 1.0: 1.6, + 200.0: 211.0, + 10.0: 11.9, + 2.0: 2.8, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, False, False)] = ( + StandardVolume_Water_DispenseSurface96Head +) = HamiltonLiquidClass( + curve={300.0: 313.5, 0.0: 0.0, 100.0: 107.2, 10.0: 11.9}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=1.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, False, True)] = ( + StandardVolume_Water_DispenseSurfaceEmpty96Head +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 0.0: 0.0, + 100.0: 107.2, + 200.0: 205.7, + 10.0: 11.9, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, True, True, False, Liquid.WATER, False, False)] = ( + StandardVolume_Water_DispenseSurfacePart96Head +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 0.0: 0.0, + 100.0: 107.2, + 200.0: 205.7, + 10.0: 11.9, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.WATER, False, True)] = ( + StandardVolume_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 5.0: 6.3, + 0.5: 0.9, + 50.0: 55.1, + 0.0: 0.0, + 1.0: 1.6, + 20.0: 23.2, + 100.0: 107.2, + 2.0: 2.8, + 10.0: 11.9, + 200.0: 211.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=100.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(300, False, True, False, Liquid.WATER, False, False)] = ( + StandardVolume_Water_DispenseSurface_Part +) = HamiltonLiquidClass( + curve={ + 300.0: 313.5, + 5.0: 6.8, + 50.0: 55.1, + 0.0: 0.0, + 20.0: 23.2, + 100.0: 107.2, + 10.0: 12.3, + 200.0: 211.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=5.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=4.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=1.0, + dispense_stop_flow_rate=5.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, True, Liquid.DMSO, True, True)] = ( + Tip_50ulFilter_96COREHead1000ul_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 52.1, 30.0: 31.7, 0.0: 0.0, 20.0: 21.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=1.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, True, Liquid.DMSO, False, True)] = ( + Tip_50ulFilter_96COREHead1000ul_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.4, + 50.0: 52.1, + 30.0: 31.5, + 0.0: 0.0, + 1.0: 0.7, + 10.0: 10.8, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, True, Liquid.WATER, True, True)] = ( + Tip_50ulFilter_96COREHead1000ul_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 54.2, 30.0: 33.2, 0.0: 0.0, 20.0: 22.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, True, Liquid.WATER, False, True)] = ( + Tip_50ulFilter_96COREHead1000ul_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.6, + 50.0: 53.6, + 30.0: 32.6, + 0.0: 0.0, + 1.0: 0.8, + 10.0: 11.3, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, True, Liquid.DMSO, True, True)] = ( + Tip_50ulFilter_96COREHead_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 51.4, 0.0: 0.0, 30.0: 31.3, 20.0: 21.0}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, True, Liquid.DMSO, False, True)] = ( + Tip_50ulFilter_96COREHead_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.6, + 50.0: 51.1, + 0.0: 0.0, + 30.0: 31.0, + 1.0: 0.8, + 10.0: 10.7, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, True, Liquid.WATER, True, True)] = ( + Tip_50ulFilter_96COREHead_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 54.0, 30.0: 33.0, 0.0: 0.0, 20.0: 22.4}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, True, Liquid.WATER, False, True)] = ( + Tip_50ulFilter_96COREHead_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.6, + 50.0: 53.5, + 30.0: 32.9, + 0.0: 0.0, + 1.0: 0.8, + 10.0: 11.4, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, True, Liquid.DMSO, True, True)] = ( + Tip_50ulFilter_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 52.5, 0.0: 0.0, 30.0: 31.4, 20.0: 21.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, True, Liquid.DMSO, False, True)] = ( + Tip_50ulFilter_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.5, + 50.0: 52.6, + 0.0: 0.0, + 30.0: 32.0, + 1.0: 0.7, + 10.0: 11.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, True, Liquid.ETHANOL, True, True)] = ( + Tip_50ulFilter_EtOH_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 57.5, 0.0: 0.0, 30.0: 35.8, 20.0: 24.4}, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=3.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, True, Liquid.ETHANOL, False, True)] = ( + Tip_50ulFilter_EtOH_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 6.5, + 50.0: 54.1, + 0.0: 0.0, + 30.0: 33.8, + 1.0: 1.9, + 10.0: 12.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=2.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=2.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, True, Liquid.GLYCERIN80, False, True)] = ( + Tip_50ulFilter_Glycerin80_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.5, + 50.0: 57.0, + 0.0: 0.0, + 30.0: 35.9, + 1.0: 0.6, + 10.0: 12.0, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=50.0, + dispense_mode=5.0, + dispense_mix_flow_rate=10.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=3.0, + dispense_swap_speed=2.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, True, Liquid.SERUM, True, True)] = ( + Tip_50ulFilter_Serum_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 54.6, 30.0: 33.5, 0.0: 0.0, 20.0: 22.6}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, True, Liquid.SERUM, False, True)] = ( + Tip_50ulFilter_Serum_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.7, + 50.0: 54.9, + 30.0: 33.0, + 0.0: 0.0, + 1.0: 0.7, + 10.0: 11.3, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=100.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, True, Liquid.WATER, True, True)] = ( + Tip_50ulFilter_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 54.0, 30.0: 33.6, 0.0: 0.0, 20.0: 22.7}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, True, Liquid.WATER, False, True)] = ( + Tip_50ulFilter_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.7, + 50.0: 54.2, + 30.0: 33.1, + 0.0: 0.0, + 1.0: 0.65, + 10.0: 11.4, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.DMSO, True, True)] = ( + Tip_50ul_96COREHead1000ul_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 52.1, 30.0: 31.7, 0.0: 0.0, 20.0: 21.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=1.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.DMSO, False, True)] = ( + Tip_50ul_96COREHead1000ul_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.4, + 50.0: 52.1, + 30.0: 31.5, + 0.0: 0.0, + 1.0: 0.7, + 10.0: 10.8, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.WATER, True, True)] = ( + Tip_50ul_96COREHead1000ul_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 52.8, 0.0: 0.0, 30.0: 33.2, 20.0: 22.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.WATER, False, True)] = ( + Tip_50ul_96COREHead1000ul_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.8, + 50.0: 53.6, + 30.0: 32.6, + 0.0: 0.0, + 1.0: 0.8, + 10.0: 11.3, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=5.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=5.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=3.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.DMSO, True, True)] = ( + Tip_50ul_96COREHead_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 51.4, 30.0: 31.3, 0.0: 0.0, 20.0: 21.1}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.DMSO, False, True)] = ( + Tip_50ul_96COREHead_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.6, + 50.0: 52.1, + 30.0: 31.6, + 0.0: 0.0, + 1.0: 0.8, + 10.0: 11.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.WATER, True, True)] = ( + Tip_50ul_96COREHead_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 54.1, 0.0: 0.0, 30.0: 33.0, 20.0: 22.4}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, True, True, False, Liquid.WATER, False, True)] = ( + Tip_50ul_96COREHead_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.6, + 50.0: 53.6, + 30.0: 32.9, + 0.0: 0.0, + 1.0: 0.7, + 10.0: 11.4, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +# Liquid class for wash 50ul tips with CO-RE 96 Head in CO-RE 96 Head Washer. +star_mapping[(50, True, True, False, Liquid.WATER, False, False)] = ( + Tip_50ul_Core96Washer_DispenseSurface +) = HamiltonLiquidClass( + curve={ + 5.0: 5.7, + 50.0: 54.2, + 30.0: 33.2, + 0.0: 0.0, + 1.0: 0.5, + 10.0: 11.4, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=0.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=0.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, False, Liquid.DMSO, True, True)] = ( + Tip_50ul_DMSO_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 52.5, 30.0: 32.2, 0.0: 0.0, 20.0: 21.4}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=10.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=1.0, + dispense_blow_out_volume=10.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=200.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, False, Liquid.DMSO, False, True)] = ( + Tip_50ul_DMSO_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.6, + 50.0: 52.6, + 30.0: 32.1, + 0.0: 0.0, + 1.0: 0.7, + 10.0: 11.0, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, False, Liquid.ETHANOL, True, True)] = ( + Tip_50ul_EtOH_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 58.4, 0.0: 0.0, 30.0: 36.0, 20.0: 24.2}, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=50.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=400.0, + dispense_mode=3.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=3.0, + dispense_blow_out_volume=50.0, + dispense_swap_speed=4.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, False, Liquid.ETHANOL, False, True)] = ( + Tip_50ul_EtOH_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 6.7, + 50.0: 54.1, + 0.0: 0.0, + 30.0: 33.7, + 1.0: 2.1, + 10.0: 12.1, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=2.0, + aspiration_swap_speed=50.0, + aspiration_settling_time=0.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=75.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=2.0, + dispense_blow_out_volume=2.0, + dispense_swap_speed=50.0, + dispense_settling_time=0.5, + dispense_stop_flow_rate=50.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, False, Liquid.GLYCERIN80, False, True)] = ( + Tip_50ul_Glycerin80_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.7, + 50.0: 59.4, + 0.0: 0.0, + 30.0: 36.0, + 1.0: 0.3, + 10.0: 11.8, + }, + aspiration_flow_rate=50.0, + aspiration_mix_flow_rate=50.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=2.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=50.0, + dispense_mode=5.0, + dispense_mix_flow_rate=10.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=2.0, + dispense_swap_speed=2.0, + dispense_settling_time=2.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, False, Liquid.SERUM, True, True)] = ( + Tip_50ul_Serum_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 54.6, 30.0: 33.5, 0.0: 0.0, 20.0: 22.6}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=150.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, False, Liquid.SERUM, False, True)] = ( + Tip_50ul_Serum_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.7, + 50.0: 54.9, + 30.0: 33.0, + 0.0: 0.0, + 1.0: 0.7, + 10.0: 11.3, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=100.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, False, Liquid.WATER, True, True)] = ( + Tip_50ul_Water_DispenseJet_Empty +) = HamiltonLiquidClass( + curve={50.0: 54.0, 30.0: 33.5, 0.0: 0.0, 20.0: 22.5}, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=100.0, + aspiration_air_transport_volume=5.0, + aspiration_blow_out_volume=30.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=0.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=180.0, + dispense_mode=3.0, + dispense_mix_flow_rate=1.0, + dispense_air_transport_volume=5.0, + dispense_blow_out_volume=30.0, + dispense_swap_speed=1.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=100.0, + dispense_stop_back_volume=0.0, +) + + +star_mapping[(50, False, True, False, Liquid.WATER, False, True)] = ( + Tip_50ul_Water_DispenseSurface_Empty +) = HamiltonLiquidClass( + curve={ + 5.0: 5.7, + 50.0: 54.2, + 30.0: 33.2, + 0.0: 0.0, + 1.0: 0.7, + 10.0: 11.4, + }, + aspiration_flow_rate=100.0, + aspiration_mix_flow_rate=75.0, + aspiration_air_transport_volume=0.0, + aspiration_blow_out_volume=1.0, + aspiration_swap_speed=2.0, + aspiration_settling_time=1.0, + aspiration_over_aspirate_volume=2.0, + aspiration_clot_retract_height=0.0, + dispense_flow_rate=120.0, + dispense_mode=5.0, + dispense_mix_flow_rate=75.0, + dispense_air_transport_volume=0.0, + dispense_blow_out_volume=1.0, + dispense_swap_speed=2.0, + dispense_settling_time=0.0, + dispense_stop_flow_rate=1.0, + dispense_stop_back_volume=0.0, +) diff --git a/pylabrobot/liquid_handling/liquid_classes/hamilton/vantage.py b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/vantage.py similarity index 99% rename from pylabrobot/liquid_handling/liquid_classes/hamilton/vantage.py rename to pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/vantage.py index 87bf132453f..b136491a660 100644 --- a/pylabrobot/liquid_handling/liquid_classes/hamilton/vantage.py +++ b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/vantage.py @@ -1,6 +1,6 @@ from typing import Dict, Optional, Tuple -from pylabrobot.liquid_handling.liquid_classes.hamilton.base import ( +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import ( HamiltonLiquidClass, ) from pylabrobot.resources.liquid import Liquid diff --git a/pylabrobot/legacy/liquid_handling/liquid_classes/tecan.py b/pylabrobot/legacy/liquid_handling/liquid_classes/tecan.py new file mode 100644 index 00000000000..3cdc0751b2a --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/liquid_classes/tecan.py @@ -0,0 +1,2794 @@ +import re +from typing import Dict, Optional, Tuple + +from pylabrobot.resources.liquid import Liquid +from pylabrobot.resources.tecan import TipType + + +def from_str(s: str) -> Optional[Liquid]: + """Parses a Tecan liquid class name and creates a Liquid object.""" + + m = re.match(r"(\w+) free dispense$", s) + if m is None: + return None + + lc = m.group(1) + if lc in {"Ethanol", "Serum"}: + lc += " 100%" + return Liquid(lc) + + +class TecanLiquidClass: + """A liquid class like used in EVOware.""" + + def __init__( + self, + lld_mode: int, + lld_conductivity: int, + lld_speed: float, + lld_distance: float, + clot_speed: float, + clot_limit: float, + pmp_sensitivity: int, + pmp_viscosity: float, + pmp_character: int, + density: float, + calibration_factor: float, + calibration_offset: float, + aspirate_speed: float, + aspirate_delay: float, + aspirate_stag_volume: float, + aspirate_stag_speed: float, + aspirate_lag_volume: float, + aspirate_lag_speed: float, + aspirate_tag_volume: float, + aspirate_tag_speed: float, + aspirate_excess: float, + aspirate_conditioning: float, + aspirate_pinch_valve: bool, + aspirate_lld: bool, + aspirate_lld_position: int, + aspirate_lld_offset: float, + aspirate_mix: bool, + aspirate_mix_volume: float, + aspirate_mix_cycles: int, + aspirate_retract_position: int, + aspirate_retract_speed: float, + aspirate_retract_offset: float, + dispense_speed: float, + dispense_breakoff: float, + dispense_delay: float, + dispense_tag: bool, + dispense_pinch_valve: bool, + dispense_lld: bool, + dispense_lld_position: int, + dispense_lld_offset: float, + dispense_touching_direction: int, + dispense_touching_speed: float, + dispense_touching_delay: float, + dispense_mix: bool, + dispense_mix_volume: float, + dispense_mix_cycles: int, + dispense_retract_position: int, + dispense_retract_speed: float, + dispense_retract_offset: float, + ): + self.lld_mode = lld_mode + self.lld_conductivity = lld_conductivity + self.lld_speed = lld_speed + self.lld_distance = lld_distance + self.clot_speed = clot_speed + self.clot_limit = clot_limit + self.pmp_sensitivity = pmp_sensitivity + self.pmp_viscosity = pmp_viscosity + self.pmp_character = pmp_character + self.density = density + + self.calibration_factor = calibration_factor + self.calibration_offset = calibration_offset + + self.aspirate_speed = aspirate_speed + self.aspirate_delay = aspirate_delay + self.aspirate_stag_volume = aspirate_stag_volume + self.aspirate_stag_speed = aspirate_stag_speed + self.aspirate_lag_volume = aspirate_lag_volume + self.aspirate_lag_speed = aspirate_lag_speed + self.aspirate_tag_volume = aspirate_tag_volume + self.aspirate_tag_speed = aspirate_tag_speed + self.aspirate_excess = aspirate_excess + self.aspirate_conditioning = aspirate_conditioning + self.aspirate_pinch_valve = aspirate_pinch_valve + self.aspirate_lld = aspirate_lld + self.aspirate_lld_position = aspirate_lld_position + self.aspirate_lld_offset = aspirate_lld_offset + self.aspirate_mix = aspirate_mix + self.aspirate_mix_volume = aspirate_mix_volume + self.aspirate_mix_cycles = aspirate_mix_cycles + self.aspirate_retract_position = aspirate_retract_position + self.aspirate_retract_speed = aspirate_retract_speed + self.aspirate_retract_offset = aspirate_retract_offset + + self.dispense_speed = dispense_speed + self.dispense_breakoff = dispense_breakoff + self.dispense_delay = dispense_delay + self.dispense_tag = dispense_tag + self.dispense_pinch_valve = dispense_pinch_valve + self.dispense_lld = dispense_lld + self.dispense_lld_position = dispense_lld_position + self.dispense_lld_offset = dispense_lld_offset + self.dispense_touching_direction = dispense_touching_direction + self.dispense_touching_speed = dispense_touching_speed + self.dispense_touching_delay = dispense_touching_delay + self.dispense_mix = dispense_mix + self.dispense_mix_volume = dispense_mix_volume + self.dispense_mix_cycles = dispense_mix_cycles + self.dispense_retract_position = dispense_retract_position + self.dispense_retract_speed = dispense_retract_speed + self.dispense_retract_offset = dispense_retract_offset + + def compute_corrected_volume(self, target_volume: float) -> float: + return self.calibration_factor * target_volume + self.calibration_offset + + +mapping: Dict[Tuple[float, float, Liquid, TipType], TecanLiquidClass] = {} + + +def get_liquid_class( + target_volume: float, + liquid_class: Liquid, + tip_type: TipType, +) -> Optional[TecanLiquidClass]: + for (mnv, mxv, lc, tt), tlc in mapping.items(): + if mnv <= target_volume < mxv and lc == liquid_class and tt == tip_type: + return tlc + return None + + +mapping[(3, 15.01, Liquid.DMSO, TipType.STANDARD)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1.1, + calibration_factor=1.063, + calibration_offset=0.1, + aspirate_speed=20, + aspirate_delay=200, + aspirate_stag_volume=10, + aspirate_stag_speed=5, + aspirate_lag_volume=10, + aspirate_lag_speed=50, + aspirate_tag_volume=5, + aspirate_tag_speed=5, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=150, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(15.01, 200.01, Liquid.DMSO, TipType.STANDARD)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1.1, + calibration_factor=1.1, + calibration_offset=-0.3, + aspirate_speed=100, + aspirate_delay=200, + aspirate_stag_volume=20, + aspirate_stag_speed=5, + aspirate_lag_volume=0, + aspirate_lag_speed=50, + aspirate_tag_volume=10, + aspirate_tag_speed=5, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=150, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(200.01, 1000.01, Liquid.DMSO, TipType.STANDARD)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1.1, + calibration_factor=1.002, + calibration_offset=19.3, + aspirate_speed=150, + aspirate_delay=300, + aspirate_stag_volume=20, + aspirate_stag_speed=5, + aspirate_lag_volume=0, + aspirate_lag_speed=50, + aspirate_tag_volume=10, + aspirate_tag_speed=5, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=150, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(3, 15.01, Liquid.DMSO, TipType.DITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1.1, + calibration_factor=1.026, + calibration_offset=0.1, + aspirate_speed=20, + aspirate_delay=200, + aspirate_stag_volume=20, + aspirate_stag_speed=5, + aspirate_lag_volume=5, + aspirate_lag_speed=50, + aspirate_tag_volume=10, + aspirate_tag_speed=5, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(15.01, 200.01, Liquid.DMSO, TipType.DITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1.1, + calibration_factor=1.007, + calibration_offset=0.8, + aspirate_speed=100, + aspirate_delay=200, + aspirate_stag_volume=20, + aspirate_stag_speed=5, + aspirate_lag_volume=20, + aspirate_lag_speed=50, + aspirate_tag_volume=10, + aspirate_tag_speed=5, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(200.01, 1000.01, Liquid.DMSO, TipType.DITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1.1, + calibration_factor=1.004, + calibration_offset=3.9, + aspirate_speed=150, + aspirate_delay=400, + aspirate_stag_volume=20, + aspirate_stag_speed=5, + aspirate_lag_volume=20, + aspirate_lag_speed=50, + aspirate_tag_volume=10, + aspirate_tag_speed=5, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(0.5, 3.01, Liquid.DMSO, TipType.STDLOWVOL)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1.1, + calibration_factor=1.311, + calibration_offset=0, + aspirate_speed=10, + aspirate_delay=200, + aspirate_stag_volume=5, + aspirate_stag_speed=5, + aspirate_lag_volume=0, + aspirate_lag_speed=50, + aspirate_tag_volume=0.25, + aspirate_tag_speed=5, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=1, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=10, + dispense_breakoff=10, + dispense_delay=400, + dispense_tag=False, + dispense_pinch_valve=True, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(3.01, 15.01, Liquid.DMSO, TipType.STDLOWVOL)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1.1, + calibration_factor=1.215, + calibration_offset=1.5, + aspirate_speed=10, + aspirate_delay=400, + aspirate_stag_volume=2, + aspirate_stag_speed=5, + aspirate_lag_volume=7, + aspirate_lag_speed=50, + aspirate_tag_volume=5, + aspirate_tag_speed=5, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=1, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=110, + dispense_breakoff=110, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(15.01, 300.01, Liquid.DMSO, TipType.STDLOWVOL)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1.1, + calibration_factor=1.122, + calibration_offset=2.4, + aspirate_speed=45, + aspirate_delay=500, + aspirate_stag_volume=10, + aspirate_stag_speed=5, + aspirate_lag_volume=0, + aspirate_lag_speed=50, + aspirate_tag_volume=5, + aspirate_tag_speed=5, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=1, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=110, + dispense_breakoff=110, + dispense_delay=100, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(1, 7.51, Liquid.DMSO, TipType.MCADITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1.1, + calibration_factor=1.35, + calibration_offset=0, + aspirate_speed=5, + aspirate_delay=200, + aspirate_stag_volume=0, + aspirate_stag_speed=5, + aspirate_lag_volume=7, + aspirate_lag_speed=50, + aspirate_tag_volume=2, + aspirate_tag_speed=5, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=False, + aspirate_lld_position=0, + aspirate_lld_offset=0, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=2, + aspirate_retract_speed=5, + aspirate_retract_offset=0, + dispense_speed=500, + dispense_breakoff=150, + dispense_delay=200, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=3, + dispense_retract_speed=42, + dispense_retract_offset=0, +) + + +mapping[(7.51, 20.01, Liquid.DMSO, TipType.MCADITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1.1, + calibration_factor=1.04, + calibration_offset=0, + aspirate_speed=10, + aspirate_delay=200, + aspirate_stag_volume=0, + aspirate_stag_speed=5, + aspirate_lag_volume=7, + aspirate_lag_speed=50, + aspirate_tag_volume=3, + aspirate_tag_speed=5, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=False, + aspirate_lld_position=0, + aspirate_lld_offset=0, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=2, + aspirate_retract_speed=5, + aspirate_retract_offset=0, + dispense_speed=500, + dispense_breakoff=150, + dispense_delay=200, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=3, + dispense_retract_speed=42, + dispense_retract_offset=0, +) + + +mapping[(20.01, 200.01, Liquid.DMSO, TipType.MCADITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1.1, + calibration_factor=1.04, + calibration_offset=0, + aspirate_speed=10, + aspirate_delay=200, + aspirate_stag_volume=0, + aspirate_stag_speed=5, + aspirate_lag_volume=10, + aspirate_lag_speed=50, + aspirate_tag_volume=3, + aspirate_tag_speed=5, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=False, + aspirate_lld_position=0, + aspirate_lld_offset=0, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=2, + aspirate_retract_speed=5, + aspirate_retract_offset=0, + dispense_speed=400, + dispense_breakoff=300, + dispense_delay=200, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=3, + dispense_retract_speed=42, + dispense_retract_offset=0, +) + + +mapping[(3, 15.01, Liquid.DMSO, TipType.AIRDITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1.1, + calibration_factor=1.08, + calibration_offset=-0.086, + aspirate_speed=20, + aspirate_delay=200, + aspirate_stag_volume=0, + aspirate_stag_speed=50, + aspirate_lag_volume=10, + aspirate_lag_speed=70, + aspirate_tag_volume=5, + aspirate_tag_speed=50, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=1, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(15.01, 200.01, Liquid.DMSO, TipType.AIRDITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1.1, + calibration_factor=1.024, + calibration_offset=0.866, + aspirate_speed=100, + aspirate_delay=200, + aspirate_stag_volume=0, + aspirate_stag_speed=70, + aspirate_lag_volume=10, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=1, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(200.01, 1000.01, Liquid.DMSO, TipType.AIRDITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1.1, + calibration_factor=1.028, + calibration_offset=-0.258, + aspirate_speed=150, + aspirate_delay=400, + aspirate_stag_volume=0, + aspirate_stag_speed=70, + aspirate_lag_volume=10, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=True, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(3, 1000.01, Liquid.ETHANOL, TipType.STANDARD)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=0.8, + calibration_factor=1.063, + calibration_offset=3.4, + aspirate_speed=100, + aspirate_delay=200, + aspirate_stag_volume=20, + aspirate_stag_speed=70, + aspirate_lag_volume=0, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=150, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(3, 1000.01, Liquid.ETHANOL, TipType.DITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=0.8, + calibration_factor=1.09, + calibration_offset=4.3, + aspirate_speed=100, + aspirate_delay=200, + aspirate_stag_volume=20, + aspirate_stag_speed=70, + aspirate_lag_volume=20, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(0.5, 3.01, Liquid.ETHANOL, TipType.STDLOWVOL)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=0.8, + calibration_factor=1.279, + calibration_offset=0.2, + aspirate_speed=10, + aspirate_delay=200, + aspirate_stag_volume=5, + aspirate_stag_speed=10, + aspirate_lag_volume=0, + aspirate_lag_speed=10, + aspirate_tag_volume=0.25, + aspirate_tag_speed=10, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=1, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=10, + aspirate_retract_offset=-5, + dispense_speed=10, + dispense_breakoff=10, + dispense_delay=400, + dispense_tag=False, + dispense_pinch_valve=True, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(3.01, 15.01, Liquid.ETHANOL, TipType.STDLOWVOL)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=0.8, + calibration_factor=1.199, + calibration_offset=1.3, + aspirate_speed=10, + aspirate_delay=400, + aspirate_stag_volume=2, + aspirate_stag_speed=10, + aspirate_lag_volume=7, + aspirate_lag_speed=10, + aspirate_tag_volume=5, + aspirate_tag_speed=10, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=1, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=110, + dispense_breakoff=110, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(15.01, 300.01, Liquid.ETHANOL, TipType.STDLOWVOL)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=0.8, + calibration_factor=1.079, + calibration_offset=2.8, + aspirate_speed=45, + aspirate_delay=500, + aspirate_stag_volume=10, + aspirate_stag_speed=10, + aspirate_lag_volume=0, + aspirate_lag_speed=10, + aspirate_tag_volume=5, + aspirate_tag_speed=10, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=1, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=110, + dispense_breakoff=110, + dispense_delay=100, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(3, 5, Liquid.ETHANOL, TipType.AIRDITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=0.8, + calibration_factor=1.072, + calibration_offset=0.22, + aspirate_speed=20, + aspirate_delay=400, + aspirate_stag_volume=0, + aspirate_stag_speed=50, + aspirate_lag_volume=5, + aspirate_lag_speed=70, + aspirate_tag_volume=5, + aspirate_tag_speed=50, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=True, + aspirate_mix_volume=200, + aspirate_mix_cycles=2, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=400, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(5, 15.01, Liquid.ETHANOL, TipType.AIRDITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=0.8, + calibration_factor=1.072, + calibration_offset=0.42, + aspirate_speed=20, + aspirate_delay=400, + aspirate_stag_volume=0, + aspirate_stag_speed=50, + aspirate_lag_volume=5, + aspirate_lag_speed=70, + aspirate_tag_volume=5, + aspirate_tag_speed=50, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=True, + aspirate_mix_volume=200, + aspirate_mix_cycles=2, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=400, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(15.01, 200.01, Liquid.ETHANOL, TipType.AIRDITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=0.8, + calibration_factor=1.011, + calibration_offset=1.802, + aspirate_speed=150, + aspirate_delay=400, + aspirate_stag_volume=0, + aspirate_stag_speed=70, + aspirate_lag_volume=5, + aspirate_lag_speed=70, + aspirate_tag_volume=5, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=True, + aspirate_mix_volume=200, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=400, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(200.01, 1000.01, Liquid.ETHANOL, TipType.AIRDITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=2, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=0.8, + calibration_factor=1, + calibration_offset=0, + aspirate_speed=150, + aspirate_delay=400, + aspirate_stag_volume=0, + aspirate_stag_speed=70, + aspirate_lag_volume=10, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=True, + aspirate_mix_volume=1000, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=400, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(3, 15.01, Liquid.SERUM, TipType.STANDARD)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=2.1, + pmp_character=0, + density=1, + calibration_factor=1.074, + calibration_offset=0.3, + aspirate_speed=20, + aspirate_delay=200, + aspirate_stag_volume=10, + aspirate_stag_speed=20, + aspirate_lag_volume=20, + aspirate_lag_speed=20, + aspirate_tag_volume=10, + aspirate_tag_speed=20, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=150, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(15.01, 300.01, Liquid.SERUM, TipType.STANDARD)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=2.1, + pmp_character=0, + density=1, + calibration_factor=1.06, + calibration_offset=0.43, + aspirate_speed=100, + aspirate_delay=200, + aspirate_stag_volume=20, + aspirate_stag_speed=70, + aspirate_lag_volume=0, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=150, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(300.01, 1000.01, Liquid.SERUM, TipType.STANDARD)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=2.1, + pmp_character=0, + density=1, + calibration_factor=1.007, + calibration_offset=16.63, + aspirate_speed=100, + aspirate_delay=200, + aspirate_stag_volume=20, + aspirate_stag_speed=70, + aspirate_lag_volume=0, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=150, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(3, 15.01, Liquid.SERUM, TipType.DITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=2.1, + pmp_character=0, + density=1, + calibration_factor=1.138, + calibration_offset=1, + aspirate_speed=20, + aspirate_delay=200, + aspirate_stag_volume=20, + aspirate_stag_speed=20, + aspirate_lag_volume=5, + aspirate_lag_speed=20, + aspirate_tag_volume=10, + aspirate_tag_speed=20, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(15.01, 200.01, Liquid.SERUM, TipType.DITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=2.1, + pmp_character=0, + density=1, + calibration_factor=1.059, + calibration_offset=2.09, + aspirate_speed=100, + aspirate_delay=200, + aspirate_stag_volume=20, + aspirate_stag_speed=70, + aspirate_lag_volume=20, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(200.01, 1000.01, Liquid.SERUM, TipType.DITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=2.1, + pmp_character=0, + density=1, + calibration_factor=1.046, + calibration_offset=8.55, + aspirate_speed=100, + aspirate_delay=400, + aspirate_stag_volume=20, + aspirate_stag_speed=70, + aspirate_lag_volume=20, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(3, 15.01, Liquid.SERUM, TipType.AIRDITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=2.1, + pmp_character=0, + density=1, + calibration_factor=1.115, + calibration_offset=1.146, + aspirate_speed=50, + aspirate_delay=600, + aspirate_stag_volume=0, + aspirate_stag_speed=50, + aspirate_lag_volume=15, + aspirate_lag_speed=70, + aspirate_tag_volume=5, + aspirate_tag_speed=50, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=1, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-10, + dispense_speed=400, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=True, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(15.01, 200.01, Liquid.SERUM, TipType.AIRDITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=2.1, + pmp_character=0, + density=1, + calibration_factor=1.043, + calibration_offset=2.671, + aspirate_speed=100, + aspirate_delay=600, + aspirate_stag_volume=0, + aspirate_stag_speed=70, + aspirate_lag_volume=20, + aspirate_lag_speed=70, + aspirate_tag_volume=6, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=1, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-10, + dispense_speed=400, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=True, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(200.01, 1000.01, Liquid.SERUM, TipType.AIRDITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=2.1, + pmp_character=0, + density=1, + calibration_factor=1.021, + calibration_offset=10.966, + aspirate_speed=100, + aspirate_delay=600, + aspirate_stag_volume=0, + aspirate_stag_speed=70, + aspirate_lag_volume=20, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=400, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=True, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(3, 15.01, Liquid.WATER, TipType.STANDARD)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1.045, + calibration_offset=0.2, + aspirate_speed=20, + aspirate_delay=200, + aspirate_stag_volume=10, + aspirate_stag_speed=20, + aspirate_lag_volume=10, + aspirate_lag_speed=20, + aspirate_tag_volume=5, + aspirate_tag_speed=20, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=150, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(15.01, 500.01, Liquid.WATER, TipType.STANDARD)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1.042, + calibration_offset=-0.04, + aspirate_speed=150, + aspirate_delay=200, + aspirate_stag_volume=20, + aspirate_stag_speed=70, + aspirate_lag_volume=0, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=150, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(500.01, 1000.01, Liquid.WATER, TipType.STANDARD)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1, + calibration_offset=20.26, + aspirate_speed=150, + aspirate_delay=200, + aspirate_stag_volume=20, + aspirate_stag_speed=70, + aspirate_lag_volume=0, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=150, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(3, 15.01, Liquid.WATER, TipType.DITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1.042, + calibration_offset=1.1, + aspirate_speed=20, + aspirate_delay=200, + aspirate_stag_volume=20, + aspirate_stag_speed=20, + aspirate_lag_volume=5, + aspirate_lag_speed=20, + aspirate_tag_volume=10, + aspirate_tag_speed=20, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(15.01, 200.01, Liquid.WATER, TipType.DITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1.034, + calibration_offset=0.9, + aspirate_speed=100, + aspirate_delay=200, + aspirate_stag_volume=20, + aspirate_stag_speed=70, + aspirate_lag_volume=5, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(200.01, 1000.01, Liquid.WATER, TipType.DITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1.031, + calibration_offset=6.12, + aspirate_speed=150, + aspirate_delay=300, + aspirate_stag_volume=20, + aspirate_stag_speed=70, + aspirate_lag_volume=10, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(0.5, 3.01, Liquid.WATER, TipType.STDLOWVOL)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1.1, + calibration_offset=0.15, + aspirate_speed=10, + aspirate_delay=200, + aspirate_stag_volume=5, + aspirate_stag_speed=10, + aspirate_lag_volume=0, + aspirate_lag_speed=10, + aspirate_tag_volume=0.25, + aspirate_tag_speed=10, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=1, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=10, + dispense_breakoff=10, + dispense_delay=400, + dispense_tag=False, + dispense_pinch_valve=True, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(3.01, 15.01, Liquid.WATER, TipType.STDLOWVOL)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1.045, + calibration_offset=0.2, + aspirate_speed=10, + aspirate_delay=400, + aspirate_stag_volume=2, + aspirate_stag_speed=10, + aspirate_lag_volume=7, + aspirate_lag_speed=10, + aspirate_tag_volume=5, + aspirate_tag_speed=10, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=1, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=110, + dispense_breakoff=110, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(15.01, 300.01, Liquid.WATER, TipType.STDLOWVOL)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1.06, + calibration_offset=0.5, + aspirate_speed=45, + aspirate_delay=500, + aspirate_stag_volume=10, + aspirate_stag_speed=10, + aspirate_lag_volume=0, + aspirate_lag_speed=10, + aspirate_tag_volume=5, + aspirate_tag_speed=10, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=1, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=110, + dispense_breakoff=110, + dispense_delay=100, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(1, 3.01, Liquid.WATER, TipType.DITILOWVOL)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1, + calibration_offset=0.25, + aspirate_speed=10, + aspirate_delay=500, + aspirate_stag_volume=5, + aspirate_stag_speed=10, + aspirate_lag_volume=0, + aspirate_lag_speed=10, + aspirate_tag_volume=0.25, + aspirate_tag_speed=10, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=1, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=10, + dispense_breakoff=10, + dispense_delay=100, + dispense_tag=False, + dispense_pinch_valve=True, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(3.01, 15.01, Liquid.WATER, TipType.DITILOWVOL)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1.03, + calibration_offset=0.25, + aspirate_speed=10, + aspirate_delay=500, + aspirate_stag_volume=10, + aspirate_stag_speed=10, + aspirate_lag_volume=10, + aspirate_lag_speed=10, + aspirate_tag_volume=3, + aspirate_tag_speed=10, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=1, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=240, + dispense_breakoff=110, + dispense_delay=200, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(1, 7.51, Liquid.WATER, TipType.MCADITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1, + calibration_offset=0.45, + aspirate_speed=5, + aspirate_delay=200, + aspirate_stag_volume=0, + aspirate_stag_speed=5, + aspirate_lag_volume=20, + aspirate_lag_speed=50, + aspirate_tag_volume=3, + aspirate_tag_speed=5, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=False, + aspirate_lld_position=0, + aspirate_lld_offset=0, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=2, + aspirate_retract_speed=5, + aspirate_retract_offset=0, + dispense_speed=500, + dispense_breakoff=150, + dispense_delay=200, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=3, + dispense_retract_speed=42, + dispense_retract_offset=0, +) + + +mapping[(7.51, 20.01, Liquid.WATER, TipType.MCADITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1, + calibration_offset=0.45, + aspirate_speed=10, + aspirate_delay=200, + aspirate_stag_volume=0, + aspirate_stag_speed=5, + aspirate_lag_volume=10, + aspirate_lag_speed=50, + aspirate_tag_volume=5, + aspirate_tag_speed=5, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=False, + aspirate_lld_position=0, + aspirate_lld_offset=0, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=2, + aspirate_retract_speed=5, + aspirate_retract_offset=0, + dispense_speed=500, + dispense_breakoff=150, + dispense_delay=200, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=3, + dispense_retract_speed=42, + dispense_retract_offset=0, +) + + +mapping[(20.01, 200.01, Liquid.WATER, TipType.MCADITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1.05, + calibration_offset=0.4, + aspirate_speed=50, + aspirate_delay=500, + aspirate_stag_volume=0, + aspirate_stag_speed=5, + aspirate_lag_volume=20, + aspirate_lag_speed=50, + aspirate_tag_volume=10, + aspirate_tag_speed=5, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=False, + aspirate_lld_position=0, + aspirate_lld_offset=0, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=2, + aspirate_retract_speed=5, + aspirate_retract_offset=0, + dispense_speed=400, + dispense_breakoff=300, + dispense_delay=200, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=3, + dispense_retract_speed=42, + dispense_retract_offset=0, +) + + +mapping[(3, 15.01, Liquid.WATER, TipType.AIRDITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1.05, + calibration_offset=1.5, + aspirate_speed=20, + aspirate_delay=400, + aspirate_stag_volume=0, + aspirate_stag_speed=20, + aspirate_lag_volume=10, + aspirate_lag_speed=70, + aspirate_tag_volume=5, + aspirate_tag_speed=20, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(15.01, 200.01, Liquid.WATER, TipType.AIRDITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1.03, + calibration_offset=1.5, + aspirate_speed=100, + aspirate_delay=400, + aspirate_stag_volume=0, + aspirate_stag_speed=70, + aspirate_lag_volume=5, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) + + +mapping[(200.01, 1000.01, Liquid.WATER, TipType.AIRDITI)] = TecanLiquidClass( + lld_mode=7, + lld_conductivity=1, + lld_speed=60, + lld_distance=4, + clot_speed=50, + clot_limit=4, + pmp_sensitivity=1, + pmp_viscosity=1, + pmp_character=0, + density=1, + calibration_factor=1.025, + calibration_offset=4, + aspirate_speed=150, + aspirate_delay=400, + aspirate_stag_volume=0, + aspirate_stag_speed=70, + aspirate_lag_volume=10, + aspirate_lag_speed=70, + aspirate_tag_volume=10, + aspirate_tag_speed=70, + aspirate_excess=0, + aspirate_conditioning=0, + aspirate_pinch_valve=False, + aspirate_lld=True, + aspirate_lld_position=3, + aspirate_lld_offset=2, + aspirate_mix=False, + aspirate_mix_volume=100, + aspirate_mix_cycles=1, + aspirate_retract_position=4, + aspirate_retract_speed=20, + aspirate_retract_offset=-5, + dispense_speed=600, + dispense_breakoff=400, + dispense_delay=0, + dispense_tag=False, + dispense_pinch_valve=False, + dispense_lld=False, + dispense_lld_position=0, + dispense_lld_offset=0, + dispense_touching_direction=0, + dispense_touching_speed=10, + dispense_touching_delay=100, + dispense_mix=False, + dispense_mix_volume=100, + dispense_mix_cycles=1, + dispense_retract_position=1, + dispense_retract_speed=50, + dispense_retract_offset=0, +) diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/legacy/liquid_handling/liquid_handler.py similarity index 99% rename from pylabrobot/liquid_handling/liquid_handler.py rename to pylabrobot/legacy/liquid_handling/liquid_handler.py index 869695be714..a10242c5c95 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/legacy/liquid_handling/liquid_handler.py @@ -24,16 +24,17 @@ cast, ) -from pylabrobot.liquid_handling.channel_positioning import ( +from pylabrobot.legacy.liquid_handling.channel_positioning import ( compute_channel_offsets, ) -from pylabrobot.liquid_handling.errors import ChannelizedError -from pylabrobot.liquid_handling.strictness import ( +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError +from pylabrobot.legacy.liquid_handling.strictness import ( Strictness, get_strictness, ) -from pylabrobot.machines.machine import Machine, need_setup_finished -from pylabrobot.plate_reading import PlateReader +from pylabrobot.legacy.machines.machine import Machine, need_setup_finished +from pylabrobot.legacy.plate_reading import PlateReader +from pylabrobot.legacy.tilting.tilter import Tilter from pylabrobot.resources import ( Container, Coordinate, @@ -58,7 +59,6 @@ from pylabrobot.resources.errors import HasTipError from pylabrobot.resources.rotation import Rotation from pylabrobot.serializer import deserialize, serialize -from pylabrobot.tilting.tilter import Tilter from .backends import LiquidHandlerBackend from .standard import ( diff --git a/pylabrobot/liquid_handling/liquid_handler_tests.py b/pylabrobot/legacy/liquid_handling/liquid_handler_tests.py similarity index 99% rename from pylabrobot/liquid_handling/liquid_handler_tests.py rename to pylabrobot/legacy/liquid_handling/liquid_handler_tests.py index ed88934d186..1973933b84f 100644 --- a/pylabrobot/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/legacy/liquid_handling/liquid_handler_tests.py @@ -7,13 +7,13 @@ import pytest -from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend -from pylabrobot.liquid_handling.backends.chatterbox import LiquidHandlerChatterboxBackend -from pylabrobot.liquid_handling.channel_positioning import ( +from pylabrobot.legacy.liquid_handling.backends.backend import LiquidHandlerBackend +from pylabrobot.legacy.liquid_handling.backends.chatterbox import LiquidHandlerChatterboxBackend +from pylabrobot.legacy.liquid_handling.channel_positioning import ( get_tight_single_resource_liquid_op_offsets, ) -from pylabrobot.liquid_handling.errors import ChannelizedError -from pylabrobot.liquid_handling.strictness import ( +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError +from pylabrobot.legacy.liquid_handling.strictness import ( Strictness, set_strictness, ) @@ -970,7 +970,7 @@ async def test_aspirate_from_petri_dish_with_lid(self): async def test_lidded_ancestor_walks_full_chain(self): # A lid on any ancestor blocks pipetting, not just the direct parent: nest a target two levels # under a lidded container and check the walk finds it. - from pylabrobot.liquid_handling.liquid_handler import _lidded_ancestor + from pylabrobot.legacy.liquid_handling.liquid_handler import _lidded_ancestor trough = hamilton_1_trough_200mL_Vb(name="trough") trough.lid = Lid( diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling.py b/pylabrobot/legacy/liquid_handling/pipette_batch_scheduling.py similarity index 99% rename from pylabrobot/liquid_handling/pipette_batch_scheduling.py rename to pylabrobot/legacy/liquid_handling/pipette_batch_scheduling.py index a6f35e576b6..7395908f9c9 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling.py +++ b/pylabrobot/legacy/liquid_handling/pipette_batch_scheduling.py @@ -31,7 +31,7 @@ from dataclasses import dataclass, field from typing import Collection, Dict, FrozenSet, List, Optional, Tuple -from pylabrobot.liquid_handling.channel_positioning import ( +from pylabrobot.legacy.liquid_handling.channel_positioning import ( compute_nonconsecutive_channel_offsets, ) from pylabrobot.resources.container import Container diff --git a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py b/pylabrobot/legacy/liquid_handling/pipette_batch_scheduling_tests.py similarity index 96% rename from pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py rename to pylabrobot/legacy/liquid_handling/pipette_batch_scheduling_tests.py index 0ef97cc23d9..fe36c5e3817 100644 --- a/pylabrobot/liquid_handling/pipette_batch_scheduling_tests.py +++ b/pylabrobot/legacy/liquid_handling/pipette_batch_scheduling_tests.py @@ -4,7 +4,7 @@ from typing import Iterable, List from unittest.mock import MagicMock, patch -from pylabrobot.liquid_handling.pipette_batch_scheduling import ( +from pylabrobot.legacy.liquid_handling.pipette_batch_scheduling import ( ChannelBatch, _span_required, enumerate_valid_batches, @@ -118,7 +118,7 @@ def test_pairwise_sum_not_uniform_product(self): self.assertIsNone(is_valid_batch([0, 1], [0, 3], cs, sp, DECK, x_tolerance=0.1)) @patch( - "pylabrobot.liquid_handling.pipette_batch_scheduling.compute_nonconsecutive_channel_offsets" + "pylabrobot.legacy.liquid_handling.pipette_batch_scheduling.compute_nonconsecutive_channel_offsets" ) def test_container_fit_failure_rejects_batch(self, mock_offsets): mock_offsets.return_value = None @@ -126,7 +126,7 @@ def test_container_fit_failure_rejects_batch(self, mock_offsets): self.assertIsNone(is_valid_batch([0, 1], [0, 1], [c, c], self.S, DECK, x_tolerance=0.1)) @patch( - "pylabrobot.liquid_handling.pipette_batch_scheduling.compute_nonconsecutive_channel_offsets" + "pylabrobot.legacy.liquid_handling.pipette_batch_scheduling.compute_nonconsecutive_channel_offsets" ) def test_container_fit_sets_spread_positions(self, mock_offsets): mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] @@ -349,7 +349,7 @@ def test_mixed_phantoms_use_pairwise_spacing(self): self.assertAlmostEqual(y[2], 245.0 - 9.0 - 18.0) @patch( - "pylabrobot.liquid_handling.pipette_batch_scheduling.compute_nonconsecutive_channel_offsets" + "pylabrobot.legacy.liquid_handling.pipette_batch_scheduling.compute_nonconsecutive_channel_offsets" ) def test_auto_spread_same_container(self, mock_offsets): mock_offsets.return_value = [Coordinate(0, 4.5, 0), Coordinate(0, -4.5, 0)] @@ -374,7 +374,7 @@ class TestPlanBatchesNoGoZones(unittest.TestCase): S = [9.0] * 8 @patch( - "pylabrobot.liquid_handling.pipette_batch_scheduling.compute_nonconsecutive_channel_offsets" + "pylabrobot.legacy.liquid_handling.pipette_batch_scheduling.compute_nonconsecutive_channel_offsets" ) def test_fits_pair_but_not_triple_in_container(self, mock_offsets): # Container fits any adjacent pair but not three channels at once. diff --git a/pylabrobot/legacy/liquid_handling/standard.py b/pylabrobot/legacy/liquid_handling/standard.py new file mode 100644 index 00000000000..fdc057b7c94 --- /dev/null +++ b/pylabrobot/legacy/liquid_handling/standard.py @@ -0,0 +1,180 @@ +"""Data structures for the standard form of liquid handling.""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Optional, Sequence, Union + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.rotation import Rotation + +if TYPE_CHECKING: + from pylabrobot.resources import ( + Container, + Resource, + TipRack, + Trash, + Well, + ) + from pylabrobot.resources.tip import Tip + from pylabrobot.resources.tip_rack import TipSpot + + +@dataclass(frozen=True) +class Pickup: + resource: TipSpot + offset: Coordinate + tip: Tip # TODO: perhaps we can remove this, because the tip spot has the tip? + + +@dataclass(frozen=True) +class Drop: + resource: Resource + offset: Coordinate + tip: Tip + + +@dataclass(frozen=True) +class PickupTipRack: + resource: TipRack + offset: Coordinate + tips: Sequence[Optional[Tip]] + + +@dataclass(frozen=True) +class DropTipRack: + resource: Union[TipRack, Trash] + offset: Coordinate + + +@dataclass(frozen=True) +class SingleChannelAspiration: + resource: Container + offset: Coordinate + tip: Tip + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class SingleChannelDispense: + resource: Container + offset: Coordinate + tip: Tip + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class Mix: + """Repeated aspirate/dispense cycles to mix the liquid during a transfer. + + Args: + volume: The volume drawn then expelled each cycle. + repetitions: The number of aspirate/dispense cycles. + flow_rate: The flow rate of the mix. + surface_following_distance: The distance (mm) the tip follows the liquid surface each cycle on + backends that support it (e.g. Hamilton STAR); others ignore it. + """ + + volume: float + repetitions: int + flow_rate: float + surface_following_distance: Optional[float] = None + + +@dataclass(frozen=True) +class MultiHeadAspirationPlate: + wells: List[Well] + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class MultiHeadDispensePlate: + wells: List[Well] + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class MultiHeadAspirationContainer: + container: Container + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class MultiHeadDispenseContainer: + container: Container + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +class GripDirection(enum.Enum): + FRONT = enum.auto() + BACK = enum.auto() + LEFT = enum.auto() + RIGHT = enum.auto() + + +@dataclass(frozen=True) +class ResourcePickup: + resource: Resource + offset: Coordinate + pickup_distance_from_top: float + direction: GripDirection + + +@dataclass(frozen=True) +class ResourceMove: + """Moving a resource that was already picked up.""" + + resource: Resource + location: Coordinate + gripped_direction: GripDirection + pickup_distance_from_top: float + offset: Coordinate + + +@dataclass(frozen=True) +class ResourceDrop: + resource: Resource + # Destination is the location of the lfb of `resource` + destination: Coordinate + destination_absolute_rotation: Rotation + offset: Coordinate + pickup_distance_from_top: float + pickup_direction: GripDirection + direction: GripDirection + rotation: float + + +PipettingOp = Union[Pickup, Drop, SingleChannelAspiration, SingleChannelDispense] diff --git a/pylabrobot/liquid_handling/strictness.py b/pylabrobot/legacy/liquid_handling/strictness.py similarity index 100% rename from pylabrobot/liquid_handling/strictness.py rename to pylabrobot/legacy/liquid_handling/strictness.py diff --git a/pylabrobot/legacy/machines/__init__.py b/pylabrobot/legacy/machines/__init__.py new file mode 100644 index 00000000000..cbfbf4e4c50 --- /dev/null +++ b/pylabrobot/legacy/machines/__init__.py @@ -0,0 +1 @@ +from .machine import Machine, need_setup_finished diff --git a/pylabrobot/machines/backend.py b/pylabrobot/legacy/machines/backend.py similarity index 100% rename from pylabrobot/machines/backend.py rename to pylabrobot/legacy/machines/backend.py diff --git a/pylabrobot/machines/machine.py b/pylabrobot/legacy/machines/machine.py similarity index 96% rename from pylabrobot/machines/machine.py rename to pylabrobot/legacy/machines/machine.py index d241d8809a1..16731d30b74 100644 --- a/pylabrobot/machines/machine.py +++ b/pylabrobot/legacy/machines/machine.py @@ -5,7 +5,7 @@ from abc import ABC from typing import Any, Awaitable, Callable, TypeVar -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend from pylabrobot.serializer import SerializableMixin if sys.version_info < (3, 10): diff --git a/pylabrobot/machines/machine_tests.py b/pylabrobot/legacy/machines/machine_tests.py similarity index 91% rename from pylabrobot/machines/machine_tests.py rename to pylabrobot/legacy/machines/machine_tests.py index bdfbb2d506e..e84e9ceccde 100644 --- a/pylabrobot/machines/machine_tests.py +++ b/pylabrobot/legacy/machines/machine_tests.py @@ -1,7 +1,7 @@ import unittest import unittest.mock -from pylabrobot.machines.machine import Machine, MachineBackend +from pylabrobot.legacy.machines.machine import Machine, MachineBackend class TestMachine(unittest.TestCase): diff --git a/pylabrobot/microscopes/molecular_devices/pico/backend.py b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py similarity index 99% rename from pylabrobot/microscopes/molecular_devices/pico/backend.py rename to pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py index 9b5cfacdc5b..5d526497572 100644 --- a/pylabrobot/microscopes/molecular_devices/pico/backend.py +++ b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py @@ -24,8 +24,8 @@ unlock_server_params, varint_as_signed, ) -from pylabrobot.plate_reading.backend import ImagerBackend -from pylabrobot.plate_reading.standard import ( +from pylabrobot.legacy.plate_reading.backend import ImagerBackend +from pylabrobot.legacy.plate_reading.standard import ( Exposure, FocalPosition, Gain, diff --git a/pylabrobot/microscopes/molecular_devices/pico/backend_tests.py b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py similarity index 99% rename from pylabrobot/microscopes/molecular_devices/pico/backend_tests.py rename to pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py index 8e8c123ea65..91746fb3c59 100644 --- a/pylabrobot/microscopes/molecular_devices/pico/backend_tests.py +++ b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py @@ -28,7 +28,7 @@ sila_string, varint_field, ) -from pylabrobot.microscopes.molecular_devices.pico.backend import ( +from pylabrobot.legacy.microscopes.molecular_devices.pico.backend import ( _FC_SVC, _HW_SVC, _INST_SVC, @@ -41,7 +41,7 @@ _extract_image_buffer, _get_image_info, ) -from pylabrobot.plate_reading.standard import ImagingMode, Objective +from pylabrobot.legacy.plate_reading.standard import ImagingMode, Objective from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well, WellBottomType diff --git a/pylabrobot/legacy/only_fans/__init__.py b/pylabrobot/legacy/only_fans/__init__.py new file mode 100644 index 00000000000..acc732faeba --- /dev/null +++ b/pylabrobot/legacy/only_fans/__init__.py @@ -0,0 +1,3 @@ +from .backend import FanBackend +from .fan import Fan +from .hamilton_hepa_fan_backend import HamiltonHepaFanBackend diff --git a/pylabrobot/only_fans/backend.py b/pylabrobot/legacy/only_fans/backend.py similarity index 91% rename from pylabrobot/only_fans/backend.py rename to pylabrobot/legacy/only_fans/backend.py index 7be5c212785..4701086d14a 100644 --- a/pylabrobot/only_fans/backend.py +++ b/pylabrobot/legacy/only_fans/backend.py @@ -1,6 +1,6 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class FanBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/only_fans/chatterbox.py b/pylabrobot/legacy/only_fans/chatterbox.py similarity index 89% rename from pylabrobot/only_fans/chatterbox.py rename to pylabrobot/legacy/only_fans/chatterbox.py index 25104bb4822..a56f9b494ed 100644 --- a/pylabrobot/only_fans/chatterbox.py +++ b/pylabrobot/legacy/only_fans/chatterbox.py @@ -1,4 +1,4 @@ -from pylabrobot.only_fans import FanBackend +from pylabrobot.legacy.only_fans import FanBackend class FanChatterboxBackend(FanBackend): diff --git a/pylabrobot/only_fans/fan.py b/pylabrobot/legacy/only_fans/fan.py similarity index 93% rename from pylabrobot/only_fans/fan.py rename to pylabrobot/legacy/only_fans/fan.py index c34e784a9ee..5a6087804d0 100644 --- a/pylabrobot/only_fans/fan.py +++ b/pylabrobot/legacy/only_fans/fan.py @@ -1,6 +1,6 @@ import asyncio -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.machines.machine import Machine from .backend import FanBackend diff --git a/pylabrobot/only_fans/hamilton_hepa_fan_backend.py b/pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py similarity index 100% rename from pylabrobot/only_fans/hamilton_hepa_fan_backend.py rename to pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py diff --git a/pylabrobot/legacy/peeling/__init__.py b/pylabrobot/legacy/peeling/__init__.py new file mode 100644 index 00000000000..48292ba9623 --- /dev/null +++ b/pylabrobot/legacy/peeling/__init__.py @@ -0,0 +1,2 @@ +from .xpeel import xpeel +from .xpeel_backend import XPeelBackend diff --git a/pylabrobot/peeling/backend.py b/pylabrobot/legacy/peeling/backend.py similarity index 83% rename from pylabrobot/peeling/backend.py rename to pylabrobot/legacy/peeling/backend.py index 6d375205e44..fb35f345b6d 100644 --- a/pylabrobot/peeling/backend.py +++ b/pylabrobot/legacy/peeling/backend.py @@ -1,6 +1,6 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class PeelerBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/peeling/peeler.py b/pylabrobot/legacy/peeling/peeler.py similarity index 89% rename from pylabrobot/peeling/peeler.py rename to pylabrobot/legacy/peeling/peeler.py index 4d7fba43b44..a3bdaca1369 100644 --- a/pylabrobot/peeling/peeler.py +++ b/pylabrobot/legacy/peeling/peeler.py @@ -1,4 +1,4 @@ -from pylabrobot.machines import Machine +from pylabrobot.legacy.machines import Machine from .backend import PeelerBackend diff --git a/pylabrobot/legacy/peeling/xpeel.py b/pylabrobot/legacy/peeling/xpeel.py new file mode 100644 index 00000000000..da0c7f1402b --- /dev/null +++ b/pylabrobot/legacy/peeling/xpeel.py @@ -0,0 +1,8 @@ +from pylabrobot.legacy.peeling.peeler import Peeler +from pylabrobot.legacy.peeling.xpeel_backend import XPeelBackend + + +def xpeel(port: str) -> Peeler: + return Peeler( + backend=XPeelBackend(port=port), + ) diff --git a/pylabrobot/peeling/xpeel_backend.py b/pylabrobot/legacy/peeling/xpeel_backend.py similarity index 99% rename from pylabrobot/peeling/xpeel_backend.py rename to pylabrobot/legacy/peeling/xpeel_backend.py index 8ea1b4a6983..934f6b0ebb6 100644 --- a/pylabrobot/peeling/xpeel_backend.py +++ b/pylabrobot/legacy/peeling/xpeel_backend.py @@ -12,7 +12,7 @@ _SERIAL_IMPORT_ERROR = e from pylabrobot.io.serial import Serial -from pylabrobot.peeling.backend import PeelerBackend +from pylabrobot.legacy.peeling.backend import PeelerBackend class XPeelBackend(PeelerBackend): diff --git a/pylabrobot/legacy/plate_reading/__init__.py b/pylabrobot/legacy/plate_reading/__init__.py new file mode 100644 index 00000000000..187d794b3c1 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/__init__.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Any + +from .agilent import ( + BioTekPlateReaderBackend, + Cytation5Backend, + Cytation5ImagingConfig, + CytationBackend, + CytationImagingConfig, + SynergyH1Backend, +) +from .bmg_labtech import CLARIOstarBackend +from .byonoy import ( + ByonoyAbsorbance96AutomateBackend, + ByonoyLuminescence96AutomateBackend, +) +from .chatterbox import PlateReaderChatterboxBackend +from .image_reader import ImageReader +from .imager import Imager +from .molecular_devices import ( + Calibrate, + CarriageSpeed, + KineticSettings, + MolecularDevicesBackend, + MolecularDevicesError, + MolecularDevicesFirmwareError, + MolecularDevicesHardwareError, + MolecularDevicesMotionError, + MolecularDevicesNVRAMError, + MolecularDevicesSettings, + MolecularDevicesSpectraMax384PlusBackend, + MolecularDevicesSpectraMaxGeminiEMBackend, + MolecularDevicesSpectraMaxM5Backend, + MolecularDevicesUnrecognizedCommandError, + PmtGain, + ReadMode, + ReadOrder, + ReadType, + ShakeSettings, + SpectrumSettings, +) +from .plate_reader import PlateReader +from .standard import ( + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + Objective, +) +from .tecan import ExperimentalTecanInfinite200ProBackend +from .tecan.spark20m.spark_backend import ExperimentalSparkBackend diff --git a/pylabrobot/plate_reading/agilent/__init__.py b/pylabrobot/legacy/plate_reading/agilent/__init__.py similarity index 100% rename from pylabrobot/plate_reading/agilent/__init__.py rename to pylabrobot/legacy/plate_reading/agilent/__init__.py diff --git a/pylabrobot/plate_reading/agilent/biotek_backend.py b/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py similarity index 99% rename from pylabrobot/plate_reading/agilent/biotek_backend.py rename to pylabrobot/legacy/plate_reading/agilent/biotek_backend.py index d119779ba7c..6e2dad4ac56 100644 --- a/pylabrobot/plate_reading/agilent/biotek_backend.py +++ b/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py @@ -5,7 +5,7 @@ from typing import Dict, Iterable, List, Optional, Tuple from pylabrobot.io.ftdi import FTDI -from pylabrobot.plate_reading.backend import PlateReaderBackend +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend from pylabrobot.resources import Plate, Well logger = logging.getLogger(__name__) diff --git a/pylabrobot/plate_reading/agilent/biotek_cytation_backend.py b/pylabrobot/legacy/plate_reading/agilent/biotek_cytation_backend.py similarity index 99% rename from pylabrobot/plate_reading/agilent/biotek_cytation_backend.py rename to pylabrobot/legacy/plate_reading/agilent/biotek_cytation_backend.py index 67d87b3f3b3..71a3a83badf 100644 --- a/pylabrobot/plate_reading/agilent/biotek_cytation_backend.py +++ b/pylabrobot/legacy/plate_reading/agilent/biotek_cytation_backend.py @@ -8,8 +8,8 @@ from dataclasses import dataclass from typing import List, Literal, Optional, Tuple, Union -from pylabrobot.plate_reading.agilent.biotek_backend import BioTekPlateReaderBackend -from pylabrobot.plate_reading.backend import ImagerBackend +from pylabrobot.legacy.plate_reading.agilent.biotek_backend import BioTekPlateReaderBackend +from pylabrobot.legacy.plate_reading.backend import ImagerBackend from pylabrobot.resources import Plate try: @@ -21,7 +21,7 @@ USE_PYSPIN = False _PYSPIN_IMPORT_ERROR = e -from pylabrobot.plate_reading.standard import ( +from pylabrobot.legacy.plate_reading.standard import ( Exposure, FocalPosition, Gain, diff --git a/pylabrobot/plate_reading/agilent/biotek_synergyh1_backend.py b/pylabrobot/legacy/plate_reading/agilent/biotek_synergyh1_backend.py similarity index 95% rename from pylabrobot/plate_reading/agilent/biotek_synergyh1_backend.py rename to pylabrobot/legacy/plate_reading/agilent/biotek_synergyh1_backend.py index 5036bb33b83..491931a41ac 100644 --- a/pylabrobot/plate_reading/agilent/biotek_synergyh1_backend.py +++ b/pylabrobot/legacy/plate_reading/agilent/biotek_synergyh1_backend.py @@ -11,7 +11,7 @@ HAS_PYLIBFTDI = False FtdiError = Exception # type: ignore[misc,assignment] -from pylabrobot.plate_reading.agilent.biotek_backend import BioTekPlateReaderBackend +from pylabrobot.legacy.plate_reading.agilent.biotek_backend import BioTekPlateReaderBackend logger = logging.getLogger(__name__) diff --git a/pylabrobot/plate_reading/agilent/biotek_tests.py b/pylabrobot/legacy/plate_reading/agilent/biotek_tests.py similarity index 99% rename from pylabrobot/plate_reading/agilent/biotek_tests.py rename to pylabrobot/legacy/plate_reading/agilent/biotek_tests.py index 351d46672a2..b7957a42c32 100644 --- a/pylabrobot/plate_reading/agilent/biotek_tests.py +++ b/pylabrobot/legacy/plate_reading/agilent/biotek_tests.py @@ -10,7 +10,7 @@ pytest.importorskip("pylibftdi") -from pylabrobot.plate_reading.agilent.biotek_cytation_backend import CytationBackend +from pylabrobot.legacy.plate_reading.agilent.biotek_cytation_backend import CytationBackend from pylabrobot.resources import cellvis_24_wellplate_3600uL_Fb, cellvis_96_wellplate_350uL_Fb diff --git a/pylabrobot/plate_reading/backend.py b/pylabrobot/legacy/plate_reading/backend.py similarity index 95% rename from pylabrobot/plate_reading/backend.py rename to pylabrobot/legacy/plate_reading/backend.py index f793e18a023..acb0010bfaf 100644 --- a/pylabrobot/plate_reading/backend.py +++ b/pylabrobot/legacy/plate_reading/backend.py @@ -3,8 +3,8 @@ from abc import ABCMeta, abstractmethod from typing import Dict, List, Optional -from pylabrobot.machines.backend import MachineBackend -from pylabrobot.plate_reading.standard import ( +from pylabrobot.legacy.machines.backend import MachineBackend +from pylabrobot.legacy.plate_reading.standard import ( Exposure, FocalPosition, Gain, diff --git a/pylabrobot/legacy/plate_reading/biotek_backend.py b/pylabrobot/legacy/plate_reading/biotek_backend.py new file mode 100644 index 00000000000..e25c12ac5e7 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/biotek_backend.py @@ -0,0 +1,8 @@ +import warnings + +from .agilent.biotek_backend import BioTekPlateReaderBackend # noqa: F401 + +warnings.warn( + "pylabrobot.legacy.plate_reading.biotek_backend is deprecated and will be removed in a future release. " + "Please use pylabrobot.legacy.plate_reading.agilent.biotek_backend instead.", +) diff --git a/pylabrobot/plate_reading/biotek_cytation_backend.py b/pylabrobot/legacy/plate_reading/biotek_cytation_backend.py similarity index 55% rename from pylabrobot/plate_reading/biotek_cytation_backend.py rename to pylabrobot/legacy/plate_reading/biotek_cytation_backend.py index c89a11cbf88..a4b3f7c74b7 100644 --- a/pylabrobot/plate_reading/biotek_cytation_backend.py +++ b/pylabrobot/legacy/plate_reading/biotek_cytation_backend.py @@ -8,6 +8,6 @@ ) warnings.warn( - "pylabrobot.plate_reading.biotek_backend is deprecated and will be removed in a future release. " - "Please use pylabrobot.plate_reading.agilent.biotek_backend instead.", + "pylabrobot.legacy.plate_reading.biotek_backend is deprecated and will be removed in a future release. " + "Please use pylabrobot.legacy.plate_reading.agilent.biotek_backend instead.", ) diff --git a/pylabrobot/legacy/plate_reading/biotek_synergyh1_backend.py b/pylabrobot/legacy/plate_reading/biotek_synergyh1_backend.py new file mode 100644 index 00000000000..2af444a948b --- /dev/null +++ b/pylabrobot/legacy/plate_reading/biotek_synergyh1_backend.py @@ -0,0 +1,8 @@ +import warnings + +from .agilent.biotek_synergyh1_backend import SynergyH1Backend # noqa: F401 + +warnings.warn( + "pylabrobot.legacy.plate_reading.biotek_synergyh1_backend is deprecated and will be removed in a future release. " + "Please use pylabrobot.legacy.plate_reading.agilent.biotek_synergyh1_backend instead.", +) diff --git a/pylabrobot/plate_reading/bmg_labtech/__init__.py b/pylabrobot/legacy/plate_reading/bmg_labtech/__init__.py similarity index 100% rename from pylabrobot/plate_reading/bmg_labtech/__init__.py rename to pylabrobot/legacy/plate_reading/bmg_labtech/__init__.py diff --git a/pylabrobot/plate_reading/bmg_labtech/clario_star_backend.py b/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py similarity index 100% rename from pylabrobot/plate_reading/bmg_labtech/clario_star_backend.py rename to pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py diff --git a/pylabrobot/plate_reading/byonoy/__init__.py b/pylabrobot/legacy/plate_reading/byonoy/__init__.py similarity index 100% rename from pylabrobot/plate_reading/byonoy/__init__.py rename to pylabrobot/legacy/plate_reading/byonoy/__init__.py diff --git a/pylabrobot/plate_reading/byonoy/byonoy_a96a.py b/pylabrobot/legacy/plate_reading/byonoy/byonoy_a96a.py similarity index 97% rename from pylabrobot/plate_reading/byonoy/byonoy_a96a.py rename to pylabrobot/legacy/plate_reading/byonoy/byonoy_a96a.py index f30252e501d..dea36ddefda 100644 --- a/pylabrobot/plate_reading/byonoy/byonoy_a96a.py +++ b/pylabrobot/legacy/plate_reading/byonoy/byonoy_a96a.py @@ -1,7 +1,7 @@ from typing import Optional, Tuple -from pylabrobot.plate_reading.byonoy.byonoy_backend import ByonoyAbsorbance96AutomateBackend -from pylabrobot.plate_reading.plate_reader import PlateReader +from pylabrobot.legacy.plate_reading.byonoy.byonoy_backend import ByonoyAbsorbance96AutomateBackend +from pylabrobot.legacy.plate_reading.plate_reader import PlateReader from pylabrobot.resources import Coordinate, PlateHolder, Resource, ResourceHolder from pylabrobot.resources.barcode import Barcode from pylabrobot.resources.rotation import Rotation diff --git a/pylabrobot/plate_reading/byonoy/byonoy_backend.py b/pylabrobot/legacy/plate_reading/byonoy/byonoy_backend.py similarity index 99% rename from pylabrobot/plate_reading/byonoy/byonoy_backend.py rename to pylabrobot/legacy/plate_reading/byonoy/byonoy_backend.py index ca2ae684cf3..e292ae1939d 100644 --- a/pylabrobot/plate_reading/byonoy/byonoy_backend.py +++ b/pylabrobot/legacy/plate_reading/byonoy/byonoy_backend.py @@ -7,7 +7,7 @@ from pylabrobot.io.binary import Reader, Writer from pylabrobot.io.hid import HID -from pylabrobot.plate_reading.backend import PlateReaderBackend +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend from pylabrobot.resources import Plate, Well from pylabrobot.utils.list import reshape_2d diff --git a/pylabrobot/plate_reading/byonoy/byonoy_l96.py b/pylabrobot/legacy/plate_reading/byonoy/byonoy_l96.py similarity index 96% rename from pylabrobot/plate_reading/byonoy/byonoy_l96.py rename to pylabrobot/legacy/plate_reading/byonoy/byonoy_l96.py index c9b080c5b33..958b35b43a4 100644 --- a/pylabrobot/plate_reading/byonoy/byonoy_l96.py +++ b/pylabrobot/legacy/plate_reading/byonoy/byonoy_l96.py @@ -1,7 +1,9 @@ from typing import Optional, Tuple -from pylabrobot.plate_reading.byonoy.byonoy_backend import ByonoyLuminescence96AutomateBackend -from pylabrobot.plate_reading.plate_reader import PlateReader +from pylabrobot.legacy.plate_reading.byonoy.byonoy_backend import ( + ByonoyLuminescence96AutomateBackend, +) +from pylabrobot.legacy.plate_reading.plate_reader import PlateReader from pylabrobot.resources import Coordinate, PlateHolder, Resource, ResourceHolder from pylabrobot.resources.barcode import Barcode from pylabrobot.resources.rotation import Rotation diff --git a/pylabrobot/plate_reading/byonoy/byonoy_l96a.py b/pylabrobot/legacy/plate_reading/byonoy/byonoy_l96a.py similarity index 100% rename from pylabrobot/plate_reading/byonoy/byonoy_l96a.py rename to pylabrobot/legacy/plate_reading/byonoy/byonoy_l96a.py diff --git a/pylabrobot/plate_reading/byonoy/byonoy_tests.py b/pylabrobot/legacy/plate_reading/byonoy/byonoy_tests.py similarity index 93% rename from pylabrobot/plate_reading/byonoy/byonoy_tests.py rename to pylabrobot/legacy/plate_reading/byonoy/byonoy_tests.py index 4e071783db1..fc9eddd08c5 100644 --- a/pylabrobot/plate_reading/byonoy/byonoy_tests.py +++ b/pylabrobot/legacy/plate_reading/byonoy/byonoy_tests.py @@ -1,8 +1,8 @@ import unittest import unittest.mock -from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerBackend -from pylabrobot.plate_reading.byonoy import ( +from pylabrobot.legacy.liquid_handling import LiquidHandler, LiquidHandlerBackend +from pylabrobot.legacy.plate_reading.byonoy import ( byonoy_a96a, byonoy_sbs_adapter, ) diff --git a/pylabrobot/plate_reading/chatterbox.py b/pylabrobot/legacy/plate_reading/chatterbox.py similarity index 98% rename from pylabrobot/plate_reading/chatterbox.py rename to pylabrobot/legacy/plate_reading/chatterbox.py index 4def8f3a806..dd5750350c9 100644 --- a/pylabrobot/plate_reading/chatterbox.py +++ b/pylabrobot/legacy/plate_reading/chatterbox.py @@ -1,7 +1,7 @@ import time from typing import Dict, List, Optional -from pylabrobot.plate_reading.backend import PlateReaderBackend +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend from pylabrobot.resources import Plate, Well diff --git a/pylabrobot/legacy/plate_reading/clario_star_backend.py b/pylabrobot/legacy/plate_reading/clario_star_backend.py new file mode 100644 index 00000000000..c9e4d098aa2 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/clario_star_backend.py @@ -0,0 +1,8 @@ +import warnings + +from .bmg_labtech.clario_star_backend import CLARIOstarBackend # noqa: F401 + +warnings.warn( + "pylabrobot.legacy.plate_reading.clario_star_backend is deprecated and will be removed in a future release. " + "Please use pylabrobot.legacy.plate_reading.bmg_labtech.clario_star_backend instead.", +) diff --git a/pylabrobot/plate_reading/image_reader.py b/pylabrobot/legacy/plate_reading/image_reader.py similarity index 77% rename from pylabrobot/plate_reading/image_reader.py rename to pylabrobot/legacy/plate_reading/image_reader.py index e340ccf0f51..0bb68d9591a 100644 --- a/pylabrobot/plate_reading/image_reader.py +++ b/pylabrobot/legacy/plate_reading/image_reader.py @@ -1,8 +1,8 @@ from typing import Optional -from pylabrobot.plate_reading.backend import ImageReaderBackend -from pylabrobot.plate_reading.imager import Imager -from pylabrobot.plate_reading.plate_reader import PlateReader +from pylabrobot.legacy.plate_reading.backend import ImageReaderBackend +from pylabrobot.legacy.plate_reading.imager import Imager +from pylabrobot.legacy.plate_reading.plate_reader import PlateReader from pylabrobot.resources import Rotation diff --git a/pylabrobot/plate_reading/imager.py b/pylabrobot/legacy/plate_reading/imager.py similarity index 98% rename from pylabrobot/plate_reading/imager.py rename to pylabrobot/legacy/plate_reading/imager.py index c55e0737d7f..e05b8c25d66 100644 --- a/pylabrobot/plate_reading/imager.py +++ b/pylabrobot/legacy/plate_reading/imager.py @@ -3,9 +3,9 @@ import time from typing import Any, Awaitable, Callable, Coroutine, Dict, Literal, Optional, Tuple, Union, cast -from pylabrobot.machines import Machine, need_setup_finished -from pylabrobot.plate_reading.backend import ImagerBackend -from pylabrobot.plate_reading.standard import ( +from pylabrobot.legacy.machines import Machine, need_setup_finished +from pylabrobot.legacy.plate_reading.backend import ImagerBackend +from pylabrobot.legacy.plate_reading.standard import ( AutoExposure, AutoFocus, Exposure, diff --git a/pylabrobot/plate_reading/molecular_devices/__init__.py b/pylabrobot/legacy/plate_reading/molecular_devices/__init__.py similarity index 100% rename from pylabrobot/plate_reading/molecular_devices/__init__.py rename to pylabrobot/legacy/plate_reading/molecular_devices/__init__.py diff --git a/pylabrobot/plate_reading/molecular_devices/backend.py b/pylabrobot/legacy/plate_reading/molecular_devices/backend.py similarity index 99% rename from pylabrobot/plate_reading/molecular_devices/backend.py rename to pylabrobot/legacy/plate_reading/molecular_devices/backend.py index fa573388958..39d25fc60c8 100644 --- a/pylabrobot/plate_reading/molecular_devices/backend.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/backend.py @@ -8,7 +8,7 @@ from typing import Dict, List, Literal, Optional, Tuple, Union from pylabrobot.io.serial import Serial -from pylabrobot.plate_reading.backend import PlateReaderBackend +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend from pylabrobot.resources.plate import Plate logger = logging.getLogger("pylabrobot") diff --git a/pylabrobot/plate_reading/molecular_devices/backend_tests.py b/pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py similarity index 97% rename from pylabrobot/plate_reading/molecular_devices/backend_tests.py rename to pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py index 69849538c94..cdc53586c7d 100644 --- a/pylabrobot/plate_reading/molecular_devices/backend_tests.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py @@ -2,7 +2,7 @@ import unittest from unittest.mock import AsyncMock, MagicMock, call, patch -from pylabrobot.plate_reading.molecular_devices.backend import ( +from pylabrobot.legacy.plate_reading.molecular_devices.backend import ( Calibrate, CarriageSpeed, KineticSettings, @@ -17,7 +17,7 @@ ShakeSettings, SpectrumSettings, ) -from pylabrobot.plate_reading.molecular_devices.spectramax_gemini_em_backend import ( +from pylabrobot.legacy.plate_reading.molecular_devices.spectramax_gemini_em_backend import ( MolecularDevicesSpectraMaxGeminiEMBackend, ) from pylabrobot.resources.agenbio.plates import agenbio_96_wellplate_Ub_2200uL @@ -457,16 +457,16 @@ async def test_set_tag(self): self.send_command_mock.assert_called_once_with("!TAG OFF") @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) async def test_read_absorbance(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): @@ -494,16 +494,16 @@ async def test_read_absorbance(self, mock_read_now, mock_transfer_data, mock_wai mock_transfer_data.assert_called_once() @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) async def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): @@ -538,16 +538,16 @@ async def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_w mock_transfer_data.assert_called_once() @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) async def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): @@ -577,16 +577,16 @@ async def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_w mock_transfer_data.assert_called_once() @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) async def test_read_fluorescence_polarization( @@ -626,16 +626,16 @@ async def test_read_fluorescence_polarization( mock_transfer_data.assert_called_once() @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) async def test_read_time_resolved_fluorescence( @@ -685,10 +685,10 @@ def setUp(self): self.backend = MolecularDevicesSpectraMaxGeminiEMBackend(port="COM1") def test_public_imports(self): - from pylabrobot.plate_reading import ( + from pylabrobot.legacy.plate_reading import ( MolecularDevicesSpectraMaxGeminiEMBackend as PlateReadingGeminiBackend, ) - from pylabrobot.plate_reading.molecular_devices import ( + from pylabrobot.legacy.plate_reading.molecular_devices import ( MolecularDevicesSpectraMaxGeminiEMBackend as MolecularDevicesGeminiBackend, ) diff --git a/pylabrobot/plate_reading/molecular_devices/spectramax_384_plus_backend.py b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py similarity index 100% rename from pylabrobot/plate_reading/molecular_devices/spectramax_384_plus_backend.py rename to pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py diff --git a/pylabrobot/plate_reading/molecular_devices/spectramax_gemini_em_backend.py b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_gemini_em_backend.py similarity index 100% rename from pylabrobot/plate_reading/molecular_devices/spectramax_gemini_em_backend.py rename to pylabrobot/legacy/plate_reading/molecular_devices/spectramax_gemini_em_backend.py diff --git a/pylabrobot/plate_reading/molecular_devices/spectramax_m5_backend.py b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_m5_backend.py similarity index 100% rename from pylabrobot/plate_reading/molecular_devices/spectramax_m5_backend.py rename to pylabrobot/legacy/plate_reading/molecular_devices/spectramax_m5_backend.py diff --git a/pylabrobot/legacy/plate_reading/molecular_devices_backend.py b/pylabrobot/legacy/plate_reading/molecular_devices_backend.py new file mode 100644 index 00000000000..7c4ea4c8e81 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/molecular_devices_backend.py @@ -0,0 +1,11 @@ +import warnings + +from .molecular_devices.backend import ( # noqa: F401 + MolecularDevicesBackend, + MolecularDevicesSettings, +) + +warnings.warn( + "pylabrobot.legacy.plate_reading.molecular_devices_backend is deprecated and will be removed in a future release. " + "Please use pylabrobot.legacy.plate_reading.molecular_devices.molecular_devices_backend instead.", +) diff --git a/pylabrobot/plate_reading/plate_reader.py b/pylabrobot/legacy/plate_reading/plate_reader.py similarity index 95% rename from pylabrobot/plate_reading/plate_reader.py rename to pylabrobot/legacy/plate_reading/plate_reader.py index a44e6783261..b74c7bf6fd8 100644 --- a/pylabrobot/plate_reading/plate_reader.py +++ b/pylabrobot/legacy/plate_reading/plate_reader.py @@ -1,9 +1,9 @@ import logging from typing import Dict, List, Optional, cast -from pylabrobot.machines.machine import Machine, need_setup_finished -from pylabrobot.plate_reading.backend import PlateReaderBackend -from pylabrobot.plate_reading.standard import NoPlateError +from pylabrobot.legacy.machines.machine import Machine, need_setup_finished +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend +from pylabrobot.legacy.plate_reading.standard import NoPlateError from pylabrobot.resources import Coordinate, Plate, Resource, ResourceHolder, Rotation, Well logger = logging.getLogger(__name__) @@ -18,7 +18,7 @@ class PlateReader(ResourceHolder, Machine): Here's an example of how to use this class in a Jupyter Notebook: - >>> from pylabrobot.plate_reading.clario_star import CLARIOStarBackend + >>> from pylabrobot.legacy.plate_reading.clario_star import CLARIOStarBackend >>> pr = PlateReader(backend=CLARIOStarBackend()) >>> pr.setup() >>> await pr.read_luminescence() diff --git a/pylabrobot/plate_reading/plate_reader_tests.py b/pylabrobot/legacy/plate_reading/plate_reader_tests.py similarity index 87% rename from pylabrobot/plate_reading/plate_reader_tests.py rename to pylabrobot/legacy/plate_reading/plate_reader_tests.py index 1cea4ac7b87..bd61f1bc296 100644 --- a/pylabrobot/plate_reading/plate_reader_tests.py +++ b/pylabrobot/legacy/plate_reading/plate_reader_tests.py @@ -1,7 +1,7 @@ import unittest -from pylabrobot.plate_reading import PlateReader -from pylabrobot.plate_reading.chatterbox import PlateReaderChatterboxBackend +from pylabrobot.legacy.plate_reading import PlateReader +from pylabrobot.legacy.plate_reading.chatterbox import PlateReaderChatterboxBackend from pylabrobot.resources import Plate diff --git a/pylabrobot/legacy/plate_reading/spectramax_384_plus_backend.py b/pylabrobot/legacy/plate_reading/spectramax_384_plus_backend.py new file mode 100644 index 00000000000..b9fc21dd100 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/spectramax_384_plus_backend.py @@ -0,0 +1,10 @@ +import warnings + +from .molecular_devices.spectramax_384_plus_backend import ( + MolecularDevicesSpectraMax384PlusBackend, # noqa: F401 +) + +warnings.warn( + "pylabrobot.legacy.plate_reading.spectramax_384_plus_backend is deprecated and will be removed in a future release. " + "Please use pylabrobot.legacy.plate_reading.molecular_devices.spectramax_384_plus_backend instead.", +) diff --git a/pylabrobot/legacy/plate_reading/spectramax_m5_backend.py b/pylabrobot/legacy/plate_reading/spectramax_m5_backend.py new file mode 100644 index 00000000000..76648c6f290 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/spectramax_m5_backend.py @@ -0,0 +1,10 @@ +import warnings + +from .molecular_devices.spectramax_m5_backend import ( + MolecularDevicesSpectraMaxM5Backend, # noqa: F401 +) + +warnings.warn( + "pylabrobot.legacy.plate_reading.spectramax_m5_backend is deprecated and will be removed in a future release. " + "Please use pylabrobot.legacy.plate_reading.molecular_devices.spectramax_m5_backend instead.", +) diff --git a/pylabrobot/plate_reading/standard.py b/pylabrobot/legacy/plate_reading/standard.py similarity index 100% rename from pylabrobot/plate_reading/standard.py rename to pylabrobot/legacy/plate_reading/standard.py diff --git a/pylabrobot/plate_reading/tecan/__init__.py b/pylabrobot/legacy/plate_reading/tecan/__init__.py similarity index 100% rename from pylabrobot/plate_reading/tecan/__init__.py rename to pylabrobot/legacy/plate_reading/tecan/__init__.py diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/legacy/plate_reading/tecan/infinite_backend.py similarity index 99% rename from pylabrobot/plate_reading/tecan/infinite_backend.py rename to pylabrobot/legacy/plate_reading/tecan/infinite_backend.py index 992e422c342..06c6e08ca5c 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/legacy/plate_reading/tecan/infinite_backend.py @@ -17,7 +17,7 @@ from pylabrobot.io.binary import Reader from pylabrobot.io.usb import USB -from pylabrobot.plate_reading.backend import PlateReaderBackend +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend from pylabrobot.resources import Plate from pylabrobot.resources.well import Well diff --git a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py b/pylabrobot/legacy/plate_reading/tecan/infinite_backend_tests.py similarity index 99% rename from pylabrobot/plate_reading/tecan/infinite_backend_tests.py rename to pylabrobot/legacy/plate_reading/tecan/infinite_backend_tests.py index 59e3f797bec..284254d75b0 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py +++ b/pylabrobot/legacy/plate_reading/tecan/infinite_backend_tests.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, call, patch from pylabrobot.io.usb import USB -from pylabrobot.plate_reading.tecan.infinite_backend import ( +from pylabrobot.legacy.plate_reading.tecan.infinite_backend import ( ExperimentalTecanInfinite200ProBackend, _absorbance_od_calibrated, _AbsorbanceRunDecoder, @@ -653,7 +653,7 @@ def setUp(self): self.mock_usb.read = AsyncMock(return_value=self._frame("ST")) patcher = patch( - "pylabrobot.plate_reading.tecan.infinite_backend.USB", + "pylabrobot.legacy.plate_reading.tecan.infinite_backend.USB", return_value=self.mock_usb, ) self.mock_usb_class = patcher.start() diff --git a/pylabrobot/legacy/plate_reading/tecan/spark20m/__init__.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/__init__.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/__init__.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/__init__.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/__init__.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/base_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/base_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/base_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/base_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/camera_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/camera_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/camera_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/camera_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/config_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/config_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/config_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/config_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/data_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/data_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/data_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/data_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/gas_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/gas_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/gas_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/gas_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/injector_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/injector_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/injector_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/injector_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/measurement_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/measurement_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/measurement_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/measurement_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/movement_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/movement_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/movement_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/movement_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/optics_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/optics_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/optics_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/optics_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/plate_transport_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/plate_transport_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/plate_transport_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/plate_transport_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/sensor_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/sensor_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/sensor_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/sensor_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/spark_enums.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/spark_enums.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/spark_enums.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/spark_enums.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/system_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/system_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/system_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/system_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/enums.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/enums.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/enums.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/enums.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_backend.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_backend.py similarity index 99% rename from pylabrobot/plate_reading/tecan/spark20m/spark_backend.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_backend.py index 093de094394..a89b1071665 100644 --- a/pylabrobot/plate_reading/tecan/spark20m/spark_backend.py +++ b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_backend.py @@ -3,8 +3,8 @@ import time from typing import Dict, List, Optional, Union -from pylabrobot.plate_reading.backend import PlateReaderBackend -from pylabrobot.plate_reading.utils import _get_min_max_row_col_tuples +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend +from pylabrobot.legacy.plate_reading.utils import _get_min_max_row_col_tuples from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_backend_tests.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_backend_tests.py similarity index 93% rename from pylabrobot/plate_reading/tecan/spark20m/spark_backend_tests.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_backend_tests.py index 3fc54118226..16a8e18e812 100644 --- a/pylabrobot/plate_reading/tecan/spark20m/spark_backend_tests.py +++ b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_backend_tests.py @@ -3,8 +3,8 @@ import unittest from unittest.mock import AsyncMock, MagicMock, patch -from pylabrobot.plate_reading.tecan.spark20m.enums import SparkDevice -from pylabrobot.plate_reading.tecan.spark20m.spark_backend import ExperimentalSparkBackend +from pylabrobot.legacy.plate_reading.tecan.spark20m.enums import SparkDevice +from pylabrobot.legacy.plate_reading.tecan.spark20m.spark_backend import ExperimentalSparkBackend from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well @@ -16,7 +16,7 @@ class TestExperimentalSparkBackend(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self) -> None: # Patch SparkReaderAsync self.reader_patcher = patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_backend.SparkReaderAsync" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_backend.SparkReaderAsync" ) self.MockReaderClass = self.reader_patcher.start() self.mock_reader = self.MockReaderClass.return_value @@ -27,12 +27,12 @@ async def asyncSetUp(self) -> None: # Patch processor functions self.abs_proc_patcher = patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_backend.process_absorbance" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_backend.process_absorbance" ) self.mock_process_absorbance = self.abs_proc_patcher.start() self.fluo_proc_patcher = patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_backend.process_fluorescence" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_backend.process_fluorescence" ) self.mock_process_fluorescence = self.fluo_proc_patcher.start() @@ -151,7 +151,7 @@ async def test_experimental_read_absorbance_spectrum(self) -> None: # Patch process_absorbance_spectrum with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_backend.process_absorbance_spectrum" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_backend.process_absorbance_spectrum" ) as mock_proc: mock_proc.return_value = {500.0: [[0.5, 0.6]], 510.0: [[0.7, 0.8]]} @@ -204,7 +204,7 @@ async def test_experimental_read_fluorescence_excitation_spectrum(self) -> None: # Patch process_fluorescence_spectrum with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_backend.process_fluorescence_spectrum" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_backend.process_fluorescence_spectrum" ) as mock_proc: mock_proc.return_value = {440.0: [[100.0]], 450.0: [[200.0]]} @@ -273,7 +273,7 @@ async def test_experimental_read_fluorescence_emission_spectrum(self) -> None: # Patch process_fluorescence_spectrum with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_backend.process_fluorescence_spectrum" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_backend.process_fluorescence_spectrum" ) as mock_proc: mock_proc.return_value = {550.0: [[100.0]], 560.0: [[200.0]]} diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_packet_parser.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_packet_parser.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/spark_packet_parser.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_packet_parser.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_processor.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_processor.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/spark_processor.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_processor.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_processor_tests.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_processor_tests.py similarity index 95% rename from pylabrobot/plate_reading/tecan/spark20m/spark_processor_tests.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_processor_tests.py index 597eec7def3..056f67dbb4d 100644 --- a/pylabrobot/plate_reading/tecan/spark20m/spark_processor_tests.py +++ b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_processor_tests.py @@ -6,7 +6,7 @@ import pytest # Configure logging to avoid pollution during tests -from pylabrobot.plate_reading.tecan.spark20m.spark_processor import ( +from pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor import ( process_absorbance, process_absorbance_spectrum, process_fluorescence, @@ -67,7 +67,7 @@ def test_process_success(self) -> None: } with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_absorbance([]) @@ -85,7 +85,7 @@ def test_process_missing_reference(self) -> None: # Only standalone sequences, no grouped reference parsed_data = {"SEQ_MEAS": [{"type": "standalone", "block": {"measurements": []}}]} with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_absorbance([]) @@ -94,7 +94,8 @@ def test_process_missing_reference(self) -> None: def test_process_empty_data(self) -> None: with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value={} + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + return_value={}, ): results = process_absorbance([]) self.assertEqual(results, []) @@ -119,7 +120,7 @@ def test_zero_division_protection(self) -> None: } with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_absorbance([]) @@ -260,7 +261,7 @@ def test_process_success(self) -> None: } with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_fluorescence([]) @@ -279,7 +280,7 @@ def test_process_missing_calibration(self) -> None: ] } with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_fluorescence([]) @@ -298,7 +299,7 @@ def test_process_invalid_dark_block(self) -> None: } with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_fluorescence([]) @@ -369,7 +370,8 @@ def test_process_real_data(self) -> None: class TestProcessAbsorbanceSpectrum(unittest.TestCase): def test_process_empty_data(self) -> None: with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value={} + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + return_value={}, ): results = process_absorbance_spectrum([]) self.assertEqual(results, {}) @@ -377,7 +379,7 @@ def test_process_empty_data(self) -> None: def test_process_missing_reference(self) -> None: parsed_data = {"SEQ_MEAS": [{"type": "standalone", "block": {"measurements": []}}]} with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_absorbance_spectrum([]) @@ -415,7 +417,7 @@ def test_process_multi_wavelength(self) -> None: } with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_absorbance_spectrum([]) @@ -433,7 +435,8 @@ def test_process_multi_wavelength(self) -> None: class TestProcessFluorescenceSpectrum(unittest.TestCase): def test_process_empty_data(self) -> None: with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value={} + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + return_value={}, ): results = process_fluorescence_spectrum([]) self.assertEqual(results, {}) @@ -445,7 +448,7 @@ def test_process_missing_calibration(self) -> None: ] } with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_fluorescence_spectrum([]) @@ -489,7 +492,7 @@ def test_process_multi_wavelength(self) -> None: } with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_fluorescence_spectrum([]) diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_reader_async.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_reader_async.py similarity index 99% rename from pylabrobot/plate_reading/tecan/spark20m/spark_reader_async.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_reader_async.py index 83a1d3256b0..fb13c20f235 100644 --- a/pylabrobot/plate_reading/tecan/spark20m/spark_reader_async.py +++ b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_reader_async.py @@ -172,7 +172,7 @@ async def _read_packet_in_executor( timeout: Optional[float] = None, ) -> Optional[bytes]: loop = asyncio.get_running_loop() - if reader._executor is None: + if reader.read_executor is None: raise RuntimeError("Call setup() first.") start_time = time.monotonic() @@ -187,7 +187,7 @@ async def _read_packet_in_executor( current_timeout = timeout - elapsed data = await loop.run_in_executor( - reader._executor, + reader.read_executor, lambda: reader._read_packet(size=size, timeout=current_timeout, endpoint=endpoint), ) diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_reader_async_tests.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_reader_async_tests.py similarity index 87% rename from pylabrobot/plate_reading/tecan/spark20m/spark_reader_async_tests.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_reader_async_tests.py index db0bab73ff8..6a3e95a8161 100644 --- a/pylabrobot/plate_reading/tecan/spark20m/spark_reader_async_tests.py +++ b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_reader_async_tests.py @@ -8,14 +8,19 @@ pytest.importorskip("usb") # Import the module under test -from pylabrobot.plate_reading.tecan.spark20m.enums import SparkDevice, SparkEndpoint -from pylabrobot.plate_reading.tecan.spark20m.spark_reader_async import SparkError, SparkReaderAsync +from pylabrobot.legacy.plate_reading.tecan.spark20m.enums import SparkDevice, SparkEndpoint +from pylabrobot.legacy.plate_reading.tecan.spark20m.spark_reader_async import ( + SparkError, + SparkReaderAsync, +) class TestSparkReaderAsync(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self) -> None: # Patch USB class - self.usb_patcher = patch("pylabrobot.plate_reading.tecan.spark20m.spark_reader_async.USB") + self.usb_patcher = patch( + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_reader_async.USB" + ) self.mock_usb_class = self.usb_patcher.start() self.reader = SparkReaderAsync() @@ -94,7 +99,7 @@ async def test_send_command(self) -> None: self.reader.devices[SparkDevice.PLATE_TRANSPORT] = mock_dev # Configure mock executor and device - mock_dev._executor = MagicMock() + mock_dev.read_executor = MagicMock() mock_dev.dev = MagicMock() mock_dev.write_timeout = 30 # default @@ -107,7 +112,7 @@ def execute_sync(func, *args): f.set_exception(e) return f - mock_dev._executor.submit.side_effect = execute_sync + mock_dev.read_executor.submit.side_effect = execute_sync # Mock _read_packet to avoid TypeError in background task (must be MagicMock, not AsyncMock) mock_dev._read_packet = MagicMock() @@ -138,7 +143,7 @@ async def test_send_command_device_not_connected(self) -> None: async def test_get_response_success(self) -> None: # Mock parse_single_spark_packet with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" ) as mock_parse: mock_parse.return_value = {"type": "RespReady", "payload": {"status": "OK"}} @@ -158,7 +163,7 @@ async def test_get_response_busy_then_ready(self) -> None: mock_reader._read_packet = MagicMock() with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" ) as mock_parse: # Sequence of parse results: # 1. First read (passed as task): RespMessage (busy/intermediate) @@ -169,7 +174,7 @@ async def test_get_response_busy_then_ready(self) -> None: ] # Configure mock executor and device for read retry - mock_reader._executor = MagicMock() + mock_reader.read_executor = MagicMock() mock_reader.dev = MagicMock() mock_reader.read_timeout = 30 @@ -179,7 +184,7 @@ def execute_sync(func, *args): f.set_result(result) return f - mock_reader._executor.submit.side_effect = execute_sync + mock_reader.read_executor.submit.side_effect = execute_sync # Mock _read_packet # First call inside _get_response (via executor) @@ -203,7 +208,7 @@ async def test_start_background_read(self) -> None: mock_dev._read_packet = MagicMock() self.reader.devices[SparkDevice.ABSORPTION] = mock_dev - mock_dev._executor = MagicMock() + mock_dev.read_executor = MagicMock() mock_dev.dev = MagicMock() mock_dev.read_timeout = 30 @@ -216,7 +221,7 @@ def execute_sync(func, *args): f.set_exception(e) return f - mock_dev._executor.submit.side_effect = execute_sync + mock_dev.read_executor.submit.side_effect = execute_sync DATA1 = b"\x81\x01\x00\x00\x00" DATA2 = b"\x81\x02\x00\x00\x00" @@ -269,7 +274,7 @@ async def test_close(self) -> None: async def test_get_response_error(self) -> None: with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" ) as mock_parse: mock_parse.return_value = {"type": "RespError", "payload": {"error": "BadCommand"}} @@ -288,7 +293,7 @@ async def test_get_response_empty_packet_retry(self) -> None: mock_reader._read_packet = MagicMock() # Configure mock executor and device for read retry - mock_reader._executor = MagicMock() + mock_reader.read_executor = MagicMock() mock_reader.dev = MagicMock() mock_reader.read_timeout = 30 @@ -298,10 +303,10 @@ def execute_sync(func, *args): f.set_result(result) return f - mock_reader._executor.submit.side_effect = execute_sync + mock_reader.read_executor.submit.side_effect = execute_sync with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" ) as mock_parse: # Sequence: # 1. First read task returns empty bytes -> Triggers ValueError in parser (mocked below) -> retry @@ -336,7 +341,7 @@ async def return_empty_bytes() -> bytes: async def test_read_packet_in_executor_retries(self) -> None: # Test that _read_packet_in_executor retries on invalid packets using new validation logic mock_reader = AsyncMock() - mock_reader._executor = MagicMock() + mock_reader.read_executor = MagicMock() def execute_sync(func, *args): # We need to execute the lambda passed to run_in_executor @@ -368,24 +373,16 @@ def execute_sync(func, *args): side_effect=[INVALID_SHORT, INVALID_INDICATOR, INVALID_TRUNCATED, VALID] ) - # We need to mock loop.run_in_executor to execute the lambda? - # Or just use the real loop since we are in async test. - # The real loop uses the executor. Since we mocked reader._executor, we just need to confirm it's passed. - # But wait, helper checks reader._executor is not None. - - # In the code: loop.run_in_executor(reader._executor, lambda...) - # We want to use the REAL run_in_executor with a Mock executor? - # Standard ThreadPoolExecutor works fine. - # Let's use a real ThreadPoolExecutor for simplicity and just mock _read_packet on the reader object. - - mock_reader._executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) + # The helper does loop.run_in_executor(reader.read_executor, lambda: reader._read_packet(...)), + # so a real ThreadPoolExecutor runs the lambda while _read_packet stays mocked. + mock_reader.read_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) try: data = await self.reader._read_packet_in_executor(mock_reader, timeout=1.0) self.assertEqual(data, VALID) self.assertEqual(mock_reader._read_packet.call_count, 4) finally: - mock_reader._executor.shutdown() + mock_reader.read_executor.shutdown() if __name__ == "__main__": diff --git a/pylabrobot/plate_reading/utils.py b/pylabrobot/legacy/plate_reading/utils.py similarity index 100% rename from pylabrobot/plate_reading/utils.py rename to pylabrobot/legacy/plate_reading/utils.py diff --git a/pylabrobot/legacy/plate_washing/__init__.py b/pylabrobot/legacy/plate_washing/__init__.py new file mode 100644 index 00000000000..c46811a7fe2 --- /dev/null +++ b/pylabrobot/legacy/plate_washing/__init__.py @@ -0,0 +1,4 @@ +"""Plate washing module for PyLabRobot. + +This module provides support for automated plate washers. +""" diff --git a/pylabrobot/plate_washing/biotek/__init__.py b/pylabrobot/legacy/plate_washing/biotek/__init__.py similarity index 56% rename from pylabrobot/plate_washing/biotek/__init__.py rename to pylabrobot/legacy/plate_washing/biotek/__init__.py index dc8718dd159..db80d4ff823 100644 --- a/pylabrobot/plate_washing/biotek/__init__.py +++ b/pylabrobot/legacy/plate_washing/biotek/__init__.py @@ -1,5 +1,5 @@ """BioTek plate washer backends for PyLabRobot. Import device-specific symbols from subpackages: - from pylabrobot.plate_washing.biotek.el406 import BioTekEL406Backend + from pylabrobot.legacy.plate_washing.biotek.el406 import BioTekEL406Backend """ diff --git a/pylabrobot/plate_washing/biotek/el406/__init__.py b/pylabrobot/legacy/plate_washing/biotek/el406/__init__.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/__init__.py rename to pylabrobot/legacy/plate_washing/biotek/el406/__init__.py diff --git a/pylabrobot/plate_washing/biotek/el406/actions.py b/pylabrobot/legacy/plate_washing/biotek/el406/actions.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/actions.py rename to pylabrobot/legacy/plate_washing/biotek/el406/actions.py diff --git a/pylabrobot/plate_washing/biotek/el406/actions_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/actions_tests.py similarity index 98% rename from pylabrobot/plate_washing/biotek/el406/actions_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/actions_tests.py index 5ba1b29ed9a..8ad9d5656b2 100644 --- a/pylabrobot/plate_washing/biotek/el406/actions_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/actions_tests.py @@ -1,14 +1,14 @@ # mypy: disable-error-code="union-attr,assignment,arg-type" """Tests for BioTek EL406 action methods.""" -from pylabrobot.plate_washing.biotek.el406 import ( +from pylabrobot.legacy.plate_washing.biotek.el406 import ( EL406Motor, EL406MotorHomeType, EL406StepType, EL406WasherManifold, ExperimentalBioTekEL406Backend, ) -from pylabrobot.plate_washing.biotek.el406.mock_tests import EL406TestCase +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import EL406TestCase class TestEL406BackendAbort(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/backend.py b/pylabrobot/legacy/plate_washing/biotek/el406/backend.py similarity index 98% rename from pylabrobot/plate_washing/biotek/el406/backend.py rename to pylabrobot/legacy/plate_washing/biotek/el406/backend.py index 15bb6b833f1..8e45acd3414 100644 --- a/pylabrobot/plate_washing/biotek/el406/backend.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/backend.py @@ -19,7 +19,7 @@ from contextlib import asynccontextmanager from pylabrobot.io.ftdi import FTDI -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend from pylabrobot.resources import Plate from .actions import EL406ActionsMixin diff --git a/pylabrobot/plate_washing/biotek/el406/batch_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/batch_tests.py similarity index 98% rename from pylabrobot/plate_washing/biotek/el406/batch_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/batch_tests.py index b7ef54ec295..988d99664f7 100644 --- a/pylabrobot/plate_washing/biotek/el406/batch_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/batch_tests.py @@ -2,7 +2,7 @@ import unittest -from pylabrobot.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase class TestBatchContextManager(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/communication.py b/pylabrobot/legacy/plate_washing/biotek/el406/communication.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/communication.py rename to pylabrobot/legacy/plate_washing/biotek/el406/communication.py diff --git a/pylabrobot/plate_washing/biotek/el406/communication_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/communication_tests.py similarity index 87% rename from pylabrobot/plate_washing/biotek/el406/communication_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/communication_tests.py index cca36900599..4575264512c 100644 --- a/pylabrobot/plate_washing/biotek/el406/communication_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/communication_tests.py @@ -1,7 +1,7 @@ # mypy: disable-error-code="union-attr,assignment,arg-type" """Tests for BioTek EL406 communication functionality.""" -from pylabrobot.plate_washing.biotek.el406.mock_tests import EL406TestCase, MockFTDI +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import EL406TestCase, MockFTDI class TestTestCommunication(EL406TestCase): diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/enums.py b/pylabrobot/legacy/plate_washing/biotek/el406/enums.py new file mode 100644 index 00000000000..8456c4ee576 --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/enums.py @@ -0,0 +1,91 @@ +"""EL406 enumeration types. + +This module contains all enumeration types used by the BioTek EL406 +plate washer backend. +""" + +from __future__ import annotations + +import enum + + +class EL406WasherManifold(enum.IntEnum): + """Washer manifold types.""" + + TUBE_96_DUAL = 0 + TUBE_192 = 1 + TUBE_128 = 2 + TUBE_96_SINGLE = 3 + DEEP_PIN_96 = 4 + NOT_INSTALLED = 255 + + +class EL406SyringeManifold(enum.IntEnum): + """Syringe manifold types.""" + + NOT_INSTALLED = 0 + TUBE_16 = 1 + TUBE_32_LARGE_BORE = 2 + TUBE_32_SMALL_BORE = 3 + TUBE_16_7 = 4 + TUBE_8 = 5 + PLATE_6_WELL = 6 + PLATE_12_WELL = 7 + PLATE_24_WELL = 8 + PLATE_48_WELL = 9 + + +class EL406Sensor(enum.IntEnum): + """Sensor types for the EL406.""" + + VACUUM = 0 # Vacuum sensor + WASTE = 1 # Waste container sensor + FLUID = 2 # Fluid level sensor + FLOW = 3 # Flow sensor + FILTER_VAC = 4 # Filter vacuum sensor + PLATE = 5 # Plate presence sensor + + +class EL406StepType(enum.IntEnum): + """Step types for EL406 operations.""" + + UNDEFINED = 0 + P_DISPENSE = 1 # Peristaltic pump dispense + P_PRIME = 2 # Peristaltic pump prime + P_PURGE = 3 # Peristaltic pump purge + S_DISPENSE = 4 # Syringe dispense + S_PRIME = 5 # Syringe prime + M_WASH = 6 # Manifold wash + M_ASPIRATE = 7 # Manifold aspirate + M_DISPENSE = 8 # Manifold dispense + M_PRIME = 9 # Manifold prime + M_AUTO_CLEAN = 10 # Manifold auto-clean + SHAKE_SOAK = 11 # Shake/soak + + +class EL406Motor(enum.IntEnum): + """Motor types for the EL406.""" + + CARRIER_X = 0 # X-axis plate carrier motor + CARRIER_Y = 1 # Y-axis plate carrier motor + DISP_HEAD_Z = 2 # Dispense head Z-axis motor + WASH_HEAD_Z = 3 # Wash head Z-axis motor + SYRINGE_A = 4 # Syringe pump A motor + SYRINGE_B = 5 # Syringe pump B motor + PERI_PUMP_PRIMARY = 6 # Primary peristaltic pump motor + PERI_PUMP_SECONDARY = 7 # Secondary peristaltic pump motor + LEVEL_SENSE_Y = 8 # Level sense Y-axis motor + WASH_SYRINGE = 9 # Wash syringe motor + WASH_ASP_HEAD_Z = 10 # Wash aspirate head Z-axis motor + SINGLE_WELL_Y = 11 # Single well Y-axis motor + + +class EL406MotorHomeType(enum.IntEnum): + """Motor home types for the EL406.""" + + INIT_ALL_MOTORS = 1 # Initialize all motors + INIT_PERI_PUMP = 2 # Initialize peristaltic pump + HOME_MOTOR = 3 # Home a specific motor + HOME_XYZ_MOTORS = 4 # Home all XYZ motors + VERIFY_MOTOR = 5 # Verify a specific motor position + VERIFY_XYZ_MOTORS = 6 # Verify all XYZ motor positions diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/error_codes.py b/pylabrobot/legacy/plate_washing/biotek/el406/error_codes.py new file mode 100644 index 00000000000..2cbbc39b504 --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/error_codes.py @@ -0,0 +1,247 @@ +""" +BioTek EL406 Error Codes + +This module contains error codes for the BioTek EL406 plate washer. + +The error codes provide human-readable descriptions for errors that may +occur during communication with the EL406 plate washer. +""" + +ERROR_CODES: dict[int, str] = { + 0x0175: "Error communicating with instrument software. didn't find find park opto sensor transition.", # 373 + 0x0C01: "Requested config/autocal data absent.", # 3073 + 0x0C02: "Calculated checksum didn't match checksum saved.", # 3074 + 0x0C03: "Config parameter out of range.", # 3075 + 0x1001: "Bootcode checksum error at powerup.", # 4097 + 0x1002: "Bootcode error unknown.", # 4098 + 0x1003: "Bootcode page program error.", # 4099 + 0x1004: "Bootcode block size error.", # 4100 + 0x1005: "Bootcode invalid processor signature.", # 4101 + 0x1006: "Bootcode memory exceeded.", # 4102 + 0x1007: "Bootcode invalid slave port.", # 4103 + 0x1008: "Bootcode invalid slave response.", # 4104 + 0x1009: "Bootcode invalid processor detected.", # 4105 + 0x100A: "Bootcode checksum error at powerup.", # 4106 + 0x100B: "Bootcode checksum error at powerup.", # 4107 + 0x100C: "Bootcode checksum error at powerup.", # 4108 + 0x100D: "Bootcode checksum error at powerup.", # 4109 + 0x100E: "Bootcode checksum error at powerup.", # 4110 + 0x100F: "Bootcode checksum error at powerup.", # 4111 + 0x1010: "Bootcode download checksum error.", # 4112 + 0x1250: "UI Processor internal RAM failure.", # 4688 + 0x1251: "MC Processor internal RAM failure.", # 4689 + 0x1300: "Invalid syringe", # 4864 + 0x1301: "Syringe is not connected", # 4865 + 0x1302: "Unable to initialize syringe", # 4866 + 0x1303: "Unable to initialize syringe sensor clear", # 4867 + 0x1304: "Syringe dispense volume out of calibration range", # 4868 + 0x1305: "Invalid syringe operation", # 4869 + 0x1306: "Syringe A FMEA check error", # 4870 + 0x1307: "Syringe B FMEA check error", # 4871 + 0x1355: "The Peri-pump module is not configured", # 4949 + 0x1356: "Invalid Peri-pump dispense position", # 4950 + 0x1357: "The second Peri-pump module is required", # 4951 + 0x1358: "This instrument does not support 0.5 uL Peri-pump dispense volume", # 4952 + 0x1400: "No vacuum pressure detected after turning on the vacuum pump", # 5120 + 0x1401: "The waste bottles must be emptied before continuing", # 5121 + 0x1402: "The valve to be cycle is invalid", # 5122 + 0x1403: "The magnet adapter height is out of range", # 5123 + 0x1404: "Use of the selected plate type is restricted", # 5124 + 0x1405: "Z Axis height error", # 5125 + 0x1406: "Invalid Plate type", # 5126 + 0x1407: "Invalid Step type", # 5127 + 0x1408: "Invalid plate geometry", # 5128 + 0x1409: "Invalid carrier type", # 5129 + 0x140A: "Invalid carrier specified", # 5130 + 0x140B: "Invalid carrier specified", # 5131 + 0x140C: "Invalid carrier specified", # 5132 + 0x140D: "Invalid carrier specified", # 5133 + 0x140E: "Invalid carrier specified", # 5134 + 0x140F: "Invalid carrier specified", # 5135 + 0x1410: "Incompatible hardware configuration", # 5136 + 0x1411: "Invalid carrier specified", # 5137 + 0x1412: "Plate clearance error", # 5138 + 0x1413: "AutoPrime in progress. Please wait until AutoPrime completes.", # 5139 + 0x1414: "AutoPrime is cleaning up. Please wait until AutoPrime cleanup completes.", # 5140 + 0x1415: "An AutoPrime value is out-of-range.", # 5141 + 0x1416: "Vacuum pressure incorrectly detected prior to starting the vacuum pump.", # 5142 + 0x1417: "The autocal sensor was not detected in the back of the instrument.", # 5143 + 0x1430: "Strip washer syringe FMEA check error.", # 5168 + 0x1431: "Strip washer aspirate head not installed.", # 5169 + 0x1432: "Strip washer syringe box not connected.", # 5170 + 0x1433: "Bad step type pointer passed in when finding plate heights.", # 5171 + 0x1500: "There was no buffer fluid present at the start of a manifold-based protocol or at the start of an individual step.", # 5376 + 0x1501: "There was no buffer fluid present immediately before the manifold dispense sequence.", # 5377 + 0x1502: "The buffer valve selection is invalid", # 5378 + 0x1503: "The requested volume to be dispensed through the manifold is smaller than the minimum volume that will be dispensed by the time the DC dispense pump turns on and the dispense valve is opened.", # 5379 + 0x1504: "There was no buffer fluid detected flowing through the manifold tubing during a manifold dispense/prime operation.", # 5380 + 0x1505: "There was no buffer fluid present at the end of a manifold-based protocol or at the end of an individual step.", # 5381 + 0x1506: "The requested carrier Y-axis position is out of range.", # 5382 + 0x1514: "The Ultrasonic Advantage hardware is not configured.", # 5396 + 0x1515: "The low-flow cell-wash hardware is not configured", # 5397 + 0x1516: "Vacuum pressure issue for vacuum filtration", # 5398 + 0x1517: "The software could not read the vacuum filter hardware consistently.", # 5399 + 0x1600: "Ran out of on-board storage space", # 5632 + 0x1601: "Ran out of on-board storage space for P-Dispense steps", # 5633 + 0x1602: "Ran out of on-board storage space for P-Prime steps", # 5634 + 0x1603: "Ran out of on-board storage space for P-Purge steps", # 5635 + 0x1604: "Ran out of on-board storage space for S-Dispense steps", # 5636 + 0x1605: "Ran out of on-board storage space for S-Prime steps", # 5637 + 0x1606: "Ran out of on-board storage space for W-Wash steps", # 5638 + 0x1607: "Ran out of on-board storage space for W-Aspirate steps", # 5639 + 0x1608: "Ran out of on-board storage space for W-Dispense steps", # 5640 + 0x1609: "Ran out of on-board storage space for W-Prime steps", # 5641 + 0x160A: "Ran out of on-board storage space for W-AutoClean steps", # 5642 + 0x160B: "Ran out of on-board storage space for Shake/Soak steps", # 5643 + 0x160C: "Ran out of on-board storage space for 1536 Wash steps", # 5644 + 0x160D: "Invalid Step Type encountered", # 5645 + 0x160E: "Ran out of on-board storage space for P-Purge steps", # 5646 + 0x160F: "Ran out of on-board storage space for P-Purge steps", # 5647 + 0x1610: "Protocol transfer failed.", # 5648 + 0x1700: "Level sensor not installed.", # 5888 + 0x1701: "Level sensor framing error.", # 5889 + 0x1702: "Level sensor timing error.", # 5890 + 0x1703: "Level sensor unknown command.", # 5891 + 0x1704: "Level sensor parameter error.", # 5892 + 0x1705: "Level sensor address error.", # 5893 + 0x1706: "Level sensor error detected but not classified.", # 5894 + 0x1707: "Level sensor response cmd char != request cmd char.", # 5895 + 0x1708: "Level sensor command response not long enough.", # 5896 + 0x1709: "Level sensor command response address not equal to '0'.", # 5897 + 0x170A: "Level sensor command response checksum error.", # 5898 + 0x170B: "Level sensor timeout while looking for SOF char.", # 5899 + 0x170C: "Level sensor RX error - framing error.", # 5900 + 0x170D: "Level sensor RX error in Mode parameter.", # 5901 + 0x170E: "Level sensor RX error in Format parameter.", # 5902 + 0x170F: "Level sensor RX error in Sensitivity parameter.", # 5903 + 0x1710: "Level sensor RX error in Average parameter.", # 5904 + 0x1711: "Level sensor RX error in Temp Comp parameter.", # 5905 + 0x1712: "Level sensor RX error in SDC parameter.", # 5906 + 0x1713: "Level sensor RX error in SDE parameter.", # 5907 + 0x1714: "Level sensor RX error in setting configuration.", # 5908 + 0x1715: "Level sensor error in converting a read to a level.", # 5909 + 0x1716: "7 reads did not come up with at least 3 good ones.", # 5910 + 0x1717: "Level sensor echo range error.", # 5911 + 0x1718: "Level sensor echo width error.", # 5912 + 0x1719: "7 reads did not come up with at least 3 good ones.", # 5913 + 0x171A: "Level sensor - motor axis incorrect in FindAxisCenter().", # 5914 + 0x171B: "7 reads did not come up with at least 3 good ones.", # 5915 + 0x171C: "In FindAxisCenter() initial read not > threshold.", # 5916 + 0x171D: "7 reads did not come up with at least 3 good ones.", # 5917 + 0x171E: "Level sensor - no well edge found - reached step limit.", # 5918 + 0x171F: "Level sensor - repeated FindAxisCenter() did not converge.", # 5919 + 0x1720: "Level sensor corner cal memory checksum error.", # 5920 + 0x1721: "Level sensor A1 cal memory checksum error.", # 5921 + 0x1722: "Level sensor - carrier height wrong - plate test > 30mm.", # 5922 + 0x1723: "A plate read was started but not finished successfully.", # 5923 + 0x1724: "7 reads did not come up with at least 3 good ones.", # 5924 + 0x1725: "The range of the smallest 3 reads (of 7) was > 0.5mm.", # 5925 + 0x1726: "Input to McReqLvlSnsZPosn() out of range.", # 5926 + 0x1727: "The correction factor is out of range.", # 5927 + 0x1728: "7 reads did not come up with at least 3 good ones.", # 5928 + 0x1729: "FindLsyParkPosn() could not find the park position.", # 5929 + 0x172A: "Read Plate or Read One command to MC - invalid Read Type.", # 5930 + 0x172B: "Row or column was 0 - must start at 1.", # 5931 + 0x172C: "Well test error - previous config not loaded.", # 5932 + 0x172D: "Well test error - wrong well.", # 5933 + 0x172E: "7 reads did not come up with at least 3 good ones.", # 5934 + 0x172F: "7 reads did not come up with at least 3 good ones.", # 5935 + 0x1730: "7 reads did not come up with at least 3 good ones.", # 5936 + 0x1731: "7 reads did not come up with at least 3 good ones.", # 5937 + 0x1732: "Level sensor - config memory checksum error.", # 5938 + 0x1733: "Well positions have not been calculated.", # 5939 + 0x1734: "Level sense correction factor not been calculated.", # 5940 + 0x1735: "Doing a Carrier Test - no previous Z-Axis cal data in EEPROM.", # 5941 + 0x1736: "Attempted a Z-axis wash head move with Sensor Y not at park posn.", # 5942 + 0x1737: "Plate test did not find a plate.", # 5943 + 0x1738: "Level sensor - config memory checksum error.", # 5944 + 0x1739: "MC Not all level sensor cal and config data has been loaded.", # 5945 + 0x173A: "Level sensor transmission buffer should be empty before sending a command.", # 5946 + 0x173B: "Level sensor - Z-Cal, Z=0, current to cal > +/-0.75mm.", # 5947 + 0x173C: "Level sensor - Z-Cal, Z=0, factory cal < 23mm or > 29mm.", # 5948 + 0x173D: "Level sensor - Z-Cal, Z=0, post to pre > +/-0.3mm.", # 5949 + 0x173E: "Level sensor - Z-Cal, Z=0, < 15.0mm.", # 5950 + 0x173F: "7 reads did not produce at least 6 good ones.", # 5951 + 0x6100: "The Mini-Tube plate must be used with the Mini-Tube Carrier.", # 24832 + 0x6101: "The 405 TS does not support downloading basecode from the LHC.", # 24833 + 0x6102: "The Mini-Tube plate must be used with the Mini-Tube Carrier.", # 24834 + 0x6110: "The Verify Manifold Test input parameters file was not found.", # 24848 + 0x6111: "The user data file for the Verify Manifold Test could not be read in.", # 24849 + 0x6112: "The Verify Manifold Test was stopped by user.", # 24850 + 0x6113: "The Verify Manifold Test is not supported.", # 24851 + 0x6114: "Invalid well specified.", # 24852 + 0x6115: "Invalid well specified.", # 24853 + 0x6116: "Invalid well specified.", # 24854 + 0x6117: "Invalid well specified.", # 24855 + 0x6118: "Invalid well specified.", # 24856 + 0x6119: "Invalid well specified.", # 24857 + 0x611A: "Invalid well specified.", # 24858 + 0x611B: "Invalid well specified.", # 24859 + 0x611C: "Invalid well specified.", # 24860 + 0x611D: "Invalid well specified.", # 24861 + 0x611E: "Invalid well specified.", # 24862 + 0x611F: "Invalid well specified.", # 24863 + 0x6120: "The carrier is not level.", # 24864 + 0x6121: "The test had an aspirate scan error.", # 24865 + 0x6122: "The test had an dispense scan error.", # 24866 + 0x6123: "Center of well not found where expected for Verify test plate.", # 24867 + 0x6124: "Incorrect plate installed for Verify test.", # 24868 + 0x6125: "The well volume following an aspirate indicates insufficient aspiration.", # 24869 + 0x6126: "The well volume following a dispense indicates insufficient dispense.", # 24870 + 0x6127: "Scan data could not be returned from the instrument.", # 24871 + 0x6128: "Invalid well specified.", # 24872 + 0x6129: "This Verify Manifold Test step was not performed.", # 24873 + 0x6150: "The mean Dispense Volume is out of range.", # 24912 + 0x6151: "The Dispense CV % exceeds the maximum threshold.", # 24913 + 0x6152: "The Aspirate Rate is below the minimum threshold.", # 24914 + 0x6160: "This step requires Washer components to be installed and connected.", # 24928 + 0x6161: "The Strip Washer Manifold and the Plate Type are incompatible.", # 24929 + 0x6162: "The Strip Washer does not support this Plate Type", # 24930 + 0x6165: "This Peri-pump does not support single well dispensing.", # 24933 + 0x6166: "The instrument does not support single well dispensing.", # 24934 + 0x6167: "The Syringe Manifold can only be used with 6-well plates", # 24935 + 0x6168: "The Syringe Manifold can only be used with 12-well plates", # 24936 + 0x6169: "The Syringe Manifold can only be used with 24-well plates", # 24937 + 0x6170: "The Syringe Manifold can only be used with 48-well plates", # 24944 + 0x6171: "The Cassette for single well dispensing does not support this plate type", # 24945 + 0x8100: "Error communicating with instrument software. Message not acknowledged (NAK).", # 33024 + 0x8101: "Error communicating with instrument software. Timeout while waiting for serial message data.", # 33025 + 0x8102: "Error communicating with instrument software. Instrument busy and unable to process message.", # 33026 + 0x8103: "Error communicating with instrument software. Receive buffer overflow error.", # 33027 + 0x8104: "Error communicating with instrument software. Communication checksum error.", # 33028 + 0x8105: "Error communicating with instrument software. Invalid structure type in byMsgStructure header field.", # 33029 + 0x8106: "Error communicating with instrument software. Invalid destination in byMsgDestination header field.", # 33030 + 0x8107: "Error communicating with instrument software. Message sent to instrument is not supported.", # 33031 + 0x8108: "Error communicating with instrument software. Message body size exceeds max limit.", # 33032 + 0x8109: "Error communicating with instrument software. Max number of requests currently running and cannot run the latest request.", # 33033 + 0x810A: "Error communicating with instrument software. No request running when response request issued.", # 33034 + 0x810B: "Error communicating with instrument software. Receive buffer overflow error.", # 33035 + 0x810C: "Error communicating with instrument software. Response for outstanding request not ready yet.", # 33036 + 0x810D: "Error communicating with instrument software. To communicate, the instrument must be at the Main Menu.", # 33037 + 0x810E: "Error communicating with instrument software. One or more request parameters are not valid.", # 33038 + 0x810F: "Error communicating with instrument software. Command not valid in current state.", # 33039 + 0xA100: " not available.", # 41216 + 0xA101: " not available.", # 41217 + 0xA102: " not available.", # 41218 + 0xA103: " not available.", # 41219 + 0xA104: " not available.", # 41220 + 0xA300: " power supply level error.", # 41728 + 0xA301: "+5v logic power supply level error.", # 41729 + 0xA302: "+24v system/motor power supply level error.", # 41730 + 0xA303: "Internal +42v PeriPump power supply level error.", # 41731 + 0xA304: "Internal reference voltage error.", # 41732 + 0xA305: "External +42v PeriPump power supply level error.", # 41733 +} + + +def get_error_message(code: int) -> str: + """ + Get the error message for a given error code. + + Args: + code: The error code to look up. + + Returns: + The error message, or a default message if not found. + """ + return ERROR_CODES.get(code, f"Unknown error code: 0x{code:04X} ({code})") diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/errors.py b/pylabrobot/legacy/plate_washing/biotek/el406/errors.py new file mode 100644 index 00000000000..12bbcab09dc --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/errors.py @@ -0,0 +1,47 @@ +"""EL406 exception classes. + +This module contains exception classes used by the BioTek EL406 +plate washer backend. +""" + +from __future__ import annotations + + +class EL406CommunicationError(Exception): + """Exception raised for FTDI/USB communication errors with the EL406. + + This exception is raised when low-level communication fails, such as: + - USB device disconnected + - FTDI driver errors + - Write/read failures + + Attributes: + operation: The operation that failed (e.g., "write", "read", "open"). + original_error: The underlying exception that caused this error. + """ + + def __init__( + self, + message: str, + operation: str = "", + original_error: Exception | None = None, + ) -> None: + super().__init__(message) + self.operation = operation + self.original_error = original_error + + +class EL406DeviceError(Exception): + """Exception raised when the EL406 device reports an error via the validity field. + + The device returns a non-zero validity code in the status poll response + when a step command fails (e.g., no buffer fluid, invalid syringe, hardware fault). + + Attributes: + error_code: The raw error code from the device (e.g., 0x1500). + message: Human-readable error description. + """ + + def __init__(self, error_code: int, message: str) -> None: + self.error_code = error_code + super().__init__(f"EL406 error 0x{error_code:04X}: {message}") diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/helpers.py b/pylabrobot/legacy/plate_washing/biotek/el406/helpers.py new file mode 100644 index 00000000000..cdac55fcf13 --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/helpers.py @@ -0,0 +1,104 @@ +"""EL406 plate type defaults and helper functions.""" + +from __future__ import annotations + +from pylabrobot.resources import Plate + +# Threshold for distinguishing standard-height vs low-profile plates (in mm). +# Standard microplates are ~14mm tall; PCR/flanged plates are typically <12mm. +_LOW_PROFILE_THRESHOLD_MM = 12.0 + +# Wire byte → physical defaults for each EL406 plate format. +# Keys are the raw byte values sent on the wire protocol. +_WIRE_BYTE_DEFAULTS: dict[int, dict[str, int]] = { + 0: { # 1536-well standard + "dispenser_height": 250, + "dispense_z": 94, + "aspirate_z": 42, + "rows": 32, + "cols": 48, + }, + 1: { # 384-well standard + "dispenser_height": 333, + "dispense_z": 120, + "aspirate_z": 22, + "rows": 16, + "cols": 24, + }, + 2: { # 384-well PCR (low profile) + "dispenser_height": 230, + "dispense_z": 83, + "aspirate_z": 2, + "rows": 16, + "cols": 24, + }, + 4: { # 96-well + "dispenser_height": 336, + "dispense_z": 121, + "aspirate_z": 29, + "rows": 8, + "cols": 12, + }, + 14: { # 1536-well flanged (low profile) + "dispenser_height": 196, + "dispense_z": 93, + "aspirate_z": 13, + "rows": 32, + "cols": 48, + }, +} + + +def plate_to_wire_byte(plate: Plate) -> int: + """Resolve a PLR Plate to the EL406 wire protocol byte. + + Determines the format from well count, and uses plate height (``size_z``) + to distinguish standard vs low-profile variants for 384 and 1536 plates. + + Args: + plate: A PyLabRobot Plate resource. + + Returns: + Integer byte value for the EL406 wire protocol. + + Raises: + ValueError: If the plate well count is not 96, 384, or 1536. + """ + wells = plate.num_items + if wells == 96: + return 4 + if wells == 384: + return 2 if plate.get_size_z() < _LOW_PROFILE_THRESHOLD_MM else 1 + if wells == 1536: + return 14 if plate.get_size_z() < _LOW_PROFILE_THRESHOLD_MM else 0 + raise ValueError(f"Unsupported plate well count: {wells}. EL406 supports 96, 384, or 1536.") + + +def plate_defaults(plate: Plate) -> dict[str, int]: + """Return the physical defaults dict for a plate.""" + return _WIRE_BYTE_DEFAULTS[plate_to_wire_byte(plate)] + + +def plate_max_columns(plate: Plate) -> int: + """Return the number of columns for a plate.""" + return plate.num_items_x + + +def plate_max_row_groups(plate: Plate) -> int: + """Return the number of row groups for a plate. + + 96-well: 1 row group (no row selection). + 384-well: 2 row groups. + 1536-well: 4 row groups. + """ + return {12: 1, 24: 2, 48: 4}[plate.num_items_x] + + +def plate_well_count(plate: Plate) -> int: + """Return the well count for a plate (96, 384, or 1536).""" + return plate.num_items + + +def plate_default_z(plate: Plate) -> int: + """Return the default dispenser Z height for a plate.""" + return plate_defaults(plate)["dispenser_height"] diff --git a/pylabrobot/plate_washing/biotek/el406/helpers_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/helpers_tests.py similarity index 95% rename from pylabrobot/plate_washing/biotek/el406/helpers_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/helpers_tests.py index 9649c3eb73b..b532057aed6 100644 --- a/pylabrobot/plate_washing/biotek/el406/helpers_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/helpers_tests.py @@ -2,16 +2,16 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend -from pylabrobot.plate_washing.biotek.el406.helpers import plate_to_wire_byte -from pylabrobot.plate_washing.biotek.el406.mock_tests import ( +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406.helpers import plate_to_wire_byte +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import ( PT96, PT384, PT384PCR, PT1536, PT1536F, ) -from pylabrobot.plate_washing.biotek.el406.protocol import encode_column_mask +from pylabrobot.legacy.plate_washing.biotek.el406.protocol import encode_column_mask class TestPlateToWireByte(unittest.TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/mock_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/mock_tests.py similarity index 98% rename from pylabrobot/plate_washing/biotek/el406/mock_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/mock_tests.py index 683e84e46b1..ef4557b167c 100644 --- a/pylabrobot/plate_washing/biotek/el406/mock_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/mock_tests.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend from pylabrobot.resources import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/protocol.py b/pylabrobot/legacy/plate_washing/biotek/el406/protocol.py new file mode 100644 index 00000000000..8ec638c0bfc --- /dev/null +++ b/pylabrobot/legacy/plate_washing/biotek/el406/protocol.py @@ -0,0 +1,107 @@ +"""EL406 protocol framing utilities. + +This module contains the protocol framing functions for building +properly formatted messages for the BioTek EL406 plate washer. +""" + +from __future__ import annotations + +from pylabrobot.io.binary import Writer + + +def build_framed_message(command: int, data: bytes = b"") -> bytes: + """Build a properly framed EL406 message. + + Protocol structure: + [0]: 0x01 (start marker) + [1]: 0x02 (version marker) + [2-3]: command (little-endian short) + [4]: 0x01 (constant) + [5-6]: reserved (ushort, typically 0) + [7-8]: data length (ushort, little-endian) + [9-10]: checksum (ushort, little-endian) + ... followed by data bytes + + Checksum is two's complement of sum of header bytes 0-8 + all data bytes. + + Args: + command: 16-bit command code + data: Optional data bytes + + Returns: + Complete framed message with header and checksum + """ + # Build header bytes 0-8 (checksum placeholder filled after) + header_prefix = ( + Writer() + .u8(0x01) # [0] Start marker + .u8(0x02) # [1] Version marker + .u16(command) # [2-3] Command (LE) + .u8(0x01) # [4] Constant + .u16(0x0000) # [5-6] Reserved + .u16(len(data)) # [7-8] Data length (LE) + .finish() + ) # fmt: skip + + # Checksum: two's complement of sum of header bytes 0-8 + all data bytes + checksum_sum = sum(header_prefix) + sum(data) + checksum = (0xFFFF - checksum_sum + 1) & 0xFFFF + + return header_prefix + Writer().u16(checksum).finish() + data + + +def encode_column_mask(columns: list[int] | None) -> bytes: + """Encode list of column indices to 6-byte (48-bit) column mask. + + Each bit represents one column: 0 = skip, 1 = operate on column. + + Args: + columns: List of column indices (0-47) to select, or None for all columns. + If None, returns all 1s (all columns selected). + If empty list, returns all 0s (no columns selected). + + Returns: + 6 bytes representing the 48-bit column mask in little-endian order. + + Raises: + ValueError: If any column index is out of range (not 0-47). + """ + if columns is None: + return bytes([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) + + for col in columns: + if col < 0 or col > 47: + raise ValueError(f"Column index {col} out of range. Must be 0-47.") + + mask = [0] * 6 + for col in columns: + byte_index = col // 8 + bit_index = col % 8 + mask[byte_index] |= 1 << bit_index + + return bytes(mask) + + +def columns_to_column_mask(columns: list[int] | None, plate_wells: int = 96) -> list[int] | None: + """Convert 1-indexed column numbers to 0-indexed column indices. + + Args: + columns: List of column numbers (1-based), or None for all columns. + plate_wells: Plate format (96, 384, 1536). Determines max columns. + + Returns: + List of 0-indexed column indices, or None if columns is None. + + Raises: + ValueError: If column numbers are out of range. + """ + if columns is None: + return None + + max_cols = {96: 12, 384: 24, 1536: 48}.get(plate_wells, 48) + indices = [] + for col in columns: + if col < 1 or col > max_cols: + raise ValueError(f"Column {col} out of range for {plate_wells}-well plate (1-{max_cols}).") + indices.append(col - 1) + return indices diff --git a/pylabrobot/plate_washing/biotek/el406/queries.py b/pylabrobot/legacy/plate_washing/biotek/el406/queries.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/queries.py rename to pylabrobot/legacy/plate_washing/biotek/el406/queries.py diff --git a/pylabrobot/plate_washing/biotek/el406/queries_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/queries_tests.py similarity index 99% rename from pylabrobot/plate_washing/biotek/el406/queries_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/queries_tests.py index 4891605952b..0b2cea0474d 100644 --- a/pylabrobot/plate_washing/biotek/el406/queries_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/queries_tests.py @@ -7,13 +7,13 @@ import unittest # Import the backend module -from pylabrobot.plate_washing.biotek.el406 import ( +from pylabrobot.legacy.plate_washing.biotek.el406 import ( EL406Sensor, EL406SyringeManifold, EL406WasherManifold, ExperimentalBioTekEL406Backend, ) -from pylabrobot.plate_washing.biotek.el406.mock_tests import EL406TestCase, MockFTDI +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import EL406TestCase, MockFTDI class TestEL406BackendGetWasherManifold(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/setup_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/setup_tests.py similarity index 93% rename from pylabrobot/plate_washing/biotek/el406/setup_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/setup_tests.py index e0cd1d0a758..f6fc7999051 100644 --- a/pylabrobot/plate_washing/biotek/el406/setup_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/setup_tests.py @@ -3,11 +3,11 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ( +from pylabrobot.legacy.plate_washing.biotek.el406 import ( EL406CommunicationError, ExperimentalBioTekEL406Backend, ) -from pylabrobot.plate_washing.biotek.el406.mock_tests import EL406TestCase, MockFTDI +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import EL406TestCase, MockFTDI class TestEL406BackendSetup(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/steps/__init__.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/__init__.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/steps/__init__.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps/__init__.py diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_base.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_base.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/steps/_base.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps/_base.py diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_manifold.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_manifold.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/steps/_manifold.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps/_manifold.py diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_peristaltic.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_peristaltic.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/steps/_peristaltic.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps/_peristaltic.py diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_shake.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_shake.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/steps/_shake.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps/_shake.py diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_syringe.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_syringe.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/steps/_syringe.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps/_syringe.py diff --git a/pylabrobot/plate_washing/biotek/el406/steps_aspirate_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_aspirate_tests.py similarity index 97% rename from pylabrobot/plate_washing/biotek/el406/steps_aspirate_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps_aspirate_tests.py index 22ef6d92c12..c208cee3de8 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps_aspirate_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_aspirate_tests.py @@ -8,8 +8,8 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend -from pylabrobot.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase class TestEL406BackendAspirate(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/steps_dispense_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_dispense_tests.py similarity index 98% rename from pylabrobot/plate_washing/biotek/el406/steps_dispense_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps_dispense_tests.py index 917ab1c8a79..3840d040606 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps_dispense_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_dispense_tests.py @@ -3,8 +3,8 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend -from pylabrobot.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase class TestEL406BackendDispense(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/steps_peristaltic_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_peristaltic_tests.py similarity index 99% rename from pylabrobot/plate_washing/biotek/el406/steps_peristaltic_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps_peristaltic_tests.py index 95ce8c450cb..e6ce2b2d0e9 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps_peristaltic_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_peristaltic_tests.py @@ -8,8 +8,8 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend -from pylabrobot.plate_washing.biotek.el406.mock_tests import PT96, PT1536, EL406TestCase +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import PT96, PT1536, EL406TestCase class TestEL406BackendPeristalticDispense(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/steps_prime_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_prime_tests.py similarity index 99% rename from pylabrobot/plate_washing/biotek/el406/steps_prime_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps_prime_tests.py index 14c51cc4370..054ce35673b 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps_prime_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_prime_tests.py @@ -11,8 +11,8 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend -from pylabrobot.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase class TestEL406BackendPeristalticPrime(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/steps_shake_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_shake_tests.py similarity index 97% rename from pylabrobot/plate_washing/biotek/el406/steps_shake_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps_shake_tests.py index d875586f6cc..799ad9d641c 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps_shake_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_shake_tests.py @@ -7,8 +7,8 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend -from pylabrobot.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase class TestEL406BackendShake(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/steps_wash_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_wash_tests.py similarity index 99% rename from pylabrobot/plate_washing/biotek/el406/steps_wash_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps_wash_tests.py index 641a988c1bb..e7a1410d5db 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps_wash_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_wash_tests.py @@ -3,8 +3,8 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend -from pylabrobot.plate_washing.biotek.el406.mock_tests import ( +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import ( PT96, PT384, PT384PCR, diff --git a/pylabrobot/legacy/powder_dispensing/__init__.py b/pylabrobot/legacy/powder_dispensing/__init__.py new file mode 100644 index 00000000000..472e6d490c0 --- /dev/null +++ b/pylabrobot/legacy/powder_dispensing/__init__.py @@ -0,0 +1 @@ +from .powder_dispenser import PowderDispenser diff --git a/pylabrobot/powder_dispensing/backend.py b/pylabrobot/legacy/powder_dispensing/backend.py similarity index 95% rename from pylabrobot/powder_dispensing/backend.py rename to pylabrobot/legacy/powder_dispensing/backend.py index 161bc627318..59ee16a7130 100644 --- a/pylabrobot/powder_dispensing/backend.py +++ b/pylabrobot/legacy/powder_dispensing/backend.py @@ -3,7 +3,7 @@ from abc import ABCMeta, abstractmethod from typing import List -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend from pylabrobot.resources import Powder, Resource diff --git a/pylabrobot/powder_dispensing/chatterbox.py b/pylabrobot/legacy/powder_dispensing/chatterbox.py similarity index 92% rename from pylabrobot/powder_dispensing/chatterbox.py rename to pylabrobot/legacy/powder_dispensing/chatterbox.py index 6d2d5fa5640..4b4855f90c5 100644 --- a/pylabrobot/powder_dispensing/chatterbox.py +++ b/pylabrobot/legacy/powder_dispensing/chatterbox.py @@ -1,6 +1,6 @@ from typing import List -from pylabrobot.powder_dispensing.backend import ( +from pylabrobot.legacy.powder_dispensing.backend import ( DispenseResults, PowderDispense, PowderDispenserBackend, diff --git a/pylabrobot/powder_dispensing/chemspeed/crystal_powderdose.py b/pylabrobot/legacy/powder_dispensing/chemspeed/crystal_powderdose.py similarity index 90% rename from pylabrobot/powder_dispensing/chemspeed/crystal_powderdose.py rename to pylabrobot/legacy/powder_dispensing/chemspeed/crystal_powderdose.py index 1f2b6a22159..02abed0e6c4 100644 --- a/pylabrobot/powder_dispensing/chemspeed/crystal_powderdose.py +++ b/pylabrobot/legacy/powder_dispensing/chemspeed/crystal_powderdose.py @@ -1,4 +1,4 @@ -from pylabrobot.powder_dispensing.backend import ( +from pylabrobot.legacy.powder_dispensing.backend import ( PowderDispenserBackend, ) diff --git a/pylabrobot/powder_dispensing/powder_dispenser.py b/pylabrobot/legacy/powder_dispensing/powder_dispenser.py similarity index 96% rename from pylabrobot/powder_dispensing/powder_dispenser.py rename to pylabrobot/legacy/powder_dispensing/powder_dispenser.py index a2456bf99de..c48e4af7ebb 100644 --- a/pylabrobot/powder_dispensing/powder_dispenser.py +++ b/pylabrobot/legacy/powder_dispensing/powder_dispenser.py @@ -1,6 +1,6 @@ from typing import Any, Dict, List, Sequence, Union, cast -from pylabrobot.machines.machine import Machine, need_setup_finished +from pylabrobot.legacy.machines.machine import Machine, need_setup_finished from pylabrobot.resources import Powder, Resource from .backend import PowderDispense, PowderDispenserBackend diff --git a/pylabrobot/powder_dispensing/powder_dispenser_tests.py b/pylabrobot/legacy/powder_dispensing/powder_dispenser_tests.py similarity index 95% rename from pylabrobot/powder_dispensing/powder_dispenser_tests.py rename to pylabrobot/legacy/powder_dispensing/powder_dispenser_tests.py index e5258eaa81d..8ee880d206d 100644 --- a/pylabrobot/powder_dispensing/powder_dispenser_tests.py +++ b/pylabrobot/legacy/powder_dispensing/powder_dispenser_tests.py @@ -2,12 +2,12 @@ from typing import List from unittest.mock import AsyncMock -from pylabrobot.powder_dispensing.backend import ( +from pylabrobot.legacy.powder_dispensing.backend import ( DispenseResults, PowderDispense, PowderDispenserBackend, ) -from pylabrobot.powder_dispensing.powder_dispenser import ( +from pylabrobot.legacy.powder_dispensing.powder_dispenser import ( PowderDispenser, ) from pylabrobot.resources import Powder, cor_96_wellplate_360uL_Fb diff --git a/pylabrobot/legacy/pumps/__init__.py b/pylabrobot/legacy/pumps/__init__.py new file mode 100644 index 00000000000..6a4a86f00f4 --- /dev/null +++ b/pylabrobot/legacy/pumps/__init__.py @@ -0,0 +1,6 @@ +from .agrowpumps import AgrowPumpArray +from .calibration import PumpCalibration +from .cole_parmer import MasterflexBackend +from .errors import NotCalibratedError +from .pump import Pump +from .pumparray import PumpArray diff --git a/pylabrobot/pumps/agrowpumps/__init__.py b/pylabrobot/legacy/pumps/agrowpumps/__init__.py similarity index 100% rename from pylabrobot/pumps/agrowpumps/__init__.py rename to pylabrobot/legacy/pumps/agrowpumps/__init__.py diff --git a/pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py similarity index 99% rename from pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py rename to pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py index 1ba4da80908..605d835a542 100644 --- a/pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py +++ b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py @@ -12,7 +12,7 @@ AsyncModbusSerialClient = None # type: ignore _MODBUS_IMPORT_ERROR = e -from pylabrobot.pumps.backend import PumpArrayBackend +from pylabrobot.legacy.pumps.backend import PumpArrayBackend logger = logging.getLogger("pylabrobot") diff --git a/pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py similarity index 96% rename from pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py rename to pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py index b9d6047a0e4..4a46ba87987 100644 --- a/pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py +++ b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py @@ -7,8 +7,8 @@ from pymodbus.client import AsyncModbusSerialClient # type: ignore -from pylabrobot.pumps import PumpArray -from pylabrobot.pumps.agrowpumps import AgrowPumpArrayBackend +from pylabrobot.legacy.pumps import PumpArray +from pylabrobot.legacy.pumps.agrowpumps import AgrowPumpArrayBackend class SimulatedModbusClient(AsyncModbusSerialClient): diff --git a/pylabrobot/pumps/backend.py b/pylabrobot/legacy/pumps/backend.py similarity index 96% rename from pylabrobot/pumps/backend.py rename to pylabrobot/legacy/pumps/backend.py index 3ae2a3a151d..50a3b1a8ce8 100644 --- a/pylabrobot/pumps/backend.py +++ b/pylabrobot/legacy/pumps/backend.py @@ -1,7 +1,7 @@ from abc import ABCMeta, abstractmethod from typing import List -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class PumpBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/pumps/calibration.py b/pylabrobot/legacy/pumps/calibration.py similarity index 100% rename from pylabrobot/pumps/calibration.py rename to pylabrobot/legacy/pumps/calibration.py diff --git a/pylabrobot/pumps/calibration_tests.py b/pylabrobot/legacy/pumps/calibration_tests.py similarity index 98% rename from pylabrobot/pumps/calibration_tests.py rename to pylabrobot/legacy/pumps/calibration_tests.py index 96294937baf..2112f9d6dfc 100644 --- a/pylabrobot/pumps/calibration_tests.py +++ b/pylabrobot/legacy/pumps/calibration_tests.py @@ -2,7 +2,7 @@ import unittest import pylabrobot -from pylabrobot.pumps.calibration import PumpCalibration +from pylabrobot.legacy.pumps.calibration import PumpCalibration plr_directory = os.path.join(pylabrobot.__path__[0], "testing", "test_data") diff --git a/pylabrobot/pumps/chatterbox.py b/pylabrobot/legacy/pumps/chatterbox.py similarity index 94% rename from pylabrobot/pumps/chatterbox.py rename to pylabrobot/legacy/pumps/chatterbox.py index 793792460d2..567ce2f4efc 100644 --- a/pylabrobot/pumps/chatterbox.py +++ b/pylabrobot/legacy/pumps/chatterbox.py @@ -1,6 +1,6 @@ from typing import List -from pylabrobot.pumps.backend import PumpArrayBackend, PumpBackend +from pylabrobot.legacy.pumps.backend import PumpArrayBackend, PumpBackend class PumpChatterboxBackend(PumpBackend): diff --git a/pylabrobot/pumps/cole_parmer/__init__.py b/pylabrobot/legacy/pumps/cole_parmer/__init__.py similarity index 100% rename from pylabrobot/pumps/cole_parmer/__init__.py rename to pylabrobot/legacy/pumps/cole_parmer/__init__.py diff --git a/pylabrobot/pumps/cole_parmer/masterflex_backend.py b/pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py similarity index 97% rename from pylabrobot/pumps/cole_parmer/masterflex_backend.py rename to pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py index 41cdf5b24a0..9612f02a024 100644 --- a/pylabrobot/pumps/cole_parmer/masterflex_backend.py +++ b/pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py @@ -7,7 +7,7 @@ _SERIAL_IMPORT_ERROR = e from pylabrobot.io.serial import Serial -from pylabrobot.pumps.backend import PumpBackend +from pylabrobot.legacy.pumps.backend import PumpBackend class MasterflexBackend(PumpBackend): diff --git a/pylabrobot/pumps/errors.py b/pylabrobot/legacy/pumps/errors.py similarity index 100% rename from pylabrobot/pumps/errors.py rename to pylabrobot/legacy/pumps/errors.py diff --git a/pylabrobot/pumps/pump.py b/pylabrobot/legacy/pumps/pump.py similarity index 98% rename from pylabrobot/pumps/pump.py rename to pylabrobot/legacy/pumps/pump.py index 9d92f7257ba..5b9d3b31670 100644 --- a/pylabrobot/pumps/pump.py +++ b/pylabrobot/legacy/pumps/pump.py @@ -1,7 +1,7 @@ import asyncio from typing import Optional, Union -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.machines.machine import Machine from .backend import PumpBackend from .calibration import PumpCalibration diff --git a/pylabrobot/pumps/pump_tests.py b/pylabrobot/legacy/pumps/pump_tests.py similarity index 94% rename from pylabrobot/pumps/pump_tests.py rename to pylabrobot/legacy/pumps/pump_tests.py index cc8c3ab7101..71200e0c58d 100644 --- a/pylabrobot/pumps/pump_tests.py +++ b/pylabrobot/legacy/pumps/pump_tests.py @@ -1,11 +1,11 @@ import unittest from unittest.mock import AsyncMock, Mock -from pylabrobot.pumps import PumpArray -from pylabrobot.pumps.backend import PumpArrayBackend, PumpBackend -from pylabrobot.pumps.calibration import PumpCalibration -from pylabrobot.pumps.errors import NotCalibratedError -from pylabrobot.pumps.pump import Pump +from pylabrobot.legacy.pumps import PumpArray +from pylabrobot.legacy.pumps.backend import PumpArrayBackend, PumpBackend +from pylabrobot.legacy.pumps.calibration import PumpCalibration +from pylabrobot.legacy.pumps.errors import NotCalibratedError +from pylabrobot.legacy.pumps.pump import Pump class TestPump(unittest.IsolatedAsyncioTestCase): diff --git a/pylabrobot/pumps/pumparray.py b/pylabrobot/legacy/pumps/pumparray.py similarity index 96% rename from pylabrobot/pumps/pumparray.py rename to pylabrobot/legacy/pumps/pumparray.py index daedb626a46..9d7ca1a5cec 100644 --- a/pylabrobot/pumps/pumparray.py +++ b/pylabrobot/legacy/pumps/pumparray.py @@ -1,10 +1,10 @@ import asyncio from typing import List, Optional, Union -from pylabrobot.machines.machine import Machine -from pylabrobot.pumps.backend import PumpArrayBackend -from pylabrobot.pumps.calibration import PumpCalibration -from pylabrobot.pumps.errors import NotCalibratedError +from pylabrobot.legacy.machines.machine import Machine +from pylabrobot.legacy.pumps.backend import PumpArrayBackend +from pylabrobot.legacy.pumps.calibration import PumpCalibration +from pylabrobot.legacy.pumps.errors import NotCalibratedError class PumpArray(Machine): diff --git a/pylabrobot/legacy/scales/__init__.py b/pylabrobot/legacy/scales/__init__.py new file mode 100644 index 00000000000..264143d660c --- /dev/null +++ b/pylabrobot/legacy/scales/__init__.py @@ -0,0 +1,7 @@ +from pylabrobot.legacy.scales.chatterbox import ScaleChatterboxBackend +from pylabrobot.legacy.scales.mettler_toledo_backend import ( + MettlerToledoWXS205SDU, + MettlerToledoWXS205SDUBackend, +) +from pylabrobot.legacy.scales.scale import Scale +from pylabrobot.legacy.scales.scale_backend import ScaleBackend diff --git a/pylabrobot/scales/chatterbox.py b/pylabrobot/legacy/scales/chatterbox.py similarity index 89% rename from pylabrobot/scales/chatterbox.py rename to pylabrobot/legacy/scales/chatterbox.py index 9ffb5a68e05..85b35795af3 100644 --- a/pylabrobot/scales/chatterbox.py +++ b/pylabrobot/legacy/scales/chatterbox.py @@ -1,4 +1,4 @@ -from pylabrobot.scales.scale_backend import ScaleBackend +from pylabrobot.legacy.scales.scale_backend import ScaleBackend class ScaleChatterboxBackend(ScaleBackend): diff --git a/pylabrobot/scales/mettler_toledo_backend.py b/pylabrobot/legacy/scales/mettler_toledo_backend.py similarity index 99% rename from pylabrobot/scales/mettler_toledo_backend.py rename to pylabrobot/legacy/scales/mettler_toledo_backend.py index ae73eb114df..9dd10602ff6 100644 --- a/pylabrobot/scales/mettler_toledo_backend.py +++ b/pylabrobot/legacy/scales/mettler_toledo_backend.py @@ -7,7 +7,7 @@ from typing import List, Literal, Optional, Union from pylabrobot.io.serial import Serial -from pylabrobot.scales.scale_backend import ScaleBackend +from pylabrobot.legacy.scales.scale_backend import ScaleBackend logger = logging.getLogger("pylabrobot") diff --git a/pylabrobot/scales/scale.py b/pylabrobot/legacy/scales/scale.py similarity index 95% rename from pylabrobot/scales/scale.py rename to pylabrobot/legacy/scales/scale.py index 977c28d1ecf..4bc6609c099 100644 --- a/pylabrobot/scales/scale.py +++ b/pylabrobot/legacy/scales/scale.py @@ -3,9 +3,9 @@ import warnings from typing import Optional -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.machines.machine import Machine +from pylabrobot.legacy.scales.scale_backend import ScaleBackend from pylabrobot.resources import Resource, Rotation -from pylabrobot.scales.scale_backend import ScaleBackend class Scale(Resource, Machine): diff --git a/pylabrobot/scales/scale_backend.py b/pylabrobot/legacy/scales/scale_backend.py similarity index 91% rename from pylabrobot/scales/scale_backend.py rename to pylabrobot/legacy/scales/scale_backend.py index 85894aea7c7..447bf6254f8 100644 --- a/pylabrobot/scales/scale_backend.py +++ b/pylabrobot/legacy/scales/scale_backend.py @@ -1,6 +1,6 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class ScaleBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/legacy/sealing/__init__.py b/pylabrobot/legacy/sealing/__init__.py new file mode 100644 index 00000000000..1e9b56691ad --- /dev/null +++ b/pylabrobot/legacy/sealing/__init__.py @@ -0,0 +1,3 @@ +from .a4s import a4s +from .a4s_backend import A4SBackend +from .sealer import Sealer diff --git a/pylabrobot/sealing/a4s.py b/pylabrobot/legacy/sealing/a4s.py similarity index 65% rename from pylabrobot/sealing/a4s.py rename to pylabrobot/legacy/sealing/a4s.py index ac34908c187..270cc0c5b65 100644 --- a/pylabrobot/sealing/a4s.py +++ b/pylabrobot/legacy/sealing/a4s.py @@ -1,5 +1,5 @@ -from pylabrobot.sealing.a4s_backend import A4SBackend -from pylabrobot.sealing.sealer import Sealer +from pylabrobot.legacy.sealing.a4s_backend import A4SBackend +from pylabrobot.legacy.sealing.sealer import Sealer def a4s(port: str) -> Sealer: diff --git a/pylabrobot/sealing/a4s_backend.py b/pylabrobot/legacy/sealing/a4s_backend.py similarity index 99% rename from pylabrobot/sealing/a4s_backend.py rename to pylabrobot/legacy/sealing/a4s_backend.py index 5c07cb4a333..6fe8720decf 100644 --- a/pylabrobot/sealing/a4s_backend.py +++ b/pylabrobot/legacy/sealing/a4s_backend.py @@ -13,7 +13,7 @@ _SERIAL_IMPORT_ERROR = e from pylabrobot.io.serial import Serial -from pylabrobot.sealing.backend import SealerBackend +from pylabrobot.legacy.sealing.backend import SealerBackend class A4SBackend(SealerBackend): diff --git a/pylabrobot/sealing/backend.py b/pylabrobot/legacy/sealing/backend.py similarity index 90% rename from pylabrobot/sealing/backend.py rename to pylabrobot/legacy/sealing/backend.py index 0e9e8c01b45..d4addfac09e 100644 --- a/pylabrobot/sealing/backend.py +++ b/pylabrobot/legacy/sealing/backend.py @@ -1,6 +1,6 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class SealerBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/sealing/sealer.py b/pylabrobot/legacy/sealing/sealer.py similarity index 94% rename from pylabrobot/sealing/sealer.py rename to pylabrobot/legacy/sealing/sealer.py index 3b3c027ac8e..eb4e652e0f8 100644 --- a/pylabrobot/sealing/sealer.py +++ b/pylabrobot/legacy/sealing/sealer.py @@ -1,4 +1,4 @@ -from pylabrobot.machines import Machine +from pylabrobot.legacy.machines import Machine from .backend import SealerBackend diff --git a/pylabrobot/legacy/shaking/__init__.py b/pylabrobot/legacy/shaking/__init__.py new file mode 100644 index 00000000000..f17a298d2d8 --- /dev/null +++ b/pylabrobot/legacy/shaking/__init__.py @@ -0,0 +1,3 @@ +from .backend import ShakerBackend +from .chatterbox import ShakerChatterboxBackend +from .shaker import Shaker diff --git a/pylabrobot/shaking/backend.py b/pylabrobot/legacy/shaking/backend.py similarity index 91% rename from pylabrobot/shaking/backend.py rename to pylabrobot/legacy/shaking/backend.py index 23471c6c87b..6556260f35c 100644 --- a/pylabrobot/shaking/backend.py +++ b/pylabrobot/legacy/shaking/backend.py @@ -1,6 +1,6 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class ShakerBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/shaking/chatterbox.py b/pylabrobot/legacy/shaking/chatterbox.py similarity index 91% rename from pylabrobot/shaking/chatterbox.py rename to pylabrobot/legacy/shaking/chatterbox.py index 8fcfc2933f7..c188aa2fba2 100644 --- a/pylabrobot/shaking/chatterbox.py +++ b/pylabrobot/legacy/shaking/chatterbox.py @@ -1,4 +1,4 @@ -from pylabrobot.shaking import ShakerBackend +from pylabrobot.legacy.shaking import ShakerBackend class ShakerChatterboxBackend(ShakerBackend): diff --git a/pylabrobot/shaking/shaker.py b/pylabrobot/legacy/shaking/shaker.py similarity index 97% rename from pylabrobot/shaking/shaker.py rename to pylabrobot/legacy/shaking/shaker.py index a503279e5d4..78ee93fca82 100644 --- a/pylabrobot/shaking/shaker.py +++ b/pylabrobot/legacy/shaking/shaker.py @@ -1,7 +1,7 @@ import asyncio from typing import Optional -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.machines.machine import Machine from pylabrobot.resources import Coordinate, ResourceHolder from .backend import ShakerBackend diff --git a/pylabrobot/shaking/shaker_tests.py b/pylabrobot/legacy/shaking/shaker_tests.py similarity index 86% rename from pylabrobot/shaking/shaker_tests.py rename to pylabrobot/legacy/shaking/shaker_tests.py index acea10a9676..4a1f391a4ca 100644 --- a/pylabrobot/shaking/shaker_tests.py +++ b/pylabrobot/legacy/shaking/shaker_tests.py @@ -1,7 +1,7 @@ import unittest +from pylabrobot.legacy.shaking import Shaker, ShakerChatterboxBackend from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.shaking import Shaker, ShakerChatterboxBackend class ShakerTests(unittest.TestCase): diff --git a/pylabrobot/legacy/storage/__init__.py b/pylabrobot/legacy/storage/__init__.py new file mode 100644 index 00000000000..3ccfc9cd4de --- /dev/null +++ b/pylabrobot/legacy/storage/__init__.py @@ -0,0 +1,6 @@ +from .backend import IncubatorBackend +from .chatterbox import IncubatorChatterboxBackend +from .cytomat import CytomatBackend +from .incubator import Incubator +from .inheco.scila import SCILABackend +from .liconic import ExperimentalLiconicBackend diff --git a/pylabrobot/storage/backend.py b/pylabrobot/legacy/storage/backend.py similarity index 95% rename from pylabrobot/storage/backend.py rename to pylabrobot/legacy/storage/backend.py index 82fb094dc62..7f9043bad78 100644 --- a/pylabrobot/storage/backend.py +++ b/pylabrobot/legacy/storage/backend.py @@ -1,7 +1,7 @@ from abc import ABCMeta, abstractmethod from typing import List, Optional -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend from pylabrobot.resources import Plate, PlateCarrier, PlateHolder diff --git a/pylabrobot/storage/chatterbox.py b/pylabrobot/legacy/storage/chatterbox.py similarity index 94% rename from pylabrobot/storage/chatterbox.py rename to pylabrobot/legacy/storage/chatterbox.py index 89115098f00..2280f7cbb8a 100644 --- a/pylabrobot/storage/chatterbox.py +++ b/pylabrobot/legacy/storage/chatterbox.py @@ -1,6 +1,6 @@ +from pylabrobot.legacy.storage.backend import IncubatorBackend from pylabrobot.resources.carrier import PlateHolder from pylabrobot.resources.plate import Plate -from pylabrobot.storage.backend import IncubatorBackend class IncubatorChatterboxBackend(IncubatorBackend): diff --git a/pylabrobot/storage/cytomat/__init__.py b/pylabrobot/legacy/storage/cytomat/__init__.py similarity index 100% rename from pylabrobot/storage/cytomat/__init__.py rename to pylabrobot/legacy/storage/cytomat/__init__.py diff --git a/pylabrobot/storage/cytomat/constants.py b/pylabrobot/legacy/storage/cytomat/constants.py similarity index 100% rename from pylabrobot/storage/cytomat/constants.py rename to pylabrobot/legacy/storage/cytomat/constants.py diff --git a/pylabrobot/storage/cytomat/cytomat.py b/pylabrobot/legacy/storage/cytomat/cytomat.py similarity index 98% rename from pylabrobot/storage/cytomat/cytomat.py rename to pylabrobot/legacy/storage/cytomat/cytomat.py index ca9227fe003..bd0df56ce19 100644 --- a/pylabrobot/storage/cytomat/cytomat.py +++ b/pylabrobot/legacy/storage/cytomat/cytomat.py @@ -13,9 +13,8 @@ _SERIAL_IMPORT_ERROR = e from pylabrobot.io.serial import Serial -from pylabrobot.resources import Plate, PlateCarrier, PlateHolder -from pylabrobot.storage.backend import IncubatorBackend -from pylabrobot.storage.cytomat.constants import ( +from pylabrobot.legacy.storage.backend import IncubatorBackend +from pylabrobot.legacy.storage.cytomat.constants import ( ActionRegister, ActionType, CytomatActionResponse, @@ -28,24 +27,25 @@ SwapStationPosition, WarningRegister, ) -from pylabrobot.storage.cytomat.errors import ( +from pylabrobot.legacy.storage.cytomat.errors import ( CytomatBusyError, CytomatCommandUnknownError, CytomatTelegramStructureError, error_map, error_register_map, ) -from pylabrobot.storage.cytomat.schemas import ( +from pylabrobot.legacy.storage.cytomat.schemas import ( ActionRegisterState, OverviewRegisterState, SensorStates, SwapStationState, ) -from pylabrobot.storage.cytomat.utils import ( +from pylabrobot.legacy.storage.cytomat.utils import ( hex_to_base_twelve, hex_to_binary, validate_storage_location_number, ) +from pylabrobot.resources import Plate, PlateCarrier, PlateHolder logger = logging.getLogger(__name__) diff --git a/pylabrobot/storage/cytomat/errors.py b/pylabrobot/legacy/storage/cytomat/errors.py similarity index 98% rename from pylabrobot/storage/cytomat/errors.py rename to pylabrobot/legacy/storage/cytomat/errors.py index 2f493349a6a..ebf5c438ad9 100644 --- a/pylabrobot/storage/cytomat/errors.py +++ b/pylabrobot/legacy/storage/cytomat/errors.py @@ -1,6 +1,6 @@ from typing import Dict -from pylabrobot.storage.cytomat.constants import ErrorRegister +from pylabrobot.legacy.storage.cytomat.constants import ErrorRegister class CytomatBusyError(Exception): diff --git a/pylabrobot/storage/cytomat/heraeus_cytomat_backend.py b/pylabrobot/legacy/storage/cytomat/heraeus_cytomat_backend.py similarity index 99% rename from pylabrobot/storage/cytomat/heraeus_cytomat_backend.py rename to pylabrobot/legacy/storage/cytomat/heraeus_cytomat_backend.py index a991d55fc59..a4d87614586 100644 --- a/pylabrobot/storage/cytomat/heraeus_cytomat_backend.py +++ b/pylabrobot/legacy/storage/cytomat/heraeus_cytomat_backend.py @@ -13,9 +13,9 @@ _SERIAL_IMPORT_ERROR = e from pylabrobot.io.serial import Serial +from pylabrobot.legacy.storage.backend import IncubatorBackend from pylabrobot.resources import Plate, PlateHolder from pylabrobot.resources.carrier import PlateCarrier -from pylabrobot.storage.backend import IncubatorBackend logger = logging.getLogger(__name__) diff --git a/pylabrobot/storage/cytomat/racks.py b/pylabrobot/legacy/storage/cytomat/racks.py similarity index 100% rename from pylabrobot/storage/cytomat/racks.py rename to pylabrobot/legacy/storage/cytomat/racks.py diff --git a/pylabrobot/storage/cytomat/schemas.py b/pylabrobot/legacy/storage/cytomat/schemas.py similarity index 92% rename from pylabrobot/storage/cytomat/schemas.py rename to pylabrobot/legacy/storage/cytomat/schemas.py index b32f85c34ac..c58a31b0afd 100644 --- a/pylabrobot/storage/cytomat/schemas.py +++ b/pylabrobot/legacy/storage/cytomat/schemas.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from pylabrobot.storage.cytomat.constants import ( +from pylabrobot.legacy.storage.cytomat.constants import ( ActionRegister, ActionType, LoadStatusAtProcessor, @@ -8,7 +8,7 @@ OverviewRegister, SwapStationPosition, ) -from pylabrobot.storage.cytomat.utils import hex_to_binary +from pylabrobot.legacy.storage.cytomat.utils import hex_to_binary @dataclass(frozen=True) diff --git a/pylabrobot/storage/cytomat/utils.py b/pylabrobot/legacy/storage/cytomat/utils.py similarity index 100% rename from pylabrobot/storage/cytomat/utils.py rename to pylabrobot/legacy/storage/cytomat/utils.py diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/legacy/storage/incubator.py similarity index 97% rename from pylabrobot/storage/incubator.py rename to pylabrobot/legacy/storage/incubator.py index 4a4d6d5fe64..416d319223e 100644 --- a/pylabrobot/storage/incubator.py +++ b/pylabrobot/legacy/storage/incubator.py @@ -1,7 +1,7 @@ import random from typing import List, Literal, Optional, Union, cast -from pylabrobot.machines import Machine +from pylabrobot.legacy.machines import Machine from pylabrobot.resources import ( Coordinate, Plate, @@ -205,7 +205,7 @@ def deserialize(cls, data: dict, allow_marshal: bool = False): size_z=data["size_z"], racks=[PlateCarrier.deserialize(rack) for rack in data["racks"]], loading_tray_location=cast(Coordinate, deserialize(data["loading_tray_location"])), - rotation=Rotation.deserialize(data["rotation"]), - category=data["category"], - model=data["model"], + rotation=cast(Optional[Rotation], deserialize(data.get("rotation"))), + category=data.get("category"), + model=data.get("model"), ) diff --git a/pylabrobot/storage/incubator_tests.py b/pylabrobot/legacy/storage/incubator_tests.py similarity index 86% rename from pylabrobot/storage/incubator_tests.py rename to pylabrobot/legacy/storage/incubator_tests.py index eee9b4873d5..7a7ae988d01 100644 --- a/pylabrobot/storage/incubator_tests.py +++ b/pylabrobot/legacy/storage/incubator_tests.py @@ -1,7 +1,7 @@ import unittest +from pylabrobot.legacy.storage import Incubator, IncubatorChatterboxBackend from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.storage import Incubator, IncubatorChatterboxBackend class IncubatorTests(unittest.TestCase): diff --git a/pylabrobot/storage/inheco/__init__.py b/pylabrobot/legacy/storage/inheco/__init__.py similarity index 65% rename from pylabrobot/storage/inheco/__init__.py rename to pylabrobot/legacy/storage/inheco/__init__.py index 93a8f4ebbae..23c91804c7e 100644 --- a/pylabrobot/storage/inheco/__init__.py +++ b/pylabrobot/legacy/storage/inheco/__init__.py @@ -1,4 +1,4 @@ -"""A hybrid between pylabrobot.shaking and pylabrobot.temperature_controlling""" +"""A hybrid between pylabrobot.legacy.shaking and pylabrobot.legacy.temperature_controlling""" from .incubator_shaker import IncubatorShakerStack from .incubator_shaker_backend import InhecoIncubatorShakerStackBackend, InhecoIncubatorShakerUnit diff --git a/pylabrobot/storage/inheco/incubator_shaker.py b/pylabrobot/legacy/storage/inheco/incubator_shaker.py similarity index 99% rename from pylabrobot/storage/inheco/incubator_shaker.py rename to pylabrobot/legacy/storage/inheco/incubator_shaker.py index 4b6c0f0d389..f806f491355 100644 --- a/pylabrobot/storage/inheco/incubator_shaker.py +++ b/pylabrobot/legacy/storage/inheco/incubator_shaker.py @@ -1,6 +1,6 @@ from typing import Dict -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.machines.machine import Machine from pylabrobot.resources import Coordinate, Resource, ResourceHolder from .incubator_shaker_backend import InhecoIncubatorShakerStackBackend, InhecoIncubatorShakerUnit diff --git a/pylabrobot/storage/inheco/incubator_shaker_backend.py b/pylabrobot/legacy/storage/inheco/incubator_shaker_backend.py similarity index 99% rename from pylabrobot/storage/inheco/incubator_shaker_backend.py rename to pylabrobot/legacy/storage/inheco/incubator_shaker_backend.py index 2d4a7ace352..7e2161d1413 100644 --- a/pylabrobot/storage/inheco/incubator_shaker_backend.py +++ b/pylabrobot/legacy/storage/inheco/incubator_shaker_backend.py @@ -21,7 +21,7 @@ from typing import Awaitable, Callable, Dict, List, Literal, Optional, TypeVar, cast from pylabrobot.io.serial import Serial -from pylabrobot.machines.machine import MachineBackend +from pylabrobot.legacy.machines.machine import MachineBackend if sys.version_info < (3, 10): from typing_extensions import Concatenate, ParamSpec diff --git a/pylabrobot/storage/inheco/scila/__init__.py b/pylabrobot/legacy/storage/inheco/scila/__init__.py similarity index 100% rename from pylabrobot/storage/inheco/scila/__init__.py rename to pylabrobot/legacy/storage/inheco/scila/__init__.py diff --git a/pylabrobot/storage/inheco/scila/inheco_sila_interface.py b/pylabrobot/legacy/storage/inheco/scila/inheco_sila_interface.py similarity index 99% rename from pylabrobot/storage/inheco/scila/inheco_sila_interface.py rename to pylabrobot/legacy/storage/inheco/scila/inheco_sila_interface.py index 86388ae337c..103919f73ca 100644 --- a/pylabrobot/storage/inheco/scila/inheco_sila_interface.py +++ b/pylabrobot/legacy/storage/inheco/scila/inheco_sila_interface.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from typing import Any, Optional, Tuple -from pylabrobot.storage.inheco.scila.soap import ( +from pylabrobot.legacy.storage.inheco.scila.soap import ( XSI, _localname, soap_body_payload, diff --git a/pylabrobot/storage/inheco/scila/scila_backend.py b/pylabrobot/legacy/storage/inheco/scila/scila_backend.py similarity index 97% rename from pylabrobot/storage/inheco/scila/scila_backend.py rename to pylabrobot/legacy/storage/inheco/scila/scila_backend.py index 53b94c07c02..ee38e97002a 100644 --- a/pylabrobot/storage/inheco/scila/scila_backend.py +++ b/pylabrobot/legacy/storage/inheco/scila/scila_backend.py @@ -1,8 +1,8 @@ import xml.etree.ElementTree as ET from typing import Any, Dict, Literal, Optional -from pylabrobot.machines.backend import MachineBackend -from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface +from pylabrobot.legacy.machines.backend import MachineBackend +from pylabrobot.legacy.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface def _parse_scalar(text: Optional[str], tag: str) -> object: diff --git a/pylabrobot/storage/inheco/scila/scila_backend_tests.py b/pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py similarity index 97% rename from pylabrobot/storage/inheco/scila/scila_backend_tests.py rename to pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py index 63120a0384d..15c26258fe1 100644 --- a/pylabrobot/storage/inheco/scila/scila_backend_tests.py +++ b/pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py @@ -2,13 +2,13 @@ import xml.etree.ElementTree as ET from unittest.mock import AsyncMock, patch -from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface -from pylabrobot.storage.inheco.scila.scila_backend import SCILABackend +from pylabrobot.legacy.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface +from pylabrobot.legacy.storage.inheco.scila.scila_backend import SCILABackend class TestSCILABackend(unittest.IsolatedAsyncioTestCase): def setUp(self): - self.patcher = patch("pylabrobot.storage.inheco.scila.scila_backend.InhecoSiLAInterface") + self.patcher = patch("pylabrobot.legacy.storage.inheco.scila.scila_backend.InhecoSiLAInterface") self.MockInhecoSiLAInterface = self.patcher.start() self.mock_sila_interface = AsyncMock(spec=InhecoSiLAInterface) self.mock_sila_interface.bound_port = 80 diff --git a/pylabrobot/legacy/storage/inheco/scila/soap.py b/pylabrobot/legacy/storage/inheco/scila/soap.py new file mode 100644 index 00000000000..4517fade635 --- /dev/null +++ b/pylabrobot/legacy/storage/inheco/scila/soap.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import datetime as _dt +import re as _re +import xml.etree.ElementTree as ET +from typing import Any, Dict, List, Mapping, Optional, Union + +SOAP_ENV = "http://schemas.xmlsoap.org/soap/envelope/" +XSI = "http://www.w3.org/2001/XMLSchema-instance" + +ET.register_namespace("s", SOAP_ENV) +ET.register_namespace("xsi", XSI) + + +# --------- scalar parsing/formatting --------- + +_INT_RE = _re.compile(r"^-?\d+$") +_FLOAT_RE = _re.compile(r"^-?\d+\.\d+$") +# ISO-8601 duration subset: PnDTnHnMnS (supports fractional seconds) +_DUR_RE = _re.compile( + r"^P" + r"(?:(?P\d+)D)?" + r"(?:T" + r"(?:(?P\d+)H)?" + r"(?:(?P\d+)M)?" + r"(?:(?P\d+(?:\.\d+)?)S)?" + r")?$" +) + +# Basic ISO datetime with optional fractional seconds and optional Z +_DT_RE = _re.compile( + r"^(?P\d{4})-(?P\d{2})-(?P\d{2})" + r"T(?P\d{2}):(?P\d{2}):(?P\d{2})" + r"(?:\.(?P\d+))?" + r"(?PZ)?$" +) + + +def _parse_duration_iso8601(s: str) -> Optional[_dt.timedelta]: + m = _DUR_RE.match(s) + if not m: + return None + days = int(m.group("days") or 0) + hours = int(m.group("hours") or 0) + minutes = int(m.group("minutes") or 0) + sec_str = m.group("seconds") or "0" + seconds = float(sec_str) if sec_str else 0.0 + return _dt.timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds) + + +def _format_duration_iso8601(td: _dt.timedelta) -> str: + total_seconds = td.total_seconds() + if total_seconds < 0: + raise ValueError("negative timedeltas not supported for ISO-8601 formatting here") + days = int(total_seconds // 86400) + rem = total_seconds - days * 86400 + hours = int(rem // 3600) + rem -= hours * 3600 + minutes = int(rem // 60) + seconds = rem - minutes * 60 + + out = "P" + if days: + out += f"{days}D" + if hours or minutes or seconds or not days: + out += "T" + if hours: + out += f"{hours}H" + if minutes: + out += f"{minutes}M" + # keep seconds even if 0 when nothing else was emitted in T + if seconds or (not hours and not minutes): + # avoid trailing .0 when it’s integral + if abs(seconds - round(seconds)) < 1e-12: + out += f"{int(round(seconds))}S" + else: + out += f"{seconds}S" + return out + + +Scalar = Union[str, int, float, bool, _dt.timedelta, _dt.datetime] + + +def _parse_scalar(text: Optional[str]) -> Scalar: + if text is None: + return "" + s = text.strip() + if s == "": + return "" + + if s in ("true", "false"): + return s == "true" + + dur = _parse_duration_iso8601(s) + if dur is not None and s.startswith("P"): + return dur + + dm = _DT_RE.match(s) + if dm: + y, mo, d = int(dm["y"]), int(dm["m"]), int(dm["d"]) + h, mi, se = int(dm["h"]), int(dm["mi"]), int(dm["s"]) + frac = dm["frac"] + micro = int((frac or "0").ljust(6, "0")[:6]) + dt = _dt.datetime(y, mo, d, h, mi, se, microsecond=micro) + if dm["z"] == "Z": + dt = dt.replace(tzinfo=_dt.timezone.utc) + return dt + + if _INT_RE.match(s): + try: + return int(s) + except ValueError: + pass + + if _FLOAT_RE.match(s): + try: + return float(s) + except ValueError: + pass + + return s + + +def _format_scalar(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return repr(value) + if isinstance(value, _dt.timedelta): + return _format_duration_iso8601(value) + if isinstance(value, _dt.datetime): + # if timezone-aware UTC, emit Z + if value.tzinfo is not None and value.utcoffset() == _dt.timedelta(0): + v = value.astimezone(_dt.timezone.utc).replace(tzinfo=None) + return v.isoformat(timespec="seconds") + "Z" + return value.isoformat() + return str(value) + + +def _localname(tag: str) -> str: + # "{uri}Name" -> "Name" + return tag.split("}", 1)[1] if tag.startswith("{") else tag + + +def _is_xsi_nil(el: ET.Element) -> bool: + nil_attr = el.attrib.get(f"{{{XSI}}}nil") + return (nil_attr or "").lower() == "true" + + +# --------- generic XML -> Python structure --------- + + +Object = Union[Dict[str, Union["Object", List["Object"]]], Scalar] + + +def _xml_to_obj(el: ET.Element) -> Object: + if _is_xsi_nil(el): + return {} + + children = list(el) + if not children: + return _parse_scalar(el.text) + + # group by localname + grouped: Dict[str, list[ET.Element]] = {} + for ch in children: + grouped.setdefault(_localname(ch.tag), []).append(ch) + + out: Object = {} + for name, items in grouped.items(): + if len(items) == 1: + out[name] = _xml_to_obj(items[0]) # type: ignore + else: + out[name] = [_xml_to_obj(i) for i in items] # type: ignore + return out + + +# --------- SOAP decode helpers --------- + + +def soap_body_payload(xml: str) -> ET.Element: + """ + Returns the first element inside SOAP Body (e.g., GetStatusResponse). + """ + root = ET.fromstring(xml) + body = root.find(f".//{{{SOAP_ENV}}}Body") + if body is None: + raise ValueError("SOAP Body not found") + for child in list(body): + return child + raise ValueError("SOAP Body is empty") + + +def soap_decode(xml: str, *, take: str | None = None) -> Any: + """ + Decode a SOAP response into Python structures. + - If take is provided, finds the first element with that localname anywhere + under the SOAP Body payload and returns its decoded value. + - If take is None, returns the decoded payload element. + """ + payload = soap_body_payload(xml) + + if take is None: + return {_localname(payload.tag): _xml_to_obj(payload)} + + for el in payload.iter(): + if _localname(el.tag) == take: + return _xml_to_obj(el) + + raise KeyError(f"Element {take!r} not found under SOAP Body payload") + + +# --------- SOAP encode helpers --------- + + +def _append_value(parent: ET.Element, ns: str, name: str, value: Any) -> None: + el = ET.SubElement(parent, f"{{{ns}}}{name}") + + if value is None: + el.set(f"{{{XSI}}}nil", "true") + return + + if isinstance(value, Mapping): + for k, v in value.items(): + _append_value(el, ns, str(k), v) + return + + if isinstance(value, (list, tuple)): + # repeated child elements with the same name (common in SOAP doc/literal) + # here we encode as ... by default + for item in value: + _append_value(el, ns, "item", item) + return + + el.text = _format_scalar(value) + + +def soap_encode( + method: str, + params: Mapping[str, Any] | None = None, + *, + method_ns: str, + extra_method_xmlns: Mapping[str, str] | None = None, +) -> str: + """ + Builds a doc/literal style SOAP 1.1 envelope: + ... + + extra_method_xmlns lets you add xmlns:prefix="uri" attributes on the method element. + """ + env = ET.Element(f"{{{SOAP_ENV}}}Envelope") + body = ET.SubElement(env, f"{{{SOAP_ENV}}}Body") + + # Method element uses the *default* namespace (no prefix) like your example. + method_el = ET.SubElement(body, f"{{{method_ns}}}{method}") + + if extra_method_xmlns: + for prefix, uri in extra_method_xmlns.items(): + method_el.set(f"xmlns:{prefix}", uri) + + for k, v in (params or {}).items(): + _append_value(method_el, method_ns, str(k), v) + + return ET.tostring(env, encoding="unicode", xml_declaration=False) diff --git a/pylabrobot/storage/liconic/__init__.py b/pylabrobot/legacy/storage/liconic/__init__.py similarity index 100% rename from pylabrobot/storage/liconic/__init__.py rename to pylabrobot/legacy/storage/liconic/__init__.py diff --git a/pylabrobot/storage/liconic/constants.py b/pylabrobot/legacy/storage/liconic/constants.py similarity index 100% rename from pylabrobot/storage/liconic/constants.py rename to pylabrobot/legacy/storage/liconic/constants.py diff --git a/pylabrobot/storage/liconic/errors.py b/pylabrobot/legacy/storage/liconic/errors.py similarity index 99% rename from pylabrobot/storage/liconic/errors.py rename to pylabrobot/legacy/storage/liconic/errors.py index 0025661ca97..d5c8432f574 100644 --- a/pylabrobot/storage/liconic/errors.py +++ b/pylabrobot/legacy/storage/liconic/errors.py @@ -1,6 +1,6 @@ from typing import Dict, Tuple, Type -from pylabrobot.storage.liconic.constants import ControllerError, HandlingError +from pylabrobot.legacy.storage.liconic.constants import ControllerError, HandlingError class LiconicControllerRelayError(Exception): diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/legacy/storage/liconic/liconic_backend.py similarity index 98% rename from pylabrobot/storage/liconic/liconic_backend.py rename to pylabrobot/legacy/storage/liconic/liconic_backend.py index ad6859af3bc..73ed734ead2 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/legacy/storage/liconic/liconic_backend.py @@ -13,14 +13,14 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e -from pylabrobot.barcode_scanners import BarcodeScanner from pylabrobot.io.serial import Serial +from pylabrobot.legacy.barcode_scanners import BarcodeScanner +from pylabrobot.legacy.storage.backend import IncubatorBackend +from pylabrobot.legacy.storage.liconic.constants import ControllerError, HandlingError, LiconicType +from pylabrobot.legacy.storage.liconic.errors import controller_error_map, handler_error_map from pylabrobot.resources import Plate, PlateHolder from pylabrobot.resources.barcode import Barcode from pylabrobot.resources.carrier import PlateCarrier -from pylabrobot.storage.backend import IncubatorBackend -from pylabrobot.storage.liconic.constants import ControllerError, HandlingError, LiconicType -from pylabrobot.storage.liconic.errors import controller_error_map, handler_error_map logger = logging.getLogger(__name__) diff --git a/pylabrobot/storage/liconic/liconic_backend_tests.py b/pylabrobot/legacy/storage/liconic/liconic_backend_tests.py similarity index 98% rename from pylabrobot/storage/liconic/liconic_backend_tests.py rename to pylabrobot/legacy/storage/liconic/liconic_backend_tests.py index 590ab66af98..504597036d7 100644 --- a/pylabrobot/storage/liconic/liconic_backend_tests.py +++ b/pylabrobot/legacy/storage/liconic/liconic_backend_tests.py @@ -7,18 +7,18 @@ pytest.importorskip("serial") -from pylabrobot.resources import PlateHolder -from pylabrobot.resources.carrier import PlateCarrier -from pylabrobot.storage.liconic.constants import LiconicType -from pylabrobot.storage.liconic.liconic_backend import ( +from pylabrobot.legacy.storage.liconic.constants import LiconicType +from pylabrobot.legacy.storage.liconic.liconic_backend import ( LICONIC_SITE_HEIGHT_TO_STEPS, ExperimentalLiconicBackend, ) -from pylabrobot.storage.liconic.racks import ( +from pylabrobot.legacy.storage.liconic.racks import ( liconic_rack_5mm_42, liconic_rack_17mm_22, liconic_rack_44mm_10, ) +from pylabrobot.resources import PlateHolder +from pylabrobot.resources.carrier import PlateCarrier class TestStepSizeFormula(unittest.TestCase): @@ -416,7 +416,7 @@ async def test_send_command_raises_on_empty_response(self): await self.backend._send_command("RD 1915") async def test_send_command_raises_on_controller_error(self): - from pylabrobot.storage.liconic.errors import LiconicControllerCommandError + from pylabrobot.legacy.storage.liconic.errors import LiconicControllerCommandError self.backend.io.read = AsyncMock(return_value=b"E1") with self.assertRaises(LiconicControllerCommandError): diff --git a/pylabrobot/storage/liconic/racks.py b/pylabrobot/legacy/storage/liconic/racks.py similarity index 100% rename from pylabrobot/storage/liconic/racks.py rename to pylabrobot/legacy/storage/liconic/racks.py diff --git a/pylabrobot/legacy/temperature_controlling/__init__.py b/pylabrobot/legacy/temperature_controlling/__init__.py new file mode 100644 index 00000000000..307117ced55 --- /dev/null +++ b/pylabrobot/legacy/temperature_controlling/__init__.py @@ -0,0 +1,6 @@ +from .chatterbox import TemperatureControllerChatterboxBackend +from .inheco.control_box import InhecoTECControlBox +from .inheco.cpac import inheco_cpac_ultraflat +from .opentrons import OpentronsTemperatureModuleV2 +from .opentrons_backend import OpentronsTemperatureModuleBackend +from .temperature_controller import TemperatureController diff --git a/pylabrobot/temperature_controlling/backend.py b/pylabrobot/legacy/temperature_controlling/backend.py similarity index 92% rename from pylabrobot/temperature_controlling/backend.py rename to pylabrobot/legacy/temperature_controlling/backend.py index 3fdeff95584..075e0954d52 100644 --- a/pylabrobot/temperature_controlling/backend.py +++ b/pylabrobot/legacy/temperature_controlling/backend.py @@ -1,6 +1,6 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class TemperatureControllerBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/temperature_controlling/chatterbox.py b/pylabrobot/legacy/temperature_controlling/chatterbox.py similarity index 93% rename from pylabrobot/temperature_controlling/chatterbox.py rename to pylabrobot/legacy/temperature_controlling/chatterbox.py index b53510080ac..0cdc84dfb2d 100644 --- a/pylabrobot/temperature_controlling/chatterbox.py +++ b/pylabrobot/legacy/temperature_controlling/chatterbox.py @@ -1,4 +1,4 @@ -from pylabrobot.temperature_controlling.backend import ( +from pylabrobot.legacy.temperature_controlling.backend import ( TemperatureControllerBackend, ) diff --git a/pylabrobot/temperature_controlling/inheco/__init__.py b/pylabrobot/legacy/temperature_controlling/inheco/__init__.py similarity index 100% rename from pylabrobot/temperature_controlling/inheco/__init__.py rename to pylabrobot/legacy/temperature_controlling/inheco/__init__.py diff --git a/pylabrobot/temperature_controlling/inheco/control_box.py b/pylabrobot/legacy/temperature_controlling/inheco/control_box.py similarity index 100% rename from pylabrobot/temperature_controlling/inheco/control_box.py rename to pylabrobot/legacy/temperature_controlling/inheco/control_box.py diff --git a/pylabrobot/temperature_controlling/inheco/cpac.py b/pylabrobot/legacy/temperature_controlling/inheco/cpac.py similarity index 68% rename from pylabrobot/temperature_controlling/inheco/cpac.py rename to pylabrobot/legacy/temperature_controlling/inheco/cpac.py index b566e395a29..32646e520ea 100644 --- a/pylabrobot/temperature_controlling/inheco/cpac.py +++ b/pylabrobot/legacy/temperature_controlling/inheco/cpac.py @@ -1,7 +1,7 @@ +from pylabrobot.legacy.temperature_controlling.inheco.control_box import InhecoTECControlBox +from pylabrobot.legacy.temperature_controlling.inheco.cpac_backend import InhecoCPACBackend +from pylabrobot.legacy.temperature_controlling.temperature_controller import TemperatureController from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.temperature_controlling.inheco.control_box import InhecoTECControlBox -from pylabrobot.temperature_controlling.inheco.cpac_backend import InhecoCPACBackend -from pylabrobot.temperature_controlling.temperature_controller import TemperatureController def inheco_cpac_ultraflat( diff --git a/pylabrobot/temperature_controlling/inheco/cpac_backend.py b/pylabrobot/legacy/temperature_controlling/inheco/cpac_backend.py similarity index 56% rename from pylabrobot/temperature_controlling/inheco/cpac_backend.py rename to pylabrobot/legacy/temperature_controlling/inheco/cpac_backend.py index 6a9f4715c8f..e2be5042b4c 100644 --- a/pylabrobot/temperature_controlling/inheco/cpac_backend.py +++ b/pylabrobot/legacy/temperature_controlling/inheco/cpac_backend.py @@ -1,4 +1,4 @@ -from pylabrobot.temperature_controlling.inheco.temperature_controller import ( +from pylabrobot.legacy.temperature_controlling.inheco.temperature_controller import ( InhecoTemperatureControllerBackend, ) diff --git a/pylabrobot/temperature_controlling/inheco/temperature_controller.py b/pylabrobot/legacy/temperature_controlling/inheco/temperature_controller.py similarity index 92% rename from pylabrobot/temperature_controlling/inheco/temperature_controller.py rename to pylabrobot/legacy/temperature_controlling/inheco/temperature_controller.py index 6c78abb5a40..53f53dc6f85 100644 --- a/pylabrobot/temperature_controlling/inheco/temperature_controller.py +++ b/pylabrobot/legacy/temperature_controlling/inheco/temperature_controller.py @@ -1,8 +1,8 @@ import abc import warnings -from pylabrobot.temperature_controlling.backend import TemperatureControllerBackend -from pylabrobot.temperature_controlling.inheco.control_box import InhecoTECControlBox +from pylabrobot.legacy.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.legacy.temperature_controlling.inheco.control_box import InhecoTECControlBox class InhecoTemperatureControllerBackend(TemperatureControllerBackend, metaclass=abc.ABCMeta): diff --git a/pylabrobot/temperature_controlling/opentrons.py b/pylabrobot/legacy/temperature_controlling/opentrons.py similarity index 87% rename from pylabrobot/temperature_controlling/opentrons.py rename to pylabrobot/legacy/temperature_controlling/opentrons.py index 771789c3aa3..8bfd0c42893 100644 --- a/pylabrobot/temperature_controlling/opentrons.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons.py @@ -1,17 +1,17 @@ from typing import Optional -from pylabrobot.resources import Coordinate, ItemizedResource -from pylabrobot.resources.opentrons.module import OTModule -from pylabrobot.temperature_controlling.backend import TemperatureControllerBackend -from pylabrobot.temperature_controlling.opentrons_backend import ( +from pylabrobot.legacy.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.legacy.temperature_controlling.opentrons_backend import ( OpentronsTemperatureModuleBackend, ) -from pylabrobot.temperature_controlling.opentrons_backend_usb import ( +from pylabrobot.legacy.temperature_controlling.opentrons_backend_usb import ( OpentronsTemperatureModuleUSBBackend, ) -from pylabrobot.temperature_controlling.temperature_controller import ( +from pylabrobot.legacy.temperature_controlling.temperature_controller import ( TemperatureController, ) +from pylabrobot.resources import Coordinate, ItemizedResource +from pylabrobot.resources.opentrons.module import OTModule class OpentronsTemperatureModuleV2(TemperatureController, OTModule): diff --git a/pylabrobot/temperature_controlling/opentrons_backend.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py similarity index 96% rename from pylabrobot/temperature_controlling/opentrons_backend.py rename to pylabrobot/legacy/temperature_controlling/opentrons_backend.py index 4072ea4ae0e..c1d4571a28a 100644 --- a/pylabrobot/temperature_controlling/opentrons_backend.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py @@ -1,6 +1,6 @@ from typing import cast -from pylabrobot.temperature_controlling.backend import ( +from pylabrobot.legacy.temperature_controlling.backend import ( TemperatureControllerBackend, ) diff --git a/pylabrobot/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py similarity index 97% rename from pylabrobot/temperature_controlling/opentrons_backend_usb.py rename to pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py index dd941633d55..2616020cf1a 100644 --- a/pylabrobot/temperature_controlling/opentrons_backend_usb.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py @@ -1,7 +1,7 @@ from typing import Optional from pylabrobot.io.serial import Serial -from pylabrobot.temperature_controlling.backend import ( +from pylabrobot.legacy.temperature_controlling.backend import ( TemperatureControllerBackend, ) diff --git a/pylabrobot/temperature_controlling/temperature_controller.py b/pylabrobot/legacy/temperature_controlling/temperature_controller.py similarity index 98% rename from pylabrobot/temperature_controlling/temperature_controller.py rename to pylabrobot/legacy/temperature_controlling/temperature_controller.py index 3b58305c7dc..85a8c52e2fa 100644 --- a/pylabrobot/temperature_controlling/temperature_controller.py +++ b/pylabrobot/legacy/temperature_controlling/temperature_controller.py @@ -2,7 +2,7 @@ import time from typing import Optional -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.machines.machine import Machine from pylabrobot.resources import Coordinate, ResourceHolder from .backend import TemperatureControllerBackend diff --git a/pylabrobot/temperature_controlling/temperature_controller_tests.py b/pylabrobot/legacy/temperature_controlling/temperature_controller_tests.py similarity index 94% rename from pylabrobot/temperature_controlling/temperature_controller_tests.py rename to pylabrobot/legacy/temperature_controlling/temperature_controller_tests.py index 5e2a5c8cb86..14f8b9a1787 100644 --- a/pylabrobot/temperature_controlling/temperature_controller_tests.py +++ b/pylabrobot/legacy/temperature_controlling/temperature_controller_tests.py @@ -1,11 +1,11 @@ import unittest -from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.temperature_controlling import ( +from pylabrobot.legacy.temperature_controlling import ( TemperatureController, TemperatureControllerChatterboxBackend, ) -from pylabrobot.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.legacy.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.resources.coordinate import Coordinate class TemperatureControllerTests(unittest.TestCase): diff --git a/pylabrobot/legacy/thermocycling/__init__.py b/pylabrobot/legacy/thermocycling/__init__.py new file mode 100644 index 00000000000..084f2aed8d7 --- /dev/null +++ b/pylabrobot/legacy/thermocycling/__init__.py @@ -0,0 +1,7 @@ +from .backend import ThermocyclerBackend +from .chatterbox import ThermocyclerChatterboxBackend +from .opentrons import OpentronsThermocyclerModuleV1, OpentronsThermocyclerModuleV2 +from .opentrons_backend import OpentronsThermocyclerBackend +from .standard import Step +from .thermo_fisher import * +from .thermocycler import Thermocycler diff --git a/pylabrobot/thermocycling/backend.py b/pylabrobot/legacy/thermocycling/backend.py similarity index 94% rename from pylabrobot/thermocycling/backend.py rename to pylabrobot/legacy/thermocycling/backend.py index cf0955364ae..d0d089699ef 100644 --- a/pylabrobot/thermocycling/backend.py +++ b/pylabrobot/legacy/thermocycling/backend.py @@ -1,8 +1,8 @@ from abc import ABCMeta, abstractmethod from typing import List -from pylabrobot.machines.backend import MachineBackend -from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol +from pylabrobot.legacy.machines.backend import MachineBackend +from pylabrobot.legacy.thermocycling.standard import BlockStatus, LidStatus, Protocol class ThermocyclerBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/thermocycling/chatterbox.py b/pylabrobot/legacy/thermocycling/chatterbox.py similarity index 97% rename from pylabrobot/thermocycling/chatterbox.py rename to pylabrobot/legacy/thermocycling/chatterbox.py index 1c45e40752d..46dd31bc62a 100644 --- a/pylabrobot/thermocycling/chatterbox.py +++ b/pylabrobot/legacy/thermocycling/chatterbox.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from typing import List, Optional -from pylabrobot.thermocycling.backend import ThermocyclerBackend -from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol +from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend +from pylabrobot.legacy.thermocycling.standard import BlockStatus, LidStatus, Protocol @dataclass diff --git a/pylabrobot/thermocycling/chatterbox_tests.py b/pylabrobot/legacy/thermocycling/chatterbox_tests.py similarity index 94% rename from pylabrobot/thermocycling/chatterbox_tests.py rename to pylabrobot/legacy/thermocycling/chatterbox_tests.py index dcd85299aae..b483f481128 100644 --- a/pylabrobot/thermocycling/chatterbox_tests.py +++ b/pylabrobot/legacy/thermocycling/chatterbox_tests.py @@ -2,9 +2,9 @@ from contextlib import redirect_stdout from io import StringIO +from pylabrobot.legacy.thermocycling import Thermocycler, ThermocyclerChatterboxBackend +from pylabrobot.legacy.thermocycling.standard import Protocol, Stage, Step from pylabrobot.resources import Coordinate -from pylabrobot.thermocycling import Thermocycler, ThermocyclerChatterboxBackend -from pylabrobot.thermocycling.standard import Protocol, Stage, Step class TestThermocyclerChatterbox(unittest.IsolatedAsyncioTestCase): diff --git a/pylabrobot/thermocycling/inheco/__init__.py b/pylabrobot/legacy/thermocycling/inheco/__init__.py similarity index 100% rename from pylabrobot/thermocycling/inheco/__init__.py rename to pylabrobot/legacy/thermocycling/inheco/__init__.py diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/legacy/thermocycling/inheco/odtc_backend.py similarity index 98% rename from pylabrobot/thermocycling/inheco/odtc_backend.py rename to pylabrobot/legacy/thermocycling/inheco/odtc_backend.py index 84e1b69665b..327a60955e3 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/legacy/thermocycling/inheco/odtc_backend.py @@ -4,9 +4,12 @@ import xml.etree.ElementTree as ET from typing import Any, Dict, List, Optional -from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface, SiLAError -from pylabrobot.thermocycling.backend import ThermocyclerBackend -from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol +from pylabrobot.legacy.storage.inheco.scila.inheco_sila_interface import ( + InhecoSiLAInterface, + SiLAError, +) +from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend +from pylabrobot.legacy.thermocycling.standard import BlockStatus, LidStatus, Protocol def _format_number(n: Any) -> str: diff --git a/pylabrobot/thermocycling/opentrons.py b/pylabrobot/legacy/thermocycling/opentrons.py similarity index 95% rename from pylabrobot/thermocycling/opentrons.py rename to pylabrobot/legacy/thermocycling/opentrons.py index 6c8d8546b3c..bc2b9738f3b 100644 --- a/pylabrobot/thermocycling/opentrons.py +++ b/pylabrobot/legacy/thermocycling/opentrons.py @@ -2,10 +2,10 @@ from typing import Optional, cast +from pylabrobot.legacy.thermocycling.opentrons_backend import OpentronsThermocyclerBackend +from pylabrobot.legacy.thermocycling.thermocycler import Thermocycler from pylabrobot.resources import Coordinate, ItemizedResource from pylabrobot.resources.opentrons.module import OTModule -from pylabrobot.thermocycling.opentrons_backend import OpentronsThermocyclerBackend -from pylabrobot.thermocycling.thermocycler import Thermocycler class OpentronsThermocyclerModuleV1(Thermocycler, OTModule): diff --git a/pylabrobot/thermocycling/opentrons_backend.py b/pylabrobot/legacy/thermocycling/opentrons_backend.py similarity index 98% rename from pylabrobot/thermocycling/opentrons_backend.py rename to pylabrobot/legacy/thermocycling/opentrons_backend.py index 039708f348f..430da6f73b7 100644 --- a/pylabrobot/thermocycling/opentrons_backend.py +++ b/pylabrobot/legacy/thermocycling/opentrons_backend.py @@ -2,8 +2,8 @@ from typing import List, Optional, cast -from pylabrobot.thermocycling.backend import ThermocyclerBackend -from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol +from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend +from pylabrobot.legacy.thermocycling.standard import BlockStatus, LidStatus, Protocol try: from ot_api.modules import ( diff --git a/pylabrobot/thermocycling/opentrons_backend_tests.py b/pylabrobot/legacy/thermocycling/opentrons_backend_tests.py similarity index 80% rename from pylabrobot/thermocycling/opentrons_backend_tests.py rename to pylabrobot/legacy/thermocycling/opentrons_backend_tests.py index c68460a991e..c484f6ebf1e 100644 --- a/pylabrobot/thermocycling/opentrons_backend_tests.py +++ b/pylabrobot/legacy/thermocycling/opentrons_backend_tests.py @@ -5,10 +5,10 @@ pytest.importorskip("ot_api") +from pylabrobot.legacy.thermocycling.opentrons import OpentronsThermocyclerModuleV1 +from pylabrobot.legacy.thermocycling.opentrons_backend import OpentronsThermocyclerBackend +from pylabrobot.legacy.thermocycling.standard import BlockStatus, LidStatus, Protocol, Stage, Step from pylabrobot.resources.itemized_resource import ItemizedResource -from pylabrobot.thermocycling.opentrons import OpentronsThermocyclerModuleV1 -from pylabrobot.thermocycling.opentrons_backend import OpentronsThermocyclerBackend -from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol, Stage, Step class TestOpentronsThermocyclerBackend(unittest.IsolatedAsyncioTestCase): @@ -29,7 +29,7 @@ def test_opentrons_v1_serialization(self): deserialized = OpentronsThermocyclerModuleV1.deserialize(serialized) assert tc_model == deserialized - @patch("pylabrobot.thermocycling.opentrons_backend.list_connected_modules") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.list_connected_modules") async def test_find_module_raises_error_if_not_found(self, mock_list_connected_modules): """Test that an error is raised if the module is not found.""" mock_list_connected_modules.return_value = [{"id": "some_other_id", "data": {}}] @@ -37,37 +37,37 @@ async def test_find_module_raises_error_if_not_found(self, mock_list_connected_m await self.thermocycler_backend.get_lid_open() self.assertEqual(str(e.exception), "Module 'test_id' not found") - @patch("pylabrobot.thermocycling.opentrons_backend.thermocycler_open_lid") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_open_lid") async def test_open_lid(self, mock_open_lid): await self.thermocycler_backend.open_lid() mock_open_lid.assert_called_once_with(module_id="test_id") - @patch("pylabrobot.thermocycling.opentrons_backend.thermocycler_close_lid") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_close_lid") async def test_close_lid(self, mock_close_lid): await self.thermocycler_backend.close_lid() mock_close_lid.assert_called_once_with(module_id="test_id") - @patch("pylabrobot.thermocycling.opentrons_backend.thermocycler_set_block_temperature") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_set_block_temperature") async def test_set_block_temperature(self, mock_set_block_temp): await self.thermocycler_backend.set_block_temperature([95.0]) mock_set_block_temp.assert_called_once_with(celsius=95.0, module_id="test_id") - @patch("pylabrobot.thermocycling.opentrons_backend.thermocycler_set_lid_temperature") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_set_lid_temperature") async def test_set_lid_temperature(self, mock_set_lid_temp): await self.thermocycler_backend.set_lid_temperature([105.0]) mock_set_lid_temp.assert_called_once_with(celsius=105.0, module_id="test_id") - @patch("pylabrobot.thermocycling.opentrons_backend.thermocycler_deactivate_block") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_deactivate_block") async def test_deactivate_block(self, mock_deactivate_block): await self.thermocycler_backend.deactivate_block() mock_deactivate_block.assert_called_once_with(module_id="test_id") - @patch("pylabrobot.thermocycling.opentrons_backend.thermocycler_deactivate_lid") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_deactivate_lid") async def test_deactivate_lid(self, mock_deactivate_lid): await self.thermocycler_backend.deactivate_lid() mock_deactivate_lid.assert_called_once_with(module_id="test_id") - @patch("pylabrobot.thermocycling.opentrons_backend.thermocycler_run_profile_no_wait") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_run_profile_no_wait") async def test_run_protocol(self, mock_run_profile): protocol = Protocol(stages=[Stage(steps=[Step(temperature=[95], hold_seconds=10)], repeats=1)]) await self.thermocycler_backend.run_protocol(protocol, 50.0) @@ -76,7 +76,7 @@ async def test_run_protocol(self, mock_run_profile): profile=[{"celsius": 95, "holdSeconds": 10}], block_max_volume=50.0, module_id="test_id" ) - @patch("pylabrobot.thermocycling.opentrons_backend.list_connected_modules") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.list_connected_modules") async def test_getters_return_correct_data(self, mock_list_connected_modules): mock_data = { "id": "test_id", @@ -110,7 +110,7 @@ async def test_getters_return_correct_data(self, mock_list_connected_modules): assert await self.thermocycler_backend.get_current_step_index() == 0 # 1 - 1 = 0 (zero-based) assert await self.thermocycler_backend.get_total_step_count() == 3 - @patch("pylabrobot.thermocycling.opentrons_backend.list_connected_modules") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.list_connected_modules") async def test_get_hold_time_raises_if_not_running(self, mock_list_connected_modules): mock_list_connected_modules.return_value = [{"id": "test_id", "data": {}}] diff --git a/pylabrobot/thermocycling/opentrons_backend_usb.py b/pylabrobot/legacy/thermocycling/opentrons_backend_usb.py similarity index 98% rename from pylabrobot/thermocycling/opentrons_backend_usb.py rename to pylabrobot/legacy/thermocycling/opentrons_backend_usb.py index 41daf9a002c..3c5f809205d 100644 --- a/pylabrobot/thermocycling/opentrons_backend_usb.py +++ b/pylabrobot/legacy/thermocycling/opentrons_backend_usb.py @@ -4,8 +4,8 @@ import asyncio from typing import List, Optional -from pylabrobot.thermocycling.backend import ThermocyclerBackend -from pylabrobot.thermocycling.standard import ( +from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend +from pylabrobot.legacy.thermocycling.standard import ( BlockStatus, LidStatus, Protocol, diff --git a/pylabrobot/thermocycling/standard.py b/pylabrobot/legacy/thermocycling/standard.py similarity index 100% rename from pylabrobot/thermocycling/standard.py rename to pylabrobot/legacy/thermocycling/standard.py diff --git a/pylabrobot/thermocycling/thermo_fisher/__init__.py b/pylabrobot/legacy/thermocycling/thermo_fisher/__init__.py similarity index 100% rename from pylabrobot/thermocycling/thermo_fisher/__init__.py rename to pylabrobot/legacy/thermocycling/thermo_fisher/__init__.py diff --git a/pylabrobot/thermocycling/thermo_fisher/atc.py b/pylabrobot/legacy/thermocycling/thermo_fisher/atc.py similarity index 89% rename from pylabrobot/thermocycling/thermo_fisher/atc.py rename to pylabrobot/legacy/thermocycling/thermo_fisher/atc.py index c2f070994b0..ae7b701ab01 100644 --- a/pylabrobot/thermocycling/thermo_fisher/atc.py +++ b/pylabrobot/legacy/thermocycling/thermo_fisher/atc.py @@ -1,4 +1,4 @@ -from pylabrobot.thermocycling.thermo_fisher.thermo_fisher_thermocycler import ( +from pylabrobot.legacy.thermocycling.thermo_fisher.thermo_fisher_thermocycler import ( ThermoFisherThermocyclerBackend, ) diff --git a/pylabrobot/thermocycling/thermo_fisher/proflex.py b/pylabrobot/legacy/thermocycling/thermo_fisher/proflex.py similarity index 79% rename from pylabrobot/thermocycling/thermo_fisher/proflex.py rename to pylabrobot/legacy/thermocycling/thermo_fisher/proflex.py index da6c387e239..8105c735f5f 100644 --- a/pylabrobot/thermocycling/thermo_fisher/proflex.py +++ b/pylabrobot/legacy/thermocycling/thermo_fisher/proflex.py @@ -1,4 +1,4 @@ -from pylabrobot.thermocycling.thermo_fisher.thermo_fisher_thermocycler import ( +from pylabrobot.legacy.thermocycling.thermo_fisher.thermo_fisher_thermocycler import ( ThermoFisherThermocyclerBackend, ) diff --git a/pylabrobot/thermocycling/thermo_fisher/proflex_tests.py b/pylabrobot/legacy/thermocycling/thermo_fisher/proflex_tests.py similarity index 98% rename from pylabrobot/thermocycling/thermo_fisher/proflex_tests.py rename to pylabrobot/legacy/thermocycling/thermo_fisher/proflex_tests.py index 0f37d0ba393..2d4854751b2 100644 --- a/pylabrobot/thermocycling/thermo_fisher/proflex_tests.py +++ b/pylabrobot/legacy/thermocycling/thermo_fisher/proflex_tests.py @@ -2,8 +2,8 @@ import unittest import unittest.mock -from pylabrobot.thermocycling.standard import Protocol, Stage, Step -from pylabrobot.thermocycling.thermo_fisher.proflex import ProflexBackend +from pylabrobot.legacy.thermocycling.standard import Protocol, Stage, Step +from pylabrobot.legacy.thermocycling.thermo_fisher.proflex import ProflexBackend class TestProflexBackend(unittest.IsolatedAsyncioTestCase): diff --git a/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py b/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py similarity index 99% rename from pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py rename to pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py index 86bab4c0e3d..eb81ea6db35 100644 --- a/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py +++ b/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py @@ -13,8 +13,8 @@ from xml.dom import minidom from pylabrobot.io import Socket -from pylabrobot.thermocycling.backend import ThermocyclerBackend -from pylabrobot.thermocycling.standard import LidStatus, Protocol, Stage, Step +from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend +from pylabrobot.legacy.thermocycling.standard import LidStatus, Protocol, Stage, Step def _generate_run_info_files( @@ -257,7 +257,7 @@ def __init__( self._num_blocks: Optional[int] = None self.num_temp_zones = 0 self.available_blocks: List[int] = [] - self.logger = logging.getLogger("pylabrobot.thermocycling.proflex") + self.logger = logging.getLogger("pylabrobot.legacy.thermocycling.proflex") self.current_runs: Dict[int, str] = {} @property diff --git a/pylabrobot/thermocycling/thermocycler.py b/pylabrobot/legacy/thermocycling/thermocycler.py similarity index 98% rename from pylabrobot/thermocycling/thermocycler.py rename to pylabrobot/legacy/thermocycling/thermocycler.py index 622599d47a2..96e107315bc 100644 --- a/pylabrobot/thermocycling/thermocycler.py +++ b/pylabrobot/legacy/thermocycling/thermocycler.py @@ -4,10 +4,10 @@ import time from typing import List, Optional -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.machines.machine import Machine +from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend +from pylabrobot.legacy.thermocycling.standard import BlockStatus, LidStatus, Protocol, Stage, Step from pylabrobot.resources import Coordinate, ResourceHolder -from pylabrobot.thermocycling.backend import ThermocyclerBackend -from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol, Stage, Step class Thermocycler(ResourceHolder, Machine): diff --git a/pylabrobot/thermocycling/thermocycler_tests.py b/pylabrobot/legacy/thermocycling/thermocycler_tests.py similarity index 97% rename from pylabrobot/thermocycling/thermocycler_tests.py rename to pylabrobot/legacy/thermocycling/thermocycler_tests.py index db0d4821c6f..39a2451fe24 100644 --- a/pylabrobot/thermocycling/thermocycler_tests.py +++ b/pylabrobot/legacy/thermocycling/thermocycler_tests.py @@ -2,13 +2,13 @@ import unittest from unittest.mock import AsyncMock, MagicMock -from pylabrobot.resources import Coordinate -from pylabrobot.thermocycling import ( +from pylabrobot.legacy.thermocycling import ( Thermocycler, ThermocyclerBackend, ThermocyclerChatterboxBackend, ) -from pylabrobot.thermocycling.standard import Protocol, Stage, Step +from pylabrobot.legacy.thermocycling.standard import Protocol, Stage, Step +from pylabrobot.resources import Coordinate def mock_backend() -> MagicMock: diff --git a/pylabrobot/legacy/tilting/__init__.py b/pylabrobot/legacy/tilting/__init__.py new file mode 100644 index 00000000000..44f89afcdc8 --- /dev/null +++ b/pylabrobot/legacy/tilting/__init__.py @@ -0,0 +1,4 @@ +from .hamilton import HamiltonTiltModule +from .hamilton_backend import HamiltonTiltModuleBackend +from .tilter import Tilter +from .tilter_backend import TilterBackend diff --git a/pylabrobot/tilting/chatterbox.py b/pylabrobot/legacy/tilting/chatterbox.py similarity index 82% rename from pylabrobot/tilting/chatterbox.py rename to pylabrobot/legacy/tilting/chatterbox.py index 5ef3ea93be0..4259108c55d 100644 --- a/pylabrobot/tilting/chatterbox.py +++ b/pylabrobot/legacy/tilting/chatterbox.py @@ -1,4 +1,4 @@ -from pylabrobot.tilting import TilterBackend +from pylabrobot.legacy.tilting import TilterBackend class TilterChatterboxBackend(TilterBackend): diff --git a/pylabrobot/tilting/hamilton.py b/pylabrobot/legacy/tilting/hamilton.py similarity index 91% rename from pylabrobot/tilting/hamilton.py rename to pylabrobot/legacy/tilting/hamilton.py index d4233407c7b..3aac1e0f0cd 100644 --- a/pylabrobot/tilting/hamilton.py +++ b/pylabrobot/legacy/tilting/hamilton.py @@ -1,8 +1,8 @@ -from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.tilting.hamilton_backend import ( +from pylabrobot.legacy.tilting.hamilton_backend import ( HamiltonTiltModuleBackend, ) -from pylabrobot.tilting.tilter import Tilter +from pylabrobot.legacy.tilting.tilter import Tilter +from pylabrobot.resources.coordinate import Coordinate class HamiltonTiltModule(Tilter): diff --git a/pylabrobot/tilting/hamilton_backend.py b/pylabrobot/legacy/tilting/hamilton_backend.py similarity index 99% rename from pylabrobot/tilting/hamilton_backend.py rename to pylabrobot/legacy/tilting/hamilton_backend.py index 34ea0337d8b..1849a60254e 100644 --- a/pylabrobot/tilting/hamilton_backend.py +++ b/pylabrobot/legacy/tilting/hamilton_backend.py @@ -10,7 +10,7 @@ _SERIAL_IMPORT_ERROR = e from pylabrobot.io.serial import Serial -from pylabrobot.tilting.tilter_backend import ( +from pylabrobot.legacy.tilting.tilter_backend import ( TilterBackend, TiltModuleError, ) diff --git a/pylabrobot/tilting/tilter.py b/pylabrobot/legacy/tilting/tilter.py similarity index 99% rename from pylabrobot/tilting/tilter.py rename to pylabrobot/legacy/tilting/tilter.py index c833181186e..27d9e7b6610 100644 --- a/pylabrobot/tilting/tilter.py +++ b/pylabrobot/legacy/tilting/tilter.py @@ -1,7 +1,7 @@ import math from typing import List, Optional -from pylabrobot.machines import Machine +from pylabrobot.legacy.machines import Machine from pylabrobot.resources import Coordinate, Plate from pylabrobot.resources.resource_holder import ResourceHolder from pylabrobot.resources.well import CrossSectionType, Well diff --git a/pylabrobot/tilting/tilter_backend.py b/pylabrobot/legacy/tilting/tilter_backend.py similarity index 90% rename from pylabrobot/tilting/tilter_backend.py rename to pylabrobot/legacy/tilting/tilter_backend.py index 5b2b9653dc3..f0bcdbe3d32 100644 --- a/pylabrobot/tilting/tilter_backend.py +++ b/pylabrobot/legacy/tilting/tilter_backend.py @@ -1,6 +1,6 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machines.machine import MachineBackend +from pylabrobot.legacy.machines.machine import MachineBackend class TiltModuleError(Exception): diff --git a/pylabrobot/liquid_handling/__init__.py b/pylabrobot/liquid_handling/__init__.py index 62d99cf6d64..ee22fb0af4f 100644 --- a/pylabrobot/liquid_handling/__init__.py +++ b/pylabrobot/liquid_handling/__init__.py @@ -1,14 +1,10 @@ -from .backends import * -from .liquid_handler import LiquidHandler -from .standard import ( - Drop, - DropTipRack, - MultiHeadAspirationPlate, - MultiHeadDispensePlate, - Pickup, - PickupTipRack, - ResourceMove, - SingleChannelAspiration, - SingleChannelDispense, +import warnings + +warnings.warn( + "Importing from pylabrobot.liquid_handling is deprecated. " + "Use pylabrobot.legacy.liquid_handling instead.", + DeprecationWarning, + stacklevel=2, ) -from .strictness import Strictness, get_strictness, set_strictness + +from pylabrobot.legacy.liquid_handling import * # noqa: F401,F403,E402 diff --git a/pylabrobot/liquid_handling/backends/__init__.py b/pylabrobot/liquid_handling/backends/__init__.py index a039cc1ed4b..02bf0363a33 100644 --- a/pylabrobot/liquid_handling/backends/__init__.py +++ b/pylabrobot/liquid_handling/backends/__init__.py @@ -1,10 +1,10 @@ -from .backend import LiquidHandlerBackend -from .chatterbox import LiquidHandlerChatterboxBackend -from .chatterbox_backend import ChatterBoxBackend -from .hamilton.STAR_backend import STAR, STARBackend -from .hamilton.vantage_backend import Vantage, VantageBackend -from .opentrons_backend import OpentronsOT2Backend -from .opentrons_chatterbox import OpentronsOT2ChatterboxBackend -from .opentrons_simulator import OpentronsOT2Simulator -from .serializing_backend import SerializingBackend -from .tecan.EVO_backend import EVO, EVOBackend +import warnings + +warnings.warn( + "Importing from pylabrobot.liquid_handling.backends is deprecated. " + "Use pylabrobot.legacy.liquid_handling.backends instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.liquid_handling.backends import * # noqa: F401,F403,E402 diff --git a/pylabrobot/liquid_handling/backends/chatterbox.py b/pylabrobot/liquid_handling/backends/chatterbox.py index 227803bc860..33c6db14710 100644 --- a/pylabrobot/liquid_handling/backends/chatterbox.py +++ b/pylabrobot/liquid_handling/backends/chatterbox.py @@ -1,242 +1,10 @@ -from typing import List, Optional, Union +import warnings -from pylabrobot.liquid_handling.backends.backend import ( - LiquidHandlerBackend, +warnings.warn( + "Importing from pylabrobot.liquid_handling.backends.chatterbox is deprecated. " + "Use pylabrobot.legacy.liquid_handling.backends.chatterbox instead.", + DeprecationWarning, + stacklevel=2, ) -from pylabrobot.liquid_handling.standard import ( - Drop, - DropTipRack, - MultiHeadAspirationContainer, - MultiHeadAspirationPlate, - MultiHeadDispenseContainer, - MultiHeadDispensePlate, - Pickup, - PickupTipRack, - ResourceDrop, - ResourceMove, - ResourcePickup, - SingleChannelAspiration, - SingleChannelDispense, -) -from pylabrobot.resources import Tip - - -class LiquidHandlerChatterboxBackend(LiquidHandlerBackend): - """Chatter box backend for device-free testing. Prints out all operations.""" - - _pip_length = 5 - _vol_length = 8 - _resource_length = 20 - _offset_length = 16 - _flow_rate_length = 10 - _blowout_length = 10 - _lld_z_length = 10 - _kwargs_length = 15 - _tip_type_length = 12 - _max_volume_length = 16 - _fitting_depth_length = 20 - _tip_length_length = 16 - # _pickup_method_length = 20 - _filter_length = 10 - - def __init__(self, num_channels: int = 8): - """Initialize a chatter box backend.""" - super().__init__() - self._num_channels = num_channels - self._num_arms = 1 - self._head96_installed = True - - async def setup(self): - await super().setup() - print("Setting up the liquid handler.") - - async def stop(self): - print("Stopping the liquid handler.") - - def serialize(self) -> dict: - return {**super().serialize(), "num_channels": self.num_channels} - - @property - def num_channels(self) -> int: - return self._num_channels - - async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs): - print("Picking up tips:") - header = ( - f"{'pip#':<{LiquidHandlerChatterboxBackend._pip_length}} " - f"{'resource':<{LiquidHandlerChatterboxBackend._resource_length}} " - f"{'offset':<{LiquidHandlerChatterboxBackend._offset_length}} " - f"{'tip type':<{LiquidHandlerChatterboxBackend._tip_type_length}} " - f"{'max volume (µL)':<{LiquidHandlerChatterboxBackend._max_volume_length}} " - f"{'fitting depth (mm)':<{LiquidHandlerChatterboxBackend._fitting_depth_length}} " - f"{'tip length (mm)':<{LiquidHandlerChatterboxBackend._tip_length_length}} " - # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} " - f"{'filter':<{LiquidHandlerChatterboxBackend._filter_length}}" - ) - print(header) - - for op, channel in zip(ops, use_channels): - offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}" - row = ( - f" p{channel}: " - f"{op.resource.name[-30:]:<{LiquidHandlerChatterboxBackend._resource_length}} " - f"{offset:<{LiquidHandlerChatterboxBackend._offset_length}} " - f"{op.tip.__class__.__name__:<{LiquidHandlerChatterboxBackend._tip_type_length}} " - f"{op.tip.maximal_volume:<{LiquidHandlerChatterboxBackend._max_volume_length}} " - f"{op.tip.fitting_depth:<{LiquidHandlerChatterboxBackend._fitting_depth_length}} " - f"{op.tip.total_tip_length:<{LiquidHandlerChatterboxBackend._tip_length_length}} " - # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} " - f"{'Yes' if op.tip.has_filter else 'No':<{LiquidHandlerChatterboxBackend._filter_length}}" - ) - print(row) - - async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs): - print("Dropping tips:") - header = ( - f"{'pip#':<{LiquidHandlerChatterboxBackend._pip_length}} " - f"{'resource':<{LiquidHandlerChatterboxBackend._resource_length}} " - f"{'offset':<{LiquidHandlerChatterboxBackend._offset_length}} " - f"{'tip type':<{LiquidHandlerChatterboxBackend._tip_type_length}} " - f"{'max volume (µL)':<{LiquidHandlerChatterboxBackend._max_volume_length}} " - f"{'fitting depth (mm)':<{LiquidHandlerChatterboxBackend._fitting_depth_length}} " - f"{'tip length (mm)':<{LiquidHandlerChatterboxBackend._tip_length_length}} " - # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} " - f"{'filter':<{LiquidHandlerChatterboxBackend._filter_length}}" - ) - print(header) - - for op, channel in zip(ops, use_channels): - offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}" - row = ( - f" p{channel}: " - f"{op.resource.name[-30:]:<{LiquidHandlerChatterboxBackend._resource_length}} " - f"{offset:<{LiquidHandlerChatterboxBackend._offset_length}} " - f"{op.tip.__class__.__name__:<{LiquidHandlerChatterboxBackend._tip_type_length}} " - f"{op.tip.maximal_volume:<{LiquidHandlerChatterboxBackend._max_volume_length}} " - f"{op.tip.fitting_depth:<{LiquidHandlerChatterboxBackend._fitting_depth_length}} " - f"{op.tip.total_tip_length:<{LiquidHandlerChatterboxBackend._tip_length_length}} " - # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} " - f"{'Yes' if op.tip.has_filter else 'No':<{LiquidHandlerChatterboxBackend._filter_length}}" - ) - print(row) - - async def aspirate( - self, - ops: List[SingleChannelAspiration], - use_channels: List[int], - **backend_kwargs, - ): - print("Aspirating:") - header = ( - f"{'pip#':<{LiquidHandlerChatterboxBackend._pip_length}} " - f"{'vol(ul)':<{LiquidHandlerChatterboxBackend._vol_length}} " - f"{'resource':<{LiquidHandlerChatterboxBackend._resource_length}} " - f"{'offset':<{LiquidHandlerChatterboxBackend._offset_length}} " - f"{'flow rate':<{LiquidHandlerChatterboxBackend._flow_rate_length}} " - f"{'blowout':<{LiquidHandlerChatterboxBackend._blowout_length}} " - f"{'lld_z':<{LiquidHandlerChatterboxBackend._lld_z_length}} " - ) - for key in backend_kwargs: - header += f"{key:<{LiquidHandlerChatterboxBackend._kwargs_length}} "[-16:] - print(header) - - for o, p in zip(ops, use_channels): - offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}" - row = ( - f" p{p}: " - f"{o.volume:<{LiquidHandlerChatterboxBackend._vol_length}} " - f"{o.resource.name[-20:]:<{LiquidHandlerChatterboxBackend._resource_length}} " - f"{offset:<{LiquidHandlerChatterboxBackend._offset_length}} " - f"{str(o.flow_rate):<{LiquidHandlerChatterboxBackend._flow_rate_length}} " - f"{str(o.blow_out_air_volume):<{LiquidHandlerChatterboxBackend._blowout_length}} " - f"{str(o.liquid_height):<{LiquidHandlerChatterboxBackend._lld_z_length}} " - ) - for key, value in backend_kwargs.items(): - if isinstance(value, list) and all(isinstance(v, bool) for v in value): - value = "".join("T" if v else "F" for v in value) - if isinstance(value, list): - value = "".join(map(str, value)) - row += f" {value:<15}" - print(row) - - async def dispense( - self, - ops: List[SingleChannelDispense], - use_channels: List[int], - **backend_kwargs, - ): - print("Dispensing:") - header = ( - f"{'pip#':<{LiquidHandlerChatterboxBackend._pip_length}} " - f"{'vol(ul)':<{LiquidHandlerChatterboxBackend._vol_length}} " - f"{'resource':<{LiquidHandlerChatterboxBackend._resource_length}} " - f"{'offset':<{LiquidHandlerChatterboxBackend._offset_length}} " - f"{'flow rate':<{LiquidHandlerChatterboxBackend._flow_rate_length}} " - f"{'blowout':<{LiquidHandlerChatterboxBackend._blowout_length}} " - f"{'lld_z':<{LiquidHandlerChatterboxBackend._lld_z_length}} " - ) - for key in backend_kwargs: - header += f"{key:<{LiquidHandlerChatterboxBackend._kwargs_length}} "[-16:] - print(header) - - for o, p in zip(ops, use_channels): - offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}" - row = ( - f" p{p}: " - f"{o.volume:<{LiquidHandlerChatterboxBackend._vol_length}} " - f"{o.resource.name[-20:]:<{LiquidHandlerChatterboxBackend._resource_length}} " - f"{offset:<{LiquidHandlerChatterboxBackend._offset_length}} " - f"{str(o.flow_rate):<{LiquidHandlerChatterboxBackend._flow_rate_length}} " - f"{str(o.blow_out_air_volume):<{LiquidHandlerChatterboxBackend._blowout_length}} " - f"{str(o.liquid_height):<{LiquidHandlerChatterboxBackend._lld_z_length}} " - ) - for key, value in backend_kwargs.items(): - if isinstance(value, list) and all(isinstance(v, bool) for v in value): - value = "".join("T" if v else "F" for v in value) - if isinstance(value, list): - value = "".join(map(str, value)) - row += f" {value:<{LiquidHandlerChatterboxBackend._kwargs_length}}" - print(row) - - async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs): - print(f"Picking up tips from {pickup.resource.name}.") - - async def drop_tips96(self, drop: DropTipRack, **backend_kwargs): - print(f"Dropping tips to {drop.resource.name}.") - - async def aspirate96( - self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] - ): - if isinstance(aspiration, MultiHeadAspirationPlate): - resource = aspiration.wells[0].parent - else: - resource = aspiration.container - print(f"Aspirating {aspiration.volume} from {resource}.") - - async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): - if isinstance(dispense, MultiHeadDispensePlate): - resource = dispense.wells[0].parent - else: - resource = dispense.container - print(f"Dispensing {dispense.volume} to {resource}.") - - async def pick_up_resource(self, pickup: ResourcePickup): - print(f"Picking up resource: {pickup}") - - async def move_picked_up_resource(self, move: ResourceMove): - print(f"Moving picked up resource: {move}") - - async def drop_resource(self, drop: ResourceDrop): - print(f"Dropping resource: {drop}") - - async def request_tip_presence(self) -> List[Optional[bool]]: - """Return tip presence based on the tip tracker state. - - Returns: - A list of length `num_channels` where each element is `True` if a tip is mounted, - `False` if not, or `None` if unknown. - """ - return [self.head[ch].has_tip for ch in range(self.num_channels)] - def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: - return True +from pylabrobot.legacy.liquid_handling.backends.chatterbox import * # noqa: F401,F403,E402 diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 2f11245fb01..ccba26cfeb2 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1,15327 +1,10 @@ -import asyncio -import datetime -import enum -import functools -import logging -import math -import re -import sys import warnings -from abc import ABCMeta -from contextlib import asynccontextmanager, contextmanager -from dataclasses import dataclass, field -from typing import ( - Any, - Awaitable, - Callable, - Coroutine, - Dict, - List, - Literal, - Optional, - Sequence, - Tuple, - Type, - TypedDict, - TypeVar, - Union, - cast, -) - -if sys.version_info < (3, 10): - from typing_extensions import Concatenate, ParamSpec -else: - from typing import Concatenate, ParamSpec -from pylabrobot import audio -from pylabrobot.arms.standard import CartesianCoords -from pylabrobot.heating_shaking.hamilton_backend import HamiltonHeaterShakerInterface -from pylabrobot.liquid_handling.backends.hamilton.base import ( - HamiltonLiquidHandler, -) -from pylabrobot.liquid_handling.backends.hamilton.common import fill_in_defaults -from pylabrobot.liquid_handling.channel_positioning import ( - get_tight_single_resource_liquid_op_offsets, - get_wide_single_resource_liquid_op_offsets, -) -from pylabrobot.liquid_handling.errors import ChannelizedError -from pylabrobot.liquid_handling.liquid_classes.hamilton import ( - HamiltonLiquidClass, - get_star_liquid_class, -) -from pylabrobot.liquid_handling.pipette_batch_scheduling import ( - ChannelBatch, - log_batches, - plan_batches, - validate_channel_selections, -) -from pylabrobot.liquid_handling.standard import ( - Drop, - DropTipRack, - GripDirection, - Mix, - MultiHeadAspirationContainer, - MultiHeadAspirationPlate, - MultiHeadDispenseContainer, - MultiHeadDispensePlate, - Pickup, - PickupTipRack, - PipettingOp, - ResourceDrop, - ResourceMove, - ResourcePickup, - SingleChannelAspiration, - SingleChannelDispense, -) -from pylabrobot.resources import ( - Carrier, - Container, - Coordinate, - Plate, - Resource, - Tip, - TipRack, - TipSpot, - Well, -) -from pylabrobot.resources.barcode import Barcode, Barcode1DSymbology -from pylabrobot.resources.errors import ( - HasTipError, - NoTipError, - TooLittleLiquidError, - TooLittleVolumeError, +warnings.warn( + "Importing from pylabrobot.liquid_handling.backends.hamilton.STAR_backend is deprecated. " + "Use pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_backend instead.", + DeprecationWarning, + stacklevel=2, ) -from pylabrobot.resources.hamilton import ( - HamiltonTip, - TipDropMethod, - TipPickupMethod, - TipSize, -) -from pylabrobot.resources.hamilton.hamilton_decks import ( - HamiltonCoreGrippers, - rails_for_x_coordinate, -) -from pylabrobot.resources.liquid import Liquid -from pylabrobot.resources.rotation import Rotation -from pylabrobot.resources.tip_tracker import does_tip_tracking -from pylabrobot.resources.trash import Trash - -T = TypeVar("T") - - -logger = logging.getLogger("pylabrobot") - -_P = ParamSpec("_P") -_R = TypeVar("_R") - - -def need_iswap_parked( - method: Callable[Concatenate["STARBackend", _P], Coroutine[Any, Any, _R]], -) -> Callable[Concatenate["STARBackend", _P], Coroutine[Any, Any, _R]]: - """Ensure that the iSWAP is in parked position before running command. - - If the iSWAP is not parked, it get's parked before running the command. - """ - - @functools.wraps(method) - async def wrapper(self: "STARBackend", *args, **kwargs): - if self.extended_conf.left_x_drive.iswap_installed and not self.iswap_parked: - await self.park_iswap( - minimum_traverse_height_at_beginning_of_a_command=int(self._iswap_traversal_height * 10) - ) - - return await method(self, *args, **kwargs) - - return wrapper - - -def _requires_head96( - method: Callable[Concatenate["STARBackend", _P], Coroutine[Any, Any, _R]], -) -> Callable[Concatenate["STARBackend", _P], Coroutine[Any, Any, _R]]: - """Ensure that a 96-head is installed before running the command.""" - - @functools.wraps(method) - async def wrapper(self: "STARBackend", *args, **kwargs): - if not self.extended_conf.left_x_drive.core_96_head_installed: - raise RuntimeError( - "This command requires a 96-head, but none is installed. " - "Check your instrument configuration." - ) - return await method(self, *args, **kwargs) - - return wrapper - - -def parse_star_fw_string(resp: str, fmt: str = "") -> dict: - """Parse a machine command or response string according to a format string. - - The format contains names of parameters (always length 2), - followed by an arbitrary number of the following, but always - the same: - - '&': char - - '#': decimal - - '*': hex - - The order of parameters in the format and response string do not - have to (and often do not) match. - - The identifier parameter (id####) is added automatically. - - TODO: string parsing - The firmware docs mention strings in the following format: '...' - However, the length of these is always known (except when reading - barcodes), so it is easier to convert strings to the right number - of '&'. With barcode reading the length of the barcode is included - with the response string. We'll probably do a custom implementation - for that. - - TODO: spaces - We should also parse responses where integers are separated by spaces, - like this: `ua#### #### ###### ###### ###### ######` - - Args: - resp: The response string to parse. - fmt: The format string. - - Raises: - ValueError: if the format string is incompatible with the response. - - Returns: - A dictionary containing the parsed values. - - Examples: - Parsing a string containing decimals (`1111`), hex (`0xB0B`) and chars (`'rw'`): - - ``` - >>> parse_fw_string("aa1111bbrwccB0B", "aa####bb&&cc***") - {'aa': 1111, 'bb': 'rw', 'cc': 2827} - ``` - """ - - # Remove device and cmd identifier from response. - resp = resp[4:] - - # Parse the parameters in the fmt string. - info = {} - - def find_param(param): - name, data = param[0:2], param[2:] - type_ = {"#": "int", "*": "hex", "&": "str"}[data[0]] - - # Build a regex to match this parameter. - exp = { - "int": r"[-+]?[\d ]", - "hex": r"[\da-fA-F ]", - "str": ".", - }[type_] - len_ = len(data.split(" ")[0]) # Get length of first block. - regex = f"{name}((?:{exp}{ {len_} }" - - if param.endswith(" (n)"): - regex += " ?)+)" - is_list = True - else: - regex += "))" - is_list = False - - # Match response against regex, save results in right datatype. - r = re.search(regex, resp) - if r is None: - raise ValueError(f"could not find matches for parameter {name}") - - g = r.groups() - if len(g) == 0: - raise ValueError(f"could not find value for parameter {name}") - m = g[0] - - if is_list: - m = m.split(" ") - - if type_ == "str": - info[name] = m - elif type_ == "int": - info[name] = [int(m_) for m_ in m if m_ != ""] - elif type_ == "hex": - info[name] = [int(m_, base=16) for m_ in m if m_ != ""] - else: - if type_ == "str": - info[name] = m - elif type_ == "int": - info[name] = int(m) - elif type_ == "hex": - info[name] = int(m, base=16) - - # Find params in string. All params are identified by 2 lowercase chars. - param = "" - prevchar = None - for char in fmt: - if char.islower() and prevchar != "(": - if len(param) > 2: - find_param(param) - param = "" - param += char - prevchar = char - if param != "": - find_param(param) # last parameter is not closed by loop. - - # If id not in fmt, add it. - if "id" not in info: - find_param("id####") - - return info - - -class STARModuleError(Exception, metaclass=ABCMeta): - """Base class for all Hamilton backend errors, raised by a single module.""" - - def __init__( - self, - message: str, - trace_information: int, - raw_response: str, - raw_module: str, - ): - self.message = message - self.trace_information = trace_information - self.raw_response = raw_response - self.raw_module = raw_module - - def __repr__(self) -> str: - return f"{self.__class__.__name__}('{self.message}')" - - -class CommandSyntaxError(STARModuleError): - """Command syntax error - - Code: 01 - """ - - -class HardwareError(STARModuleError): - """Hardware error - - Possible cause(s): - drive blocked, low power etc. - - Code: 02 - """ - - -class CommandNotCompletedError(STARModuleError): - """Command not completed - - Possible cause(s): - error in previous sequence (not executed) - - Code: 03 - """ - - -class ClotDetectedError(STARModuleError): - """Clot detected - - Possible cause(s): - LLD not interrupted - - Code: 04 - """ - - -class BarcodeUnreadableError(STARModuleError): - """Barcode unreadable - - Possible cause(s): - bad or missing barcode - - Code: 05 - """ - - -class TipTooLittleVolumeError(STARModuleError): - """Too little liquid - - Possible cause(s): - 1. liquid surface is not detected, - 2. Aspirate / Dispense conditions could not be fulfilled. - - Code: 06 - """ - - -class TipAlreadyFittedError(STARModuleError): - """Tip already fitted - - Possible cause(s): - Repeated attempts to fit a tip or iSwap movement with tips - - Code: 07 - """ - - -class HamiltonNoTipError(STARModuleError): - """No tips - - Possible cause(s): - command was started without fitting tip (tip was not fitted or fell off again) - - Code: 08 - """ - - -class NoCarrierError(STARModuleError): - """No carrier - - Possible cause(s): - load command without carrier - - Code: 09 - """ - - -class NotCompletedError(STARModuleError): - """Not completed - - Possible cause(s): - Command in command buffer was aborted due to an error in a previous command, or command stack - was deleted. - - Code: 10 - """ - - -class DispenseWithPressureLLDError(STARModuleError): - """Dispense with pressure LLD - - Possible cause(s): - dispense with pressure LLD is not permitted - - Code: 11 - """ - - -class NoTeachInSignalError(STARModuleError): - """No Teach In Signal - - Possible cause(s): - X-Movement to LLD reached maximum allowable position with- out detecting Teach in signal - - Code: 12 - """ - - -class LoadingTrayError(STARModuleError): - """Loading Tray error - - Possible cause(s): - position already occupied - - Code: 13 - """ - - -class SequencedAspirationWithPressureLLDError(STARModuleError): - """Sequenced aspiration with pressure LLD - - Possible cause(s): - sequenced aspiration with pressure LLD is not permitted - - Code: 14 - """ - - -class NotAllowedParameterCombinationError(STARModuleError): - """Not allowed parameter combination - - Possible cause(s): - i.e. PLLD and dispense or wrong X-drive assignment - - Code: 15 - """ - - -class CoverCloseError(STARModuleError): - """Cover close error - - Possible cause(s): - cover is not closed and couldn't be locked - - Code: 16 - """ - - -class AspirationError(STARModuleError): - """Aspiration error - - Possible cause(s): - aspiration liquid stream error detected - - Code: 17 - """ - - -class WashFluidOrWasteError(STARModuleError): - """Wash fluid or trash error - - Possible cause(s): - 1. missing wash fluid - 2. trash of particular washer is full - - Code: 18 - """ - - -class IncubationError(STARModuleError): - """Incubation error - - Possible cause(s): - incubator temperature out of limit - - Code: 19 - """ - - -class TADMMeasurementError(STARModuleError): - """TADM measurement error - - Possible cause(s): - overshoot of limits during aspiration or dispensation - - Code: 20, 26 - """ - - -class NoElementError(STARModuleError): - """No element - - Possible cause(s): - expected element not detected - - Code: 21 - """ - - -class ElementStillHoldingError(STARModuleError): - """Element still holding - - Possible cause(s): - "Get command" is sent twice or element is not dropped expected element is missing (lost) - - Code: 22 - """ - - -class ElementLostError(STARModuleError): - """Element lost - - Possible cause(s): - expected element is missing (lost) - - Code: 23 - """ - - -class IllegalTargetPlatePositionError(STARModuleError): - """Illegal target plate position - - Possible cause(s): - 1. over or underflow of iSWAP positions - 2. iSWAP is not in park position during pipetting activities - - Code: 24 - """ - - -class IllegalUserAccessError(STARModuleError): - """Illegal user access - - Possible cause(s): - carrier was manually removed or cover is open (immediate stop is executed) - - Code: 25 - """ - - -class PositionNotReachableError(STARModuleError): - """Position not reachable - - Possible cause(s): - position out of mechanical limits using iSWAP, CoRe gripper or PIP-channels - - Code: 27 - """ - - -class UnexpectedLLDError(STARModuleError): - """unexpected LLD - - Possible cause(s): - liquid level is reached before LLD scanning is started (using PIP or XL channels) - - Code: 28 - """ - - -class AreaAlreadyOccupiedError(STARModuleError): - """area already occupied - - Possible cause(s): - Its impossible to occupy area because this area is already in use - - Code: 29 - """ - - -class ImpossibleToOccupyAreaError(STARModuleError): - """impossible to occupy area - - Possible cause(s): - Area cant be occupied because is no solution for arm prepositioning - - Code: 30 - """ - - -class AntiDropControlError(STARModuleError): - """ - Anti drop controlling out of tolerance. (VENUS only) - - Code: 31 - """ - - -class DecapperError(STARModuleError): - """ - Decapper lock error while screw / unscrew a cap by twister channels. (VENUS only) - - Code: 32 - """ - - -class DecapperHandlingError(STARModuleError): - """ - Decapper station error while lock / unlock a cap. (VENUS only) - - Code: 33 - """ - - -class StopError(STARModuleError): - """ - Hood is open (Not from documentation, but observed) - - Code: 36 - """ - - -class SlaveError(STARModuleError): - """Slave error - - Possible cause(s): - This error code indicates an error in one of slaves. (for error handling purpose using service - software macro code) - - Code: 99 - """ - - -class WrongCarrierError(STARModuleError): - """ - Wrong carrier barcode detected. (VENUS only) - - Code: 100 - """ - - -class NoCarrierBarcodeError(STARModuleError): - """ - Carrier barcode could not be read or is missing. (VENUS only) - - Code: 101 - """ - - -class LiquidLevelError(STARModuleError): - """ - Liquid surface not detected. (VENUS only) - - This error is created from main / slave error 06/70, 06/73 and 06/87. - - Code: 102 - """ - - -class NotDetectedError(STARModuleError): - """ - Carrier not detected at deck end position. (VENUS only) - - Code: 103 - """ - - -class NotAspiratedError(STARModuleError): - """ - Dispense volume exceeds the aspirated volume. (VENUS only) - - This error is created from main / slave error 02/54. - - Code: 104 - """ - - -class ImproperDispensationError(STARModuleError): - """ - The dispensed volume is out of tolerance (may only occur for Nano Pipettor Dispense steps). - (VENUS only) - - This error is created from main / slave error 02/52 and 02/54. - - Code: 105 - """ - - -class NoLabwareError(STARModuleError): - """ - The labware to be loaded was not detected by autoload module. (VENUS only) - - Note: - - May only occur on a Reload Carrier step if the labware property 'MlStarCarPosAreRecognizable' is - set to 1. - - Code: 106 - """ - - -class UnexpectedLabwareError(STARModuleError): - """ - The labware contains unexpected barcode ( may only occur on a Reload Carrier step ). (VENUS only) - - Code: 107 - """ - - -class WrongLabwareError(STARModuleError): - """ - The labware to be reloaded contains wrong barcode ( may only occur on a Reload Carrier step ). - (VENUS only) - - Code: 108 - """ - - -class BarcodeMaskError(STARModuleError): - """ - The barcode read doesn't match the barcode mask defined. (VENUS only) - - Code: 109 - """ - - -class BarcodeNotUniqueError(STARModuleError): - """ - The barcode read is not unique. Previously loaded labware with same barcode was loaded without - unique barcode check. (VENUS only) - - Code: 110 - """ - - -class BarcodeAlreadyUsedError(STARModuleError): - """ - The barcode read is already loaded as unique barcode ( it's not possible to load the same barcode - twice ). (VENUS only) - - Code: 111 - """ - - -class KitLotExpiredError(STARModuleError): - """ - Kit Lot expired. (VENUS only) - - Code: 112 - """ - - -class DelimiterError(STARModuleError): - """ - Barcode contains character which is used as delimiter in result string. (VENUS only) - - Code: 113 - """ - - -class UnknownHamiltonError(STARModuleError): - """Unknown error""" - - -def _module_id_to_module_name(id_): - """Convert a module ID to a module name.""" - return { - "C0": "Master", - "X0": "X-drives", - "I0": "Auto Load", - "W1": "Wash station 1-3", - "W2": "Wash station 4-6", - "T1": "Temperature carrier 1", - "T2": "Temperature carrier 2", - "R0": "ISWAP", - "P1": "Pipetting channel 1", - "P2": "Pipetting channel 2", - "P3": "Pipetting channel 3", - "P4": "Pipetting channel 4", - "P5": "Pipetting channel 5", - "P6": "Pipetting channel 6", - "P7": "Pipetting channel 7", - "P8": "Pipetting channel 8", - "P9": "Pipetting channel 9", - "PA": "Pipetting channel 10", - "PB": "Pipetting channel 11", - "PC": "Pipetting channel 12", - "PD": "Pipetting channel 13", - "PE": "Pipetting channel 14", - "PF": "Pipetting channel 15", - "PG": "Pipetting channel 16", - "H0": "CoRe 96 Head", - "HW": "Pump station 1 station", - "HU": "Pump station 2 station", - "HV": "Pump station 3 station", - "N0": "Nano dispenser", - "D0": "384 dispensing head", - "NP": "Nano disp. pressure controller", - "M1": "Reserved for module 1", - }.get(id_, "Unknown Module") - - -def error_code_to_exception(code: int) -> Type[STARModuleError]: - """Convert an error code to an exception.""" - codes = { - 1: CommandSyntaxError, - 2: HardwareError, - 3: CommandNotCompletedError, - 4: ClotDetectedError, - 5: BarcodeUnreadableError, - 6: TipTooLittleVolumeError, - 7: TipAlreadyFittedError, - 8: HamiltonNoTipError, - 9: NoCarrierError, - 10: NotCompletedError, - 11: DispenseWithPressureLLDError, - 12: NoTeachInSignalError, - 13: LoadingTrayError, - 14: SequencedAspirationWithPressureLLDError, - 15: NotAllowedParameterCombinationError, - 16: CoverCloseError, - 17: AspirationError, - 18: WashFluidOrWasteError, - 19: IncubationError, - 20: TADMMeasurementError, - 21: NoElementError, - 22: ElementStillHoldingError, - 23: ElementLostError, - 24: IllegalTargetPlatePositionError, - 25: IllegalUserAccessError, - 26: TADMMeasurementError, - 27: PositionNotReachableError, - 28: UnexpectedLLDError, - 29: AreaAlreadyOccupiedError, - 30: ImpossibleToOccupyAreaError, - 31: AntiDropControlError, - 32: DecapperError, - 33: DecapperHandlingError, - 99: SlaveError, - 100: WrongCarrierError, - 101: NoCarrierBarcodeError, - 102: LiquidLevelError, - 103: NotDetectedError, - 104: NotAspiratedError, - 105: ImproperDispensationError, - 106: NoLabwareError, - 107: UnexpectedLabwareError, - 108: WrongLabwareError, - 109: BarcodeMaskError, - 110: BarcodeNotUniqueError, - 111: BarcodeAlreadyUsedError, - 112: KitLotExpiredError, - 113: DelimiterError, - } - if code in codes: - return codes[code] - return UnknownHamiltonError - - -def trace_information_to_string(module_identifier: str, trace_information: int) -> str: - """Convert a trace identifier to an error message.""" - table = None - - if module_identifier == "C0": # master - table = { - 10: "CAN error", - 11: "Slave command time out", - 20: "E2PROM error", - 30: "Unknown command", - 31: "Unknown parameter", - 32: "Parameter out of range", - 33: "Parameter does not belong to command, or not all parameters were sent", - 34: "Node name unknown", - 35: "id parameter error", - 37: "node name defined twice", - 38: "faulty XL channel settings", - 39: "faulty robotic channel settings", - 40: "PIP task busy", - 41: "Auto load task busy", - 42: "Miscellaneous task busy", - 43: "Incubator task busy", - 44: "Washer task busy", - 45: "iSWAP task busy", - 46: "CoRe 96 head task busy", - 47: "Carrier sensor doesn't work properly", - 48: "CoRe 384 head task busy", - 49: "Nano pipettor task busy", - 50: "XL channel task busy", - 51: "Tube gripper task busy", - 52: "Imaging channel task busy", - 53: "Robotic channel task busy", - } - elif module_identifier == "I0": # autoload - table = { - 0: "No error", - 20: "No communication to EEPROM", - 30: "Unknown command", - 31: "Unknown parameter", - 32: "Parameter out of range", - 35: "Voltages outside permitted range", - # generic firmware meaning of 36 is "Stop during execution of command" - 36: "Hamilton will not run while the hood is open", - 40: "No parallel processes permitted", - 50: "Scanner X-drive: init position not found", - 51: "Scanner X-drive: stepper motor not initialized", - 52: "Scanner X-drive: movement error (step loss)", - 55: "Scanner rotation drive: drive blocked", - 60: "Carrier Y-drive: init position not found", - 61: "Carrier Y-drive: stepper motor not initialized", - 62: "Carrier Y-drive: movement error (step loss)", - 65: "Carrier Z-drive: init position not found", - 66: "Carrier Z-drive: stepper motor not initialized", - 67: "Carrier Z-drive: movement error (step loss)", - 70: "Barcode scanner: communication error", - 75: "Loading indicator (LED): communication error", - 80: "Identification barcode not readable", - 81: "No carrier present", - 82: "No carrier loaded", - 83: "Loading tray is occupied", - 84: "Data for free definable carrier not correct", - } - elif module_identifier in [ - "PX", - "P1", - "P2", - "P3", - "P4", - "P5", - "P6", - "P7", - "P8", - "P9", - "PA", - "PB", - "PC", - "PD", - "PE", - "PF", - "PG", - ]: - table = { - 0: "No error", - 20: "No communication to EEPROM", - 30: "Unknown command", - 31: "Unknown parameter", - 32: "Parameter out of range", - 35: "Voltages outside permitted range", - 36: "Stop during execution of command", - 37: "Stop during execution of command", - 40: "No parallel processes permitted (Two or more commands sent for the same controlprocess)", - 50: "Dispensing drive init. position not found", - 51: "Dispensing drive not initialized", - 52: "Dispensing drive movement error", - 53: "Maximum volume in tip reached", - 54: "Position outside of permitted area", - 55: "Y-drive blocked", - 56: "Y-drive not initialized", - 57: "Y-drive movement error", - 60: "Z-drive blocked", - 61: "Z-drive not initialized", - 62: "Z-drive movement error", - 63: "Z-drive limit stop not found", - 65: "Squeezer drive blocked. Can you manually unblock the squeezer drive by turning its screw?", - 66: "Squeezer drive not initialized", - 67: "Squeezer drive movement error: Step loss", - 68: "Init position adjustment error", - 70: "No liquid level found (possibly because no liquid was present, or too little liquid was present to trigger cLLD)", - 71: "Not enough liquid present (Immersion depth or surface following position possibly" - "below minimal access range)", - 72: "Auto calibration at pressure (Sensor not possible)", - 73: "No liquid level found with dual LLD", - 74: "Liquid at a not allowed position detected", - 75: "No tip picked up, possibly because no was present at specified position", - 76: "Tip already picked up", - 77: "Tip not dropped", - 78: "Wrong tip picked up", - 80: "Liquid not correctly aspirated", - 81: "Clot detected", - 82: "TADM measurement out of lower limit curve", - 83: "TADM measurement out of upper limit curve", - 84: "Not enough memory for TADM measurement", - 85: "No communication to digital potentiometer", - 86: "ADC algorithm error", - 87: "2nd phase of liquid nt found", - 88: "Not enough liquid present (Immersion depth or surface following position possibly" - "below minimal access range)", - 90: "Limit curve not resettable", - 91: "Limit curve not programmable", - 92: "Limit curve not found", - 93: "Limit curve data incorrect", - 94: "Not enough memory for limit curve", - 95: "Invalid limit curve index", - 96: "Limit curve already stored", - } - elif module_identifier == "H0": # Core 96 head - table = { - 0: "No error", - 20: "No communication to EEPROM", - # 21 is the current-firmware transfer-check error; older firmware reports 20 - 21: "No communication to digital potentiometer", - 25: "Flash EPROM data incorrect", - 26: "Flash EPROM cannot be programmed", - 27: "Flash EPROM cannot be erased", - 28: "Flash EPROM checksum error", - 30: "Unknown command", - 31: "Unknown parameter", - 32: "Parameter out of range", - 35: "Voltage outside permitted range", - 36: "Stop during execution of command", - 37: "The adjustment sensor did not switch", - 40: "No parallel processes permitted", - 50: "Dispensing drive initialization failed", - 51: "Dispensing drive not initialized", - 52: "Dispensing drive movement error", - 53: "Maximum volume in tip reached", - 54: "Position out of permitted area", - 55: "Y drive initialization failed", - 56: "Y drive not initialized", - 57: "Y drive movement error", - 58: "Y drive position outside of permitted area", - 60: "Z drive initialization failed", - 61: "Z drive not initialized", - 62: "Z drive movement error", - 63: "Z drive position outside of permitted area", - 65: "Squeezer drive initialization failed", - 66: "Squeezer drive not initialized", - 67: "Squeezer drive movement error: drive blocked or incremental sensor fault", - 68: "Squeezer drive position outside of permitted area", - 70: "No liquid level found", - 71: "Not enough liquid present", - 75: "No tip picked up", - 76: "Tip already picked up", - 81: "Clot detected", - 82: "TADM measurement out of lower limit curve", - 83: "TADM measurement out of upper limit curve", - 84: "Not enough memory for TADM measurement", - 90: "Limit curve not resettable", - 91: "Limit curve not programmable", - 92: "Limit curve not found", - 93: "Limit curve data incorrect", - 94: "Not enough memory for limit curve", - 95: "Invalid limit curve index", - 96: "Limit curve already stored", - } - elif module_identifier == "R0": # iswap - # These messages are iSWAP-specific. The internal plate gripper (IPG) also - # reports as module R0 but numbers its drives differently, so for an IPG the - # codes from 55 up map to different drives than the ones listed here. - table = { - 20: "No communication to EEPROM", - 30: "Unknown command", - 31: "Unknown parameter", - 32: "Parameter out of range", - 33: "FW doesn't match to HW", - 36: "Stop during execution of command", - 37: "The adjustment sensor did not switch", - 38: "The adjustment sensor cannot be searched", - 40: "No parallel processes permitted", - 41: "No parallel processes permitted", - 42: "No parallel processes permitted", - 50: "Y-drive Initialization failed", - 51: "Y-drive not initialized", - 52: "Y-drive movement error: drive locked or incremental sensor fault", - 53: "Y-drive movement error: position counter over/underflow", - 60: "Z-drive initialization failed", - 61: "Z-drive not initialized", - 62: "Z-drive movement error: drive locked or incremental sensor fault", - 63: "Z-drive movement error: position counter over/underflow", - 70: "Rotation-drive initialization failed", - 71: "Rotation-drive not initialized", - 72: "Rotation-drive movement error: drive locked or incremental sensor fault", - 73: "Rotation-drive movement error: position counter over/underflow", - 80: "Wrist twist drive initialization failed", - 81: "Wrist twist drive not initialized", - 82: "Wrist twist drive movement error: drive locked or incremental sensor fault", - 83: "Wrist twist drive movement error: position counter over/underflow", - 85: "Gripper drive: communication error to gripper DMS digital potentiometer", - 86: "Gripper drive: Auto adjustment of DMS digital potentiometer not possible", - 89: "Gripper drive movement error: drive locked or incremental sensor fault during gripping", - 90: "Gripper drive initialized failed", - 91: "iSWAP not initialized. Call STARBackend.initialize_iswap().", - 92: "Gripper drive movement error: drive locked or incremental sensor fault during release", - 93: "Gripper drive movement error: position counter over/underflow", - 94: "Plate not found", - 96: "Plate not available", - 97: "Unexpected object found", - } - elif module_identifier == "X0": # X-drives - table = { - 0: "No error", - 20: "Transmission error (I2C bus or EEPROM)", - 25: "Flash EPROM data incorrect", - 26: "Flash EPROM cannot be programmed", - 27: "Flash EPROM cannot be erased", - 28: "Flash EPROM checksum error", - 30: "Unknown command", - 31: "Unknown parameter", - 32: "Parameter out of range", - 35: "Voltages outside permitted range", - # older firmware reports 36 as an emergency-stop / cover-open event - 36: "Stop during execution of command", - 40: "No parallel processes permitted (X drive 1)", - 41: "No parallel processes permitted (X drive 2)", - 42: "No parallel processes permitted (reserve drive)", - 50: "X drive 1: initialization failed", - 51: "X drive 1: drive not initialized", - 52: "X drive 1: movement error (drive blocked or lag too high)", - 53: "X drive 1: position error (drive displaced)", - 54: "X drive 1: dispense-on-fly error", - 55: "X drive 1: positioning-to-dispense-on-fly error", - 70: "X drive 2: initialization failed", - 71: "X drive 2: drive not initialized", - 72: "X drive 2: movement error (drive blocked or lag too high)", - 73: "X drive 2: position error (drive displaced)", - 74: "X drive 2: dispense-on-fly error", - 75: "X drive 2: positioning-to-dispense-on-fly error", - 80: "Reserve drive: initialization failed", - 81: "Reserve drive: drive not initialized", - 82: "Reserve drive: movement error (drive blocked or lag too high)", - } - - if table is not None and trace_information in table: - return table[trace_information] - - return f"Unknown trace information code {trace_information:02}" - - -class STARFirmwareError(Exception): - def __init__(self, errors: Dict[str, STARModuleError], raw_response: str): - self.errors = errors - self.raw_response = raw_response - super().__init__(f"{errors}, {raw_response}") - - -def star_firmware_string_to_error( - error_code_dict: Dict[str, str], - raw_response: str, -) -> STARFirmwareError: - """Convert a firmware string to a STARFirmwareError.""" - - errors = {} - - for module_id, error in error_code_dict.items(): - module_name = _module_id_to_module_name(module_id) - if "/" in error: - # C0 module: error code / trace information - error_code_str, trace_information_str = error.split("/") - error_code, trace_information = ( - int(error_code_str), - int(trace_information_str), - ) - if error_code == 0 and trace_information == 0: - continue - error_class = error_code_to_exception(error_code) - elif module_id == "I0" and error == "36": - error_class = StopError - trace_information = int(error) - else: - # Slave modules: er## (just trace information) - error_class = UnknownHamiltonError - trace_information = int(error) - error_description = trace_information_to_string( - module_identifier=module_id, trace_information=trace_information - ) - errors[module_name] = error_class( - message=error_description, - trace_information=trace_information, - raw_response=error, - raw_module=module_id, - ) - - # If the master error is a SlaveError, remove it from the errors dict. - if isinstance(errors.get("Master"), SlaveError): - errors.pop("Master") - - return STARFirmwareError(errors=errors, raw_response=raw_response) - - -def convert_star_module_error_to_plr_error( - error: STARModuleError, -) -> Optional[Exception]: - """Convert an error returned by a specific STAR module to a Hamilton error.""" - # TipAlreadyFittedError -> HasTipError - if isinstance(error, TipAlreadyFittedError): - return HasTipError() - - # HamiltonNoTipError -> NoTipError - if isinstance(error, HamiltonNoTipError): - return NoTipError(error.message) - - if error.trace_information == 75: - return NoTipError(error.message) - - if error.trace_information in {70, 71}: - return TooLittleLiquidError(error.message) - - if error.trace_information in {54}: - return TooLittleVolumeError(error.message) - - return None - - -def convert_star_firmware_error_to_plr_error( - error: STARFirmwareError, -) -> Optional[Exception]: - """Check if a STARFirmwareError can be converted to a native PLR error. If so, return it, else - return `None`.""" - - # if all errors are channel errors, return a ChannelizedError - if all(e.startswith("Pipetting channel ") for e in error.errors): - - def _channel_to_int(channel: str) -> int: - return int(channel.split(" ")[-1]) - 1 # star is 1-indexed, plr is 0-indexed - - errors = { - _channel_to_int(module_name): convert_star_module_error_to_plr_error(error) or error - for module_name, error in error.errors.items() - } - return ChannelizedError(errors=errors, raw_response=error.raw_response) - - return None - - -def _dispensing_mode_for_op(empty: bool, jet: bool, blow_out: bool) -> int: - """from docs: - 0 = Partial volume in jet mode - 1 = Blow out in jet mode, called "empty" in the VENUS liquid editor - 2 = Partial volume at surface - 3 = Blow out at surface, called "empty" in the VENUS liquid editor - 4 = Empty tip at fix position - """ - - if empty: - return 4 - if jet: - return 1 if blow_out else 0 - else: - return 3 if blow_out else 2 - - -@dataclass -class DriveConfiguration: - """Configuration and geometry for an X drive (left or right). - - The installed-module bits combine byte 1 (xl/xr) and byte 2 (xn/xo). The arm - geometry - width, travel range, workspace range - comes from the X-drive range (RU) - and working-envelope (UA) queries, so it is None on a drive built from the module - bits alone (e.g. a simulated configuration) and populated when - `request_extended_configuration` builds the drive. `model` and `reference_point` - follow from `width`. - - Note: the installed modules on left and right drives must be different. - """ - - pip_installed: bool = False - iswap_installed: bool = False - core_96_head_installed: bool = False - nano_pipettor_installed: bool = False - dispensing_head_384_installed: bool = False - xl_channels_installed: bool = False - tube_gripper_installed: bool = False - imaging_channel_installed: bool = False - robotic_channel_installed: bool = False - - width: Optional[float] = None - """Arm width (mm), from the machine configuration.""" - x_range: Optional[Tuple[float, float]] = None - """Drive travel `(min, max)` in mm.""" - workspace_range: Optional[Tuple[float, float]] = None - """Reachable X workspace `(min, max)` in mm.""" - - @property - def model(self) -> str: - """Arm variant derived from `width`: wide arms span both rails, narrow arms one.""" - assert self.width is not None, "arm geometry not resolved" - if self.width > 300: - return "hamilton_legacy_star_dual_rail_arm" - return "hamilton_legacy_star_single_right_rail_arm" - - @property - def reference_point(self) -> Literal["center", "right"]: - """Where along the arm's width the tracked X refers to: the arm center for a - dual-rail arm, the right edge for a single-rail arm.""" - assert self.width is not None, "arm geometry not resolved" - return "center" if self.width > 300 else "right" - - -@dataclass -class MachineConfiguration: - """Response from RM (Request Machine Configuration) command [SFCO.0035].""" - - # kb byte (configuration data 1) - pip_type_1000ul: bool = False - """Bit 0: PIP Type. False = 300ul, True = 1000ul.""" - kb_iswap_installed: bool = False - """Bit 1: ISWAP. False = none, True = installed.""" - main_front_cover_monitoring_installed: bool = False - """Bit 2: Main front cover monitoring. False = none, True = installed.""" - auto_load_installed: bool = False - """Bit 3: Auto load. False = none, True = installed.""" - wash_station_1_installed: bool = False - """Bit 4: Wash station 1. False = none, True = installed.""" - wash_station_2_installed: bool = False - """Bit 5: Wash station 2. False = none, True = installed.""" - temp_controlled_carrier_1_installed: bool = False - """Bit 6: Temperature controlled carrier 1. False = none, True = installed.""" - temp_controlled_carrier_2_installed: bool = False - """Bit 7: Temperature controlled carrier 2. False = none, True = installed.""" - - num_pip_channels: int = 0 - """Number of PIP channels (kp). Range: 0..16.""" - - -@dataclass -class ExtendedConfiguration: - """Response from QM (Request Extended Configuration) command. - - This command returns the full instrument configuration matching the AK - (Set Instrument Configuration) [SFCO.0026] parameter set. - """ - - # ka (configuration data 2, 24-bit) - left_x_drive_large: bool = False - """Bit 0: Left X drive. False = small, True = large.""" - ka_core_96_head_installed: bool = False - """Bit 1: CoRe 96 Head. False = none, True = installed.""" - right_x_drive_large: bool = False - """Bit 2: Right X drive. False = small, True = large.""" - pump_station_1_installed: bool = False - """Bit 3: Pump station 1. False = none, True = installed.""" - pump_station_2_installed: bool = False - """Bit 4: Pump station 2. False = none, True = installed.""" - wash_station_1_type_cr: bool = False - """Bit 5: Type wash station 1. False = G3, True = CR.""" - wash_station_2_type_cr: bool = False - """Bit 6: Type wash station 2. False = G3, True = CR.""" - left_cover_installed: bool = False - """Bit 7: Left cover. False = none, True = installed.""" - right_cover_installed: bool = False - """Bit 8: Right cover. False = none, True = installed.""" - additional_front_cover_monitoring_installed: bool = False - """Bit 9: Additional front cover monitoring. False = none, True = installed.""" - pump_station_3_installed: bool = False - """Bit 10: Pump station 3. False = none, True = installed.""" - multi_channel_nano_pipettor_installed: bool = False - """Bit 11: Multi channel nano pipettor. False = none, True = installed.""" - dispensing_head_384_installed: bool = False - """Bit 12: 384 dispensing head. False = none, True = installed.""" - xl_channels_installed: bool = False - """Bit 13: XL channels. False = none, True = installed.""" - tube_gripper_installed: bool = False - """Bit 14: Tube gripper. False = none, True = installed.""" - waste_direction_left: bool = False - """Bit 15: Waste direction. False = right, True = left.""" - iswap_gripper_wide: bool = False - """Bit 16: iSWAP gripper size. False = small, True = wide.""" - additional_channel_nano_pipettor_installed: bool = False - """Bit 17: Additional channel nano pipettor. False = none, True = installed.""" - imaging_channel_installed: bool = False - """Bit 18: Imaging channel. False = none, True = installed.""" - robotic_channel_installed: bool = False - """Bit 19: Robotic channel. False = none, True = installed.""" - channel_order_ox_first: bool = False - """Bit 20: Channel order. False = XL first, True = OX first.""" - x0_interface_ham_can: bool = False - """Bit 21: X0 interface. False = other, True = Ham CAN.""" - park_heads_with_iswap_off: bool = False - """Bit 22: Park heads with iSWAP. False = on, True = off.""" - - # ke (configuration data 3, 32-bit) - configuration_data_3: int = 0 - """Raw configuration data 3 (ke, 32-bit). Bit definitions are undocumented.""" - - instrument_size_slots: int = 54 - """Instrument size in slots, X range (xt). Default: 54.""" - auto_load_size_slots: int = 54 - """Auto load size in slots (xa). Default: 54.""" - tip_waste_x_position: float = 1340.0 - """Tip waste X-position [mm] (xw). Default: 1340.0.""" - left_x_drive: DriveConfiguration = field(default_factory=DriveConfiguration) - """Left X drive configuration (xl + xn).""" - right_x_drive: Optional[DriveConfiguration] = None - """Right X drive configuration (xr + xo), or None when no right arm is installed.""" - min_iswap_collision_free_position: float = 350.0 - """Minimal iSWAP collision free position for direct X access [mm] (xm). Default: 350.0.""" - max_iswap_collision_free_position: float = 1140.0 - """Maximal iSWAP collision free position for direct X access [mm] (xx). Default: 1140.0.""" - left_x_arm_width: float = 370.0 - """Width of left X arm [mm] (xu). Default: 370.0.""" - right_x_arm_width: float = 370.0 - """Width of right X arm [mm] (xv). Default: 370.0.""" - num_xl_channels: int = 0 - """Number of XL channels (kc). Range: 0..8.""" - num_robotic_channels: int = 0 - """Number of Robotic channels (kr). Range: 0..8.""" - min_raster_pitch_pip_channels: float = 9.0 - """Minimal raster pitch of PIP channels [mm] (ys). Default: 9.0.""" - min_raster_pitch_xl_channels: float = 36.0 - """Minimal raster pitch of XL channels [mm] (kl). Default: 36.0.""" - min_raster_pitch_robotic_channels: float = 36.0 - """Minimal raster pitch of Robotic channels [mm] (km). Default: 36.0.""" - pip_maximal_y_position: float = 606.5 - """PIP maximal Y position [mm] (ym). Default: 606.5.""" - left_arm_min_y_position: float = 6.0 - """Left arm minimal Y position [mm] (yu). Default: 6.0.""" - right_arm_min_y_position: float = 6.0 - """Right arm minimal Y position [mm] (yx). Default: 6.0.""" - - -@dataclass -class PipChannelInformation: - """Installed hardware information for a single pipetting channel (VW command).""" - - ChannelType = Literal["ML_STAR", "ML_STAR_RPC"] - HeadType = Literal["ML_STAR", "ML_STAR_PLE", "ML_STAR_RPC"] - StopDiscType = Literal["core_i", "core_ii"] - PressureADC = Literal["Renesas_X9268", "Analog_Devices_AD5263"] - - channel_type: ChannelType - head_type: HeadType - stop_disc_type: StopDiscType - pressure_adc: PressureADC - - -@dataclass(frozen=True, eq=False) -class Head96Information: - """Information about the installed 96-head.""" - - StopDiscType = Literal["core_i", "core_ii"] - InstrumentType = Literal["legacy", "FM-STAR"] - HeadType = Literal["Low volume head", "High volume head", "96 head II", "96 head TADM", "unknown"] - - fw_version: datetime.date - x_offset: float - """Deck X distance from the X-arm carriage center to head channel A1 (mm), read from - master EEPROM at setup. Mirrors iSWAPInformation.rotation_drive_x_offset.""" - supports_clot_monitoring_clld: bool - stop_disc_type: StopDiscType - instrument_type: InstrumentType - head_type: HeadType - - # === Firmware/variant-derived limits. z_range is set at setup because its max is a hardware - # probe; the Y and dispensing-drive windows are pure functions of fw_version (and the encoder - # resolutions below), so they are exposed as properties rather than stored. === - z_range: Tuple[float, float] - """Z-drive position window (mm); FM-STAR extends it. Set at setup: the min is variant-derived, - the max is read from a hardware probe.""" - - z_speed_range: Tuple[float, float] = (0.25, 100.0) - """Z-drive speed window (mm/s); unchanged across the 2008/2013/2025 firmware, unlike the - version-resolved `y_speed_range`.""" - z_acceleration_range: Tuple[float, float] = (25.0, 500.0) - """Z-drive acceleration window (mm/s2); unchanged across the 2008/2013/2025 firmware (the - pre-2010 encoding differs, the physical range does not).""" - - # === Encoder resolutions (defaulted device facts). Y/Z are unchanged across firmware; the - # dispensing/squeezer resolutions are the 2013+ generation values (2008-era heads differ). === - z_drive_mm_per_increment: float = 0.005 - y_drive_mm_per_increment: float = 0.015625 - dispensing_drive_mm_per_increment: float = 0.001025641026 - dispensing_drive_uL_per_increment: float = 0.019340933 - squeezer_drive_mm_per_increment: float = 0.0002086672009 - - # === Firmware/variant-derived area-of-operation windows (standard units). Pure functions of - # fw_version and the encoder resolutions above, so they are computed on access. === - @property - def y_range(self) -> Tuple[float, float]: - """Y-drive position window (mm); 2013 firmware shifted it from the 2008 range.""" - min_inc, max_inc = (6000, 36000) if self.fw_version.year >= 2010 else (7000, 36200) - return ( - round(min_inc * self.y_drive_mm_per_increment, 2), - round(max_inc * self.y_drive_mm_per_increment, 2), - ) - - @property - def y_speed_range(self) -> Tuple[float, float]: - """Y-drive speed window (mm/s). The pre-2021 max (390.625 = the firmware default, 25000 inc) is - an empirical, deck-tested cap; per firmware version the maxima are 312.5 (2008) and 625 (2013+). - Verify on a pre-2021 head before raising it.""" - return (0.78125, 390.625 if self.fw_version.year <= 2021 else 625.0) - - @property - def y_acceleration_range(self) -> Tuple[float, float]: - """Y-drive acceleration window (mm/s2). The min (5000 inc) is constant; the max rose from 32000 - inc (2008) to 50000 inc (2013+), so it tracks firmware like the Y range / speed.""" - max_inc = 50000 if self.fw_version.year >= 2010 else 32000 - return ( - round(5000 * self.y_drive_mm_per_increment, 2), - round(max_inc * self.y_drive_mm_per_increment, 2), - ) - - @property - def dispensing_drive_range(self) -> Tuple[float, float]: - """Aspirate/dispense piston volume window (uL); applies to both aspirate and dispense. 2013 - firmware widened the max from 62130 inc.""" - max_inc = 64350 if self.fw_version.year >= 2010 else 62130 - return (0.0, round(max_inc * self.dispensing_drive_uL_per_increment, 2)) - - @property - def dispensing_drive_speed_range(self) -> Tuple[float, float]: - """Dispensing-drive speed window (uL/s); 2013 firmware widened the max from 52000 inc.""" - min_inc = 5 # firmware dv minimum (00005 increments/second) - max_inc = 55000 if self.fw_version.year >= 2010 else 52000 - return ( - round(min_inc * self.dispensing_drive_uL_per_increment, 2), - round(max_inc * self.dispensing_drive_uL_per_increment, 2), - ) - - # === Per-drive factory default speed / acceleration (standard units). The Y/Z-drive defaults are - # deliberately not kept here: STARBackend reads them from the machine at setup into mutable - # attributes (head96_{y,z}_drive_{speed,acceleration}_default) so a run can override them. === - @property - def dispensing_drive_speed_default(self) -> float: - """Dispensing-drive default speed (uL/s); constant across firmware.""" - return 261.1 - - @property - def dispensing_drive_acceleration_default(self) -> float: - """Dispensing-drive default acceleration (uL/s2); 2013 firmware raised it.""" - increments = 900000 if self.fw_version.year >= 2010 else 150000 - return round(increments * self.dispensing_drive_uL_per_increment, 2) - - @property - def squeezer_drive_speed_default(self) -> float: - """Squeezer-drive default speed (mm/s); 2013 firmware raised it.""" - increments = 76000 if self.fw_version.year >= 2010 else 16000 - return round(increments * self.squeezer_drive_mm_per_increment, 2) - - @property - def squeezer_drive_acceleration_default(self) -> float: - """Squeezer-drive default acceleration (mm/s2); 2013 firmware raised it.""" - increments = 300000 if self.fw_version.year >= 2010 else 100000 - return round(increments * self.squeezer_drive_mm_per_increment, 2) - - -@dataclass(frozen=True, eq=False) -class iSWAPInformation: - """Device parameters for the installed iSWAP, loaded or resolved at setup. - - Populated once by `STARBackend._set_up_iswap` when the iSWAP is installed - (`extended_conf.left_x_drive.iswap_installed`). Holds two kinds of data: - per-machine calibration read from EEPROM (link lengths, calibrated stops, - offsets), and firmware/hardware-version-dependent device facts (per-drive - area-of-operation ranges and encoder resolutions). Neither changes at - runtime, so the record is treated as immutable post-setup. - """ - - # Two tiers (dataclasses require non-default fields before defaulted fields): - # per-machine calibration read from EEPROM, then defaulted device facts. - # Each tier is ordered by axis/drive (X, Y, Z, rotation, wrist, gripper). - - # === Per-machine calibration (read from EEPROM at setup; no defaults) ====== - fw_version: str - """iSWAP firmware version string (R0 RF response).""" - - # -- X -- - rotation_drive_x_offset: float - """Deck X distance from the X-arm carriage center to the rotation drive - (mm). Stored in master EEPROM as parameter `kg`. Hamilton factory default - is 34.0 mm.""" - - # -- Y -- - rotation_drive_y_max: float - """Upper Y-axis bound of the iSWAP carriage (mm). Parking sits at this Y; - anything past it is in the mechanical-stop region.""" - - # -- rotation drive -- - rotation_drive_predefined_increments: Dict["STARBackend.RotationDriveOrientation", int] - """Calibrated motor-increment positions for the rotation drive's named - stops (LEFT / FRONT / RIGHT / PARKED_RIGHT), read from EEPROM `pw[0..4]`. - Used as anchor points for angle <-> increment conversion in - `_iswap_rotation_drive_increments_to_angle`.""" - - link_1_length: float - """Distance from the rotation joint (joint 1) to the wrist joint (joint 2), - in mm. Hamilton factory default is 138.0 mm; queried from EEPROM via - `iswap_request_link_1_length` (R0 RA ra=pw, slot 9).""" - - # -- wrist drive -- - wrist_drive_predefined_increments: Dict["STARBackend.WristDriveOrientation", int] - """Calibrated motor-increment positions for the wrist drive's named stops - (RIGHT / STRAIGHT / LEFT / REVERSE), read from EEPROM `pt[1..4]`. Used to - determine the per-machine STRAIGHT angle, which anchors forward kinematics - (link-2 angle is measured relative to STRAIGHT).""" - - link_2_length: float - """Distance from the wrist joint (joint 2) to the gripper finger center, - in mm. Hamilton factory default is 138.0 mm; queried from EEPROM via - `iswap_request_link_2_length` (R0 RA ra=pt, slot 9).""" - - # === Firmware/hardware-version-dependent device facts (4th-generation iSWAP, - # the only generation currently supported): per-drive area-of-operation - # ranges and encoder resolutions. Defaulted (same across units of a - # generation), so setup construction is unchanged. Defaults mirror the - # STARBackend class constants tagged `# TODO: remove in v1`. =============== - - # -- Y -- - y_increment_range: Tuple[int, int] = (0, 14_000) - """Y-carriage position range accepted by the YA command, in motor increments - (the mechanical area of operation ends earlier; the per-machine parking bound - is `rotation_drive_y_max`).""" - - y_mm_per_increment: float = 0.046302083 - - y_speed_increment_range: Tuple[int, int] = (50, 8_000) # unit: increments/sec - - # -- Z -- - z_increment_range: Tuple[int, int] = (-187, 26_661) - - z_mm_per_increment: float = 0.01072765 - - z_speed_increment_range: Tuple[int, int] = (50, 15_000) # unit: increments/sec - - z_acceleration_increment_range: Tuple[int, int] = (5, 999) # unit: 1000 increments/sec^2 - - # -- rotation drive (joint 1, W) -- - rotation_increment_range: Tuple[int, int] = (-30_032, 30_032) - - rotation_deg_per_increment: float = 0.00309619077 - - # -- wrist drive (joint 2, T) -- - wrist_increment_range: Tuple[int, int] = (-30_000, 30_000) - - wrist_deg_per_increment: float = 0.00507968798 - - # -- gripper (G) -- - gripper_increment_range: Tuple[int, int] = (12_780, 24_120) # jaw width - - gripper_mm_per_increment: float = 0.00554337 - - -class STARBackend(HamiltonLiquidHandler, HamiltonHeaterShakerInterface): - """Interface for the Hamilton STARBackend.""" - - class iSWAPAxis(enum.IntEnum): - """Axis index for `iswap_request_joint_state` dicts. - - Units are axis-implicit (matches PF400's `Dict[int, float]` keyed by `PFAxis`): - prismatic axes (X/Y/Z, GRIPPER) are in mm; revolute axes (ROTATION, WRIST) - are in degrees. Z is the rotation-drive-bottom Z (sits 13 mm above the - gripper finger plane). - """ - - X = 1 # X-arm carriage (rotation-drive X in deck coords) - Y = 2 # Y carriage at rotation drive - Z = 3 # Z carriage at rotation drive (rotation-drive-bottom Z, deck coords). - # NOT the grip-center Z - that sits ~13 mm below this plane and only - # appears in iswap_request_pose().location.z. - ROTATION = 4 # W joint 1, signed from calibrated FRONT (deg) - WRIST = 5 # T joint 2, signed from motor zero (deg) - GRIPPER = 6 # gripper jaw opening width - - @property - def is_in_kinematic_chain(self) -> bool: - """Whether this axis enters forward kinematics. The gripper is an - addressable actuator but not a chain member - it changes what is held, - not where the gripper frame is.""" - return self is not STARBackend.iSWAPAxis.GRIPPER - - @property - def is_revolute(self) -> bool: - """True for revolute (rotary, deg) axes; False for prismatic (linear, mm).""" - return self in (STARBackend.iSWAPAxis.ROTATION, STARBackend.iSWAPAxis.WRIST) - - PIP_X_MIN_WITH_LEFT_SIDE_PANEL: float = 320.0 - HEAD96_X_MIN_WITH_LEFT_SIDE_PANEL: float = 0.0 - - def __init__( - self, - device_address: Optional[int] = None, - serial_number: Optional[str] = None, - packet_read_timeout: int = 3, - read_timeout: int = 30, - write_timeout: int = 30, - left_side_panel_installed: bool = False, - ): - """Create a new STAR interface. - - Args: - device_address: the USB device address of the Hamilton STARBackend. Only useful if using more than - one Hamilton machine over USB. - serial_number: the serial number of the Hamilton STARBackend. Only useful if using more than one - Hamilton machine over USB. - packet_read_timeout: timeout in seconds for reading a single packet. - read_timeout: timeout in seconds for reading a full response. - write_timeout: timeout in seconds for writing a command. - left_side_panel_installed: if True, restrict PIP channels to x >= 320mm and - the 96-head to x >= 0mm to prevent collisions with the left side panel. - """ - - super().__init__( - device_address=device_address, - packet_read_timeout=packet_read_timeout, - read_timeout=read_timeout, - write_timeout=write_timeout, - id_product=0x8000, - serial_number=serial_number, - ) - - self.left_side_panel_installed = left_side_panel_installed - self._machine_conf: Optional[MachineConfiguration] = None - - self._iswap_parked: Optional[bool] = None - self._num_channels: Optional[int] = None - self._channels_minimum_y_spacing: List[float] = [9.0] * 8 - self._core_parked: Optional[bool] = None - self._extended_conf: Optional[ExtendedConfiguration] = None - self._channel_traversal_height: float = 245.0 - self._iswap_traversal_height: float = 280.0 - # All iSWAP setup state lives in a single dataclass populated by - # `set_up_iswap` from firmware/EEPROM. See `iswap_information` property - # for guarded access. None pre-setup; immutable post-setup. - self._iswap_information: Optional[iSWAPInformation] = None - # Mutable 96-head Y/Z drive speed/acceleration defaults, seeded from the machine's registers at - # setup and overridable via the @property setters (range-checked). None until setup() loads them; - # a move uses the current default when no explicit value is passed. - self._head96_y_drive_speed_default: Optional[float] = None - self._head96_y_drive_acceleration_default: Optional[float] = None - self._head96_z_drive_speed_default: Optional[float] = None - self._head96_z_drive_acceleration_default: Optional[float] = None - self.core_adjustment = Coordinate.zero() - self._unsafe = UnSafe(self) - - self._pip_channel_information: Optional[List[PipChannelInformation]] = None - - self._default_1d_symbology: Barcode1DSymbology = "Code 128 (Subset B and C)" - self._x_grouping_tolerance_mm: float = 0.1 - - self._setup_done = False - - def _min_spacing_between(self, i: int, j: int) -> float: - """Return the firmware-safe minimum Y spacing between channels *i* and *j*. - - For each adjacent pair, takes max() of both channels' spacings and ceiling-rounds - to 0.1mm. For non-adjacent channels, sums these per-pair spacings. - - TODO: migrate to radii model (spacing[i]/2 + spacing[j]/2) to match - compute_channel_offsets. Current max() model is conservative but inconsistent - with channel_positioning.py's diameter-based abstraction. - """ - lo, hi = min(i, j), max(i, j) - if hi - lo == 1: - import math - - spacing = max(self._channels_minimum_y_spacing[lo], self._channels_minimum_y_spacing[hi]) - return math.ceil(spacing * 10) / 10 - return sum(self._min_spacing_between(k, k + 1) for k in range(lo, hi)) - - def _ops_to_fw_positions( - self, ops: Sequence[PipettingOp], use_channels: List[int] - ) -> Tuple[List[int], List[int], List[bool]]: - x_positions, y_positions, channels_involved = super()._ops_to_fw_positions(ops, use_channels) - # TODO: also bound each involved channel's x against the arm's resolved x_range here, - # raising once with every non-compliant (channel, x) pair. Extends the primitive guard - # in `_check_x_arm_reachable` to the high-level pipetting ops that route through here. - if self.left_side_panel_installed: - min_x = round(self.PIP_X_MIN_WITH_LEFT_SIDE_PANEL * 10) - for x, involved in zip(x_positions, channels_involved): - if involved and x < min_x: - raise ValueError( - f"PIP channel x={x / 10}mm is below the minimum " - f"{self.PIP_X_MIN_WITH_LEFT_SIDE_PANEL}mm (left side panel is installed)" - ) - return x_positions, y_positions, channels_involved - - @property - def machine_conf(self) -> MachineConfiguration: - """Machine configuration.""" - if self._machine_conf is None: - raise RuntimeError("has not loaded machine_conf, forgot to call `setup`?") - return self._machine_conf - - @property - def autoload_installed(self) -> bool: - """Deprecated. Use `machine_conf.auto_load_installed`.""" - warnings.warn( - "autoload_installed is deprecated. Use `machine_conf.auto_load_installed` instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.machine_conf.auto_load_installed - - @property - def iswap_installed(self) -> bool: - """Deprecated. Use `extended_conf.left_x_drive.iswap_installed`.""" - warnings.warn( - "iswap_installed is deprecated. Use `extended_conf.left_x_drive.iswap_installed` instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.extended_conf.left_x_drive.iswap_installed - - @property - def core96_head_installed(self) -> bool: - """Deprecated. Use `extended_conf.left_x_drive.core_96_head_installed`.""" - warnings.warn( - "core96_head_installed is deprecated. Use " - "`extended_conf.left_x_drive.core_96_head_installed` instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.extended_conf.left_x_drive.core_96_head_installed - - @property - def num_arms(self) -> int: - return 1 if self.extended_conf.left_x_drive.iswap_installed else 0 - - @property - def head96_installed(self) -> Optional[bool]: - return self.extended_conf.left_x_drive.core_96_head_installed - - @property - def unsafe(self) -> "UnSafe": - """Actions that have a higher risk of damaging the robot. Use with care!""" - return self._unsafe - - @property - def num_channels(self) -> int: - """The number of pipette channels present on the robot.""" - if self._num_channels is None: - raise RuntimeError("has not loaded num_channels, forgot to call `setup`?") - return self._num_channels - - def set_minimum_traversal_height(self, traversal_height: float): - raise NotImplementedError( - "set_minimum_traversal_height is deprecated. use set_minimum_channel_traversal_height or " - "set_minimum_iswap_traversal_height instead." - ) - - def set_minimum_channel_traversal_height(self, traversal_height: float): - """Set the minimum traversal height for the pip channels. - - This refers to the bottom of the pipetting channel when no tip is present, or the bottom of the - tip when a tip is present. This value will be used as the default value for the - `minimal_traverse_height_at_begin_of_command` and `minimal_height_at_command_end` parameters - unless they are explicitly set. - """ - - assert 0 < traversal_height < 285, "Traversal height must be between 0 and 285 mm" - - self._channel_traversal_height = traversal_height - - def set_minimum_iswap_traversal_height(self, traversal_height: float): - """Set the minimum traversal height for the iswap.""" - - assert 0 < traversal_height < 285, "Traversal height must be between 0 and 285 mm" - - self._iswap_traversal_height = traversal_height - - @contextmanager - def iswap_minimum_traversal_height(self, traversal_height: float): - orig = self._iswap_traversal_height - self._iswap_traversal_height = traversal_height - try: - yield - except Exception as e: - self._iswap_traversal_height = orig - raise e - - @property - def iswap_traversal_height(self) -> float: - return self._iswap_traversal_height - - @property - def module_id_length(self): - return 2 - - @property - def extended_conf(self) -> ExtendedConfiguration: - """Extended configuration.""" - if self._extended_conf is None: - raise RuntimeError("has not loaded extended_conf, forgot to call `setup`?") - return self._extended_conf - - @property - def iswap_parked(self) -> bool: - return self._iswap_parked is True - - @property - def core_parked(self) -> bool: - return self._core_parked is True - - @property - def iswap_information(self) -> iSWAPInformation: - """Cached iSWAP setup state (link lengths, EEPROM-calibrated stops, fw version). - - Populated by `setup()` when the iSWAP is installed. Raises if `setup()` - has not run with the iSWAP active. - """ - if self._iswap_information is None: - raise RuntimeError( - "iSWAP information not loaded; ensure the iSWAP is installed and `setup()` has run." - ) - return self._iswap_information - - async def get_iswap_version(self) -> str: - """The iSWAP firmware version, loaded into `iswap_information` during setup.""" - return self.iswap_information.fw_version - - async def request_pip_channel_version(self, channel: int) -> str: - return cast( - str, - (await self.send_command(STARBackend.channel_id(channel), "RF", fmt="rf" + "&" * 17))["rf"], - ) - - async def _pip_channel_request_configuration(self, channel: int) -> PipChannelInformation: - """Request installed hardware for a pipetting channel using the VW command. - - Args: - channel: 0-indexed channel number. - """ - pip_fw = self._parse_firmware_version_datetime(await self.request_pip_channel_version(channel)) - if pip_fw.year <= 2016: - raise RuntimeError( - f"VW (pip channel configuration) is not supported on firmware from 2016 or older " - f"(channel {channel} firmware date: {pip_fw.isoformat()})." - ) - resp: str = await self.send_command(STARBackend.channel_id(channel), "VW") - return self._parse_pip_channel_information(resp) - - @staticmethod - def _parse_pip_channel_information(resp: str) -> PipChannelInformation: - """Parse a VW (pip channel hardware-configuration) firmware response. - - The number of fields in a VW reply varies by firmware. The full form is - 4 fields (channel_type, head_type, stop_disc_type, pressure_adc), but some - firmwares -- including post-2016 ones that pass the year gate in - `_pip_channel_request_configuration` -- return a 2-field short form such as - ``vw0 0``. Missing trailing fields fall back to their baseline ("code 0") - value rather than raising, since the cached information is descriptive - metadata and is not consulted by pipetting logic. This is distinct from the - firmware-year gate in `_pip_channel_request_configuration`, which decides - whether VW is queried at all. - - Behavior for fields that ARE present is identical to the historical parser; - only absent fields are newly defaulted. A reply with zero fields is treated - as a malformed/communication failure and raises, so it is distinguishable - from a known short layout. - - Args: - resp: Raw VW firmware response (e.g. ``"P1VWid0001vw0 0"``). - - Returns: - Parsed `PipChannelInformation`. - - Raises: - ValueError: If the response contains no hardware-configuration fields. - """ - hw_tokens = resp.split("vw")[-1].strip().split() - if not hw_tokens: - raise ValueError(f"Unparsable VW (pip channel configuration) response: {resp!r}") - - def tok(i: int) -> Optional[str]: - return hw_tokens[i] if i < len(hw_tokens) else None - - return PipChannelInformation( - channel_type="ML_STAR_RPC" if tok(0) == "1" else "ML_STAR", - head_type="ML_STAR_PLE" if tok(1) == "1" else "ML_STAR_RPC" if tok(1) == "2" else "ML_STAR", - stop_disc_type="core_i" if tok(2) in ("0", None) else "core_ii", - pressure_adc="Analog_Devices_AD5263" if tok(3) == "1" else "Renesas_X9268", - ) - - def get_id_from_fw_response(self, resp: str) -> Optional[int]: - """Get the id from a firmware response.""" - parsed = parse_star_fw_string(resp, "id####") - if "id" in parsed and parsed["id"] is not None: - return int(parsed["id"]) - return None - - def check_fw_string_error(self, resp: str): - """Raise an error if the firmware response is an error response. - - Raises: - ValueError: if the format string is incompatible with the response. - HamiltonException: if the response contains an error. - """ - - # Parse errors. - module = resp[:2] - if module == "C0": - # C0 sends errors as er##/##. P1 raises errors as er## where the first group is the error - # code, and the second group is the trace information. - # Beyond that, specific errors may be added for individual channels and modules. These - # are formatted as P1##/## H0##/##, etc. These items are added programmatically as - # named capturing groups to the regex. - - exp = r"er(?P[0-9]{2}/[0-9]{2})" - for module in [ - "X0", - "I0", - "W1", - "W2", - "T1", - "T2", - "R0", - "P1", - "P2", - "P3", - "P4", - "P5", - "P6", - "P7", - "P8", - "P9", - "PA", - "PB", - "PC", - "PD", - "PE", - "PF", - "PG", - "H0", - "HW", - "HU", - "HV", - "N0", - "D0", - "NP", - "M1", - ]: - exp += f" ?(?:{module}(?P<{module}>[0-9]{{2}}/[0-9]{{2}}))?" - errors = re.search(exp, resp) - else: - # Other modules send errors as er##, and do not contain slave errors. - exp = f"er(?P<{module}>[0-9]{{2}})" - errors = re.search(exp, resp) - - if errors is not None: - # filter None elements - errors_dict = {k: v for k, v in errors.groupdict().items() if v is not None} - # filter 00 and 00/00 elements, which mean no error. - errors_dict = {k: v for k, v in errors_dict.items() if v not in ["00", "00/00"]} - - has_error = not (errors is None or len(errors_dict) == 0) - if has_error: - he = star_firmware_string_to_error(error_code_dict=errors_dict, raw_response=resp) - - # If there is a faulty parameter error, request which parameter that is. - for module_name, error in he.errors.items(): - if error.message == "Unknown parameter": - # temp. disabled until we figure out how to handle async in parse response (the - # background thread does not have an event loop, and I'm not sure if it should.) - # vp = await self.send_command(module=error.raw_module, command="VP", fmt="vp&&")["vp"] - # he[module_name].message += f" ({vp})" - - he.errors[ - module_name - ].message += " (call lh.backend.request_name_of_last_faulty_parameter)" - - raise he - - def _parse_response(self, resp: str, fmt: str) -> dict: - """Parse a response from the machine.""" - return parse_star_fw_string(resp, fmt) - - def _parse_firmware_version_datetime(self, fw_version: str) -> datetime.date: - """Extract datetime from firmware version string. - - Args: - fw_version: Firmware version string (e.g., "v2021.03.15" or "2023_Q2_v1.4") - - Returns: - A datetime object representing the extracted date - """ - - # Prefer full date patterns like YYYY.MM.DD / YYYY_MM_DD / YYYY-MM-DD - date_match = re.search(r"\b(20\d{2})[._-](\d{2})[._-](\d{2})\b", fw_version) - if date_match: - y, m, d = map(int, date_match.groups()) - return datetime.date(y, m, d) - - # Handle quarter formats like 2023_Q2 -> first day of the quarter - q_match = re.search(r"\b(20\d{2})_Q([1-4])\b", fw_version, flags=re.IGNORECASE) - if q_match: - y = int(q_match.group(1)) - q = int(q_match.group(2)) - month = (q - 1) * 3 + 1 - return datetime.date(y, month, 1) - - # Fall back to year only -> Jan 1st of that year, or None - year_match = re.search(r"\b(20\d{2})\b", fw_version) - if year_match is None: - raise ValueError(f"Could not parse year from firmware version string: '{fw_version}'") - return datetime.date(int(year_match.group(1)), 1, 1) - - async def setup( - self, - skip_instrument_initialization=False, - skip_pip=False, - skip_autoload=False, - skip_iswap=False, - skip_core96_head=False, - ): - """Creates a USB connection and finds read/write interfaces. - - Args: - skip_autoload: if True, skip initializing the autoload module, if applicable. - skip_iswap: if True, skip initializing the iSWAP module, if applicable. - skip_core96_head: if True, skip initializing the CoRe 96 head module, if applicable. - """ - - await super().setup() - - self.id_ = 0 - - # Request machine information - self._machine_conf = await self.request_machine_configuration() - self._extended_conf = await self.request_extended_configuration() - self._head96_information: Optional[Head96Information] = None - - initialized = await self.request_instrument_initialization_status() - - if not initialized: - if not skip_instrument_initialization: - logger.info("Running backend initialization procedure.") - - await self.pre_initialize_instrument() - else: - # pre_initialize only runs when the robot is not initialized - # pre_initialize will move all channels to Z safety - # so if we skip pre_initialize, we need to raise the channels ourselves - await self.move_all_channels_in_z_safety() - if self.extended_conf.left_x_drive.core_96_head_installed: - # raise the 96-head to Z-safety before the iSWAP (shared left X-drive) moves in set_up_iswap. - # head96_move_to_z_safety can't be used yet: Head96Information is built in set_up_core96_head. - await self._head96_probe_z_max() - - tip_presences = await self.request_tip_presence() - self._num_channels = len(tip_presences) - - async def set_up_pip(): - if skip_pip: - # Skip pip-channel I/O; the __init__ defaults stand in. - # TODO: does not yet gate request_tip_presence or instrument-init moves. - return - if not initialized or any(tip_presences): - await self.initialize_pip() - self._channels_minimum_y_spacing = await self.channels_request_y_minimum_spacing() - - # VW is not supported on firmware from 2016 or older (see issue #1004). Skip the - # query there and leave the cache as None; otherwise populate it for every channel. - pip_fw = self._parse_firmware_version_datetime(await self.request_pip_channel_version(0)) - if pip_fw.year <= 2016: - self._pip_channel_information = None - else: - self._pip_channel_information = [ - await self._pip_channel_request_configuration(ch) for ch in range(self.num_channels) - ] - - async def set_up_autoload(): - if self.machine_conf.auto_load_installed and not skip_autoload: - autoload_initialized = await self.request_autoload_initialization_status() - if not autoload_initialized: - await self.initialize_autoload() - - await self.park_autoload() - - async def set_up_iswap(): - if self.extended_conf.left_x_drive.iswap_installed and not skip_iswap: - iswap_initialized = await self.request_iswap_initialization_status() - if not iswap_initialized: - await self.initialize_iswap() - - await self.park_iswap( - minimum_traverse_height_at_beginning_of_a_command=int(self._iswap_traversal_height * 10) - ) - - rot_predefined = await self._iswap_rotation_drive_request_predefined_increments() - wrist_predefined = await self._iswap_wrist_drive_request_predefined_increments() - - self._iswap_information = iSWAPInformation( - fw_version=await self.request_iswap_version(), - rotation_drive_x_offset=await self._iswap_rotation_drive_request_x_offset(), - rotation_drive_y_max=await self._iswap_rotation_drive_request_y_max(), - link_1_length=await self.iswap_request_link_1_length(), - link_2_length=await self.iswap_request_link_2_length(), - rotation_drive_predefined_increments={ - STARBackend.RotationDriveOrientation.LEFT: rot_predefined["left"], - STARBackend.RotationDriveOrientation.FRONT: rot_predefined["front"], - STARBackend.RotationDriveOrientation.RIGHT: rot_predefined["right"], - STARBackend.RotationDriveOrientation.PARKED_RIGHT: rot_predefined["parking"], - }, - wrist_drive_predefined_increments={ - STARBackend.WristDriveOrientation.RIGHT: wrist_predefined["right"], - STARBackend.WristDriveOrientation.STRAIGHT: wrist_predefined["straight"], - STARBackend.WristDriveOrientation.LEFT: wrist_predefined["left"], - STARBackend.WristDriveOrientation.REVERSE: wrist_predefined["reverse"], - }, - ) - - async def set_up_core96_head(): - if self.extended_conf.left_x_drive.core_96_head_installed and not skip_core96_head: - # Initialize 96-head - core96_head_initialized = await self.request_core_96_head_initialization_status() - if not core96_head_initialized: - await self.initialize_core_96_head( - trash96=self.deck.get_trash_area96(), - z_position_at_the_command_end=self._channel_traversal_height, - ) - - # Cache firmware version and configuration for version-specific behavior - fw_version = await self.head96_request_firmware_version() - configuration_96head = await self._head96_request_configuration() - head96_type = await self.head96_request_type() - - instrument_type: Head96Information.InstrumentType = ( - "legacy" if configuration_96head[2] == "0" else "FM-STAR" - ) - self._head96_information = Head96Information( - fw_version=fw_version, - x_offset=await self._head96_request_x_offset(), - supports_clot_monitoring_clld=bool(int(configuration_96head[0])), - stop_disc_type="core_i" if configuration_96head[1] == "0" else "core_ii", - instrument_type=instrument_type, - head_type=head96_type, - # probing safe max z position also acts a safety retraction of the head96 on every setup call - z_range=( - self._head96_resolve_z_range(instrument_type)[0], - await self._head96_probe_z_max(), - ), - ) - # Seed the mutable Y/Z drive speed/acceleration defaults from the machine's current - # registers; a run can override them via the head96_*_drive_*_default setters afterwards. - self._head96_y_drive_speed_default = await self.head96_request_y_speed() - self._head96_y_drive_acceleration_default = await self.head96_request_y_acceleration() - self._head96_z_drive_speed_default = await self.head96_request_z_speed() - self._head96_z_drive_acceleration_default = await self.head96_request_z_acceleration() - - async def set_up_arm_modules(): - await set_up_pip() - await set_up_iswap() - await set_up_core96_head() - - await asyncio.gather(set_up_autoload(), set_up_arm_modules()) - - # After setup, STAR will have thrown out anything mounted on the pipetting channels, including - # the core grippers. - self._core_parked = True - - self._setup_done = True - - async def stop(self): - await super().stop() - self._setup_done = False - - @property - def setup_done(self) -> bool: - return self._setup_done - - # ============== LiquidHandlerBackend methods ============== - - # ----------------------------------------------------------------------- - # X-Arm - # ----------------------------------------------------------------------- - - async def x_arm_request_firmware_version(self) -> Tuple[str, datetime.date]: - """Request the X-arm firmware version and build date. - - Returns: - A tuple of (version_string, build_date), e.g. ("1.0S", date(2009, 6, 24)). - """ - - resp = await self.send_command(module="X0", command="RF") - version = resp.split("rf")[-1].split(" ")[0] - build_date = self._parse_firmware_version_datetime(resp) - return version, build_date - - async def experimental_x_arm_move( - self, - x: float, - acceleration_level: int = 3, - current_protection_limiter: int = 7, - ): - """Move the X-arm to an absolute X position with specified acceleration. - - Args: - x: Target X coordinate in mm. Must be within the arm's reachable X range - (see the X-drive's `x_range`). - acceleration_level: Acceleration index (hardware units), 1-5. Default 3. - current_protection_limiter: Motor current limit (hardware units), 0-7. Default 7. - """ - - self._check_x_arm_reachable(x) - if not (1 <= acceleration_level <= 5): - raise ValueError(f"acceleration_level must be between 1 and 5, is {acceleration_level}") - if not (0 <= current_protection_limiter <= 7): - raise ValueError( - f"current_protection_limiter must be between 0 and 7, is {current_protection_limiter}" - ) - - return await self.send_command( - module="X0", - command="XP", - la=f"{round(x * 10):05}", - lr=str(acceleration_level), - lw=str(current_protection_limiter), - ) - - def _check_x_arm_reachable(self, x: float, position: Literal["left", "right"] = "left") -> None: - """Raise if x (mm) is outside the X-drive's travel range (``x_range``). - - ``x`` is the arm's position at its reference point (its center for a dual-rail arm, the - right edge for a single-rail arm), so the bound is that point's travel range ``x_range``. - - Args: - x: Target X coordinate in mm. - position: Which X-arm the move drives. Defaults to the left (main) arm. - - Reading ``extended_conf`` already raises if setup() has not run. - - Raises: - RuntimeError: If the installed arm's geometry was not resolved. - ValueError: If no such arm is installed, or x is outside its ``x_range``. - """ - arm = ( - self.extended_conf.left_x_drive if position == "left" else self.extended_conf.right_x_drive - ) - if arm is None: - raise ValueError(f"No {position} X-arm is installed.") - if arm.x_range is None: - raise RuntimeError(f"{position} X-arm geometry not resolved") - x_min, x_max = arm.x_range - if not x_min <= x <= x_max: - drives = (self.extended_conf.left_x_drive, self.extended_conf.right_x_drive) - label = f"{position} X-arm" if sum(d is not None for d in drives) > 1 else "X-arm" - raise ValueError(f"{label} x={x}mm is outside its drive travel range [{x_min}, {x_max}].") - - # # # # Single-Channel Pipette Commands # # # # - - # # # Machine Query (MEM-READ) Commands: Single-Channel # # # - - async def channel_request_y_minimum_spacing(self, channel_idx: int) -> float: - """Request the minimum Y spacing for a given channel. - - Args: - channel_idx: the channel index to query. (0-indexed) - - Returns: - The minimum Y spacing in mm. - """ - - if not 0 <= channel_idx <= self.num_channels - 1: - raise ValueError( - f"channel_idx must be between 0 and {self.num_channels - 1}, got {channel_idx}." - ) - - resp = await self.send_command( - module=self.channel_id(channel_idx), - command="VY", - fmt="yc### (n)", - ) - return self.y_drive_increment_to_mm(resp["yc"][1]) - - async def channels_request_y_minimum_spacing(self) -> List[float]: - """Query all channels for their minimum Y spacing in parallel. - - Returns: - A list of minimum Y spacings in mm, one per channel. - """ - return list( - await asyncio.gather( - *(self.channel_request_y_minimum_spacing(i) for i in range(self.num_channels)) - ) - ) - - def can_reach_position(self, channel_idx: int, position: Coordinate) -> bool: - """Check if a position is reachable by a channel (center-based).""" - if not (0 <= channel_idx < self.num_channels): - raise ValueError(f"Channel {channel_idx} is out of range for this robot.") - - # frontmost channel can go to y=6, every channel behind it constrains its min Y - spacings = self._channels_minimum_y_spacing - min_y_pos = self.extended_conf.left_arm_min_y_position + sum(spacings[channel_idx + 1 :]) - if position.y < min_y_pos: - return False - - # backmost channel max Y from config, every channel in front constrains its max Y - max_y_pos = self.extended_conf.pip_maximal_y_position - sum(spacings[:channel_idx]) - if position.y > max_y_pos: - return False - - return True - - def ensure_can_reach_position( - self, use_channels: List[int], ops: Sequence[PipettingOp], op_name: str - ): - locs = [(op.resource.get_location_wrt(self.deck, y="c") + op.offset) for op in ops] - cant_reach = [ - channel_idx - for channel_idx, loc in zip(use_channels, locs) - if not self.can_reach_position(channel_idx, loc) - ] - if len(cant_reach) > 0: - raise ValueError( - f"Channels {cant_reach} cannot reach their target positions in '{op_name}' operation.\n" - "Robots with more than 8 channels have limited Y-axis reach per channel; they don't have random access to the full deck area.\n" - "Try the operation with different channels or a different target position (i.e. different labware placement)." - ) - - class ChannelCycleCounts(TypedDict): - tip_pick_up_cycles: int - tip_discard_cycles: int - aspiration_cycles: int - dispensing_cycles: int - - async def channel_request_cycle_counts(self, channel_idx: int) -> ChannelCycleCounts: - """Request cycle counters for a single channel. - - Returns the number of tip pick-up, tip discard, aspiration, and dispensing cycles - performed by the channel. - - Args: - channel_idx: The channel index to query (0-indexed). - - Returns: - A dict with keys ``tip_pick_up_cycles``, ``tip_discard_cycles``, - ``aspiration_cycles``, and ``dispensing_cycles``. - """ - - if not (0 <= channel_idx < self.num_channels): - raise ValueError( - f"channel_idx must be between 0 and {self.num_channels - 1}, got {channel_idx}." - ) - - resp = await self.send_command( - module=self.channel_id(channel_idx), - command="RV", - fmt="na##########nb##########nc##########nd##########", - ) - return { - "tip_pick_up_cycles": resp["na"], - "tip_discard_cycles": resp["nb"], - "aspiration_cycles": resp["nc"], - "dispensing_cycles": resp["nd"], - } - - async def channels_request_cycle_counts(self) -> List[ChannelCycleCounts]: - """Request cycle counters for all channels. - - Returns: - A list of dicts (one per channel, ordered by channel index), each with keys - ``tip_pick_up_cycles``, ``tip_discard_cycles``, ``aspiration_cycles``, - and ``dispensing_cycles``. - """ - - return list( - await asyncio.gather( - *(self.channel_request_cycle_counts(channel_idx=idx) for idx in range(self.num_channels)) - ) - ) - - # # # ACTION Commands # # # - - async def pick_up_tips( - self, - ops: List[Pickup], - use_channels: List[int], - begin_tip_pick_up_process: Optional[float] = None, - end_tip_pick_up_process: Optional[float] = None, - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - pickup_method: Optional[TipPickupMethod] = None, - ): - """Pick up tips from a resource.""" - - self.ensure_can_reach_position(use_channels, ops, "pick_up_tips") - - x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) - - tip_spots = [op.resource for op in ops] - tips = set(cast(HamiltonTip, tip_spot.get_tip()) for tip_spot in tip_spots) - if len(tips) > 1: - raise ValueError("Cannot mix tips with different tip types.") - ttti = await self.get_or_assign_tip_type_index(tips.pop()) - - max_z = max(op.resource.get_location_wrt(self.deck).z + op.offset.z for op in ops) - max_total_tip_length = max(op.tip.total_tip_length for op in ops) - max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) - - # not sure why this is necessary, but it is according to log files and experiments - if self._get_hamilton_tip([op.resource for op in ops]).tip_size == TipSize.LOW_VOLUME: - max_tip_length += 2 - elif self._get_hamilton_tip([op.resource for op in ops]).tip_size != TipSize.STANDARD_VOLUME: - max_tip_length -= 2 - - tip = ops[0].tip - if not isinstance(tip, HamiltonTip): - raise TypeError("Tip type must be HamiltonTip.") - - begin_tip_pick_up_process = ( - round((max_z + max_total_tip_length) * 10) - if begin_tip_pick_up_process is None - else int(begin_tip_pick_up_process * 10) - ) - end_tip_pick_up_process = ( - round((max_z + max_tip_length) * 10) - if end_tip_pick_up_process is None - else round(end_tip_pick_up_process * 10) - ) - minimum_traverse_height_at_beginning_of_a_command = ( - round(self._channel_traversal_height * 10) - if minimum_traverse_height_at_beginning_of_a_command is None - else round(minimum_traverse_height_at_beginning_of_a_command * 10) - ) - pickup_method = pickup_method or tip.pickup_method - - try: - return await self.pick_up_tip( - x_positions=x_positions, - y_positions=y_positions, - tip_pattern=channels_involved, - tip_type_idx=ttti, - begin_tip_pick_up_process=begin_tip_pick_up_process, - end_tip_pick_up_process=end_tip_pick_up_process, - minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, - pickup_method=pickup_method, - ) - except STARFirmwareError as e: - if plr_e := convert_star_firmware_error_to_plr_error(e): - raise plr_e from e - raise e - - async def drop_tips( - self, - ops: List[Drop], - use_channels: List[int], - drop_method: Optional[TipDropMethod] = None, - begin_tip_deposit_process: Optional[float] = None, - end_tip_deposit_process: Optional[float] = None, - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - z_position_at_end_of_a_command: Optional[float] = None, - ): - """Drop tips to a resource. - - Args: - drop_method: The method to use for dropping tips. If None, the default method for dropping to - tip spots is `DROP`, and everything else is `PLACE_SHIFT`. Note that `DROP` is only the - default if *all* tips are being dropped to a tip spot. - """ - - self.ensure_can_reach_position(use_channels, ops, "drop_tips") - - if drop_method is None: - if any(not isinstance(op.resource, TipSpot) for op in ops): - drop_method = TipDropMethod.PLACE_SHIFT - else: - drop_method = TipDropMethod.DROP - - x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) - - # get highest z position - max_z = max(op.resource.get_location_wrt(self.deck).z + op.offset.z for op in ops) - if drop_method == TipDropMethod.PLACE_SHIFT: - # magic values empirically found in https://github.com/PyLabRobot/pylabrobot/pull/63 - begin_tip_deposit_process = ( - round((max_z + 59.9) * 10) - if begin_tip_deposit_process is None - else round(begin_tip_deposit_process * 10) - ) - end_tip_deposit_process = ( - round((max_z + 49.9) * 10) - if end_tip_deposit_process is None - else round(end_tip_deposit_process * 10) - ) - else: - max_total_tip_length = max(op.tip.total_tip_length for op in ops) - max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) - begin_tip_deposit_process = ( - round((max_z + max_total_tip_length) * 10) - if begin_tip_deposit_process is None - else round(begin_tip_deposit_process * 10) - ) - end_tip_deposit_process = ( - round((max_z + max_tip_length) * 10) - if end_tip_deposit_process is None - else round(end_tip_deposit_process * 10) - ) - - minimum_traverse_height_at_beginning_of_a_command = ( - round(self._channel_traversal_height * 10) - if minimum_traverse_height_at_beginning_of_a_command is None - else round(minimum_traverse_height_at_beginning_of_a_command * 10) - ) - z_position_at_end_of_a_command = ( - round(self._channel_traversal_height * 10) - if z_position_at_end_of_a_command is None - else round(z_position_at_end_of_a_command * 10) - ) - - try: - return await self.discard_tip( - x_positions=x_positions, - y_positions=y_positions, - tip_pattern=channels_involved, - begin_tip_deposit_process=begin_tip_deposit_process, - end_tip_deposit_process=end_tip_deposit_process, - minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, - z_position_at_end_of_a_command=z_position_at_end_of_a_command, - discarding_method=drop_method, - ) - except STARFirmwareError as e: - if plr_e := convert_star_firmware_error_to_plr_error(e): - raise plr_e from e - raise e - - def _assert_valid_resources(self, resources: Sequence[Resource]) -> None: - """Assert that resources are in a valid location for pipetting.""" - for resource in resources: - if resource.get_location_wrt(self.deck).z < 100: - raise ValueError( - f"Resource {resource} is too low: {resource.get_location_wrt(self.deck).z} < 100" - ) - - class LLDMode(enum.Enum): - """Liquid level detection mode.""" - - OFF = 0 - GAMMA = 1 - PRESSURE = 2 - DUAL = 3 - Z_TOUCH_OFF = 4 - - class PressureLLDMode(enum.Enum): - """Pressure liquid level detection mode.""" - - LIQUID = 0 - FOAM = 1 - - async def execute_batched( - self, - func: Callable[[ChannelBatch], Awaitable[T]], - batches: List[ChannelBatch], - min_traverse_height_during_command: Optional[float] = None, - ) -> List[T]: - """Execute a Z-axis callback across pre-planned batches with X/Y positioning. - - Handles inter-batch safety: raises channels between batches, moves X when the - X group changes, and positions Y before calling *func*. On error or - KeyboardInterrupt, channels are moved to Z safety before re-raising. - - Args: - func: Async callback that receives a ``ChannelBatch`` and performs Z-axis work - (e.g. liquid level detection, z-touch probing). Must not move X or Y. - batches: Pre-planned batches from ``plan_batches()``. - min_traverse_height_during_command: Absolute Z height (mm) for inter-batch - channel raises. ``None`` uses full Z safety. - - Returns: - List of results from each batch callback, in batch order. - """ - log_batches(batches) - results: List[T] = [] - try: - prev_batch: Optional[ChannelBatch] = None - for batch in batches: - if prev_batch is not None: - if min_traverse_height_during_command is None: - await self.move_all_channels_in_z_safety() - else: - await self.position_channels_in_z_direction( - {ch: min_traverse_height_during_command for ch in prev_batch.channels} - ) - - if prev_batch is None or not math.isclose(batch.x_position, prev_batch.x_position): - await self.move_channel_x(0, batch.x_position) - - await self.position_channels_in_y_direction(batch.y_positions) - results.append(await func(batch)) - prev_batch = batch - - except Exception: # firmware errors, RuntimeError, etc. - await self.move_all_channels_in_z_safety() - raise - except BaseException: # KeyboardInterrupt, SystemExit — still must raise channels - await self.move_all_channels_in_z_safety() - raise - - return results - - async def _prepare_batched( - self, - containers: List[Container], - use_channels: Optional[List[int]] = None, - resource_offsets: Optional[List[Coordinate]] = None, - x_grouping_tolerance: Optional[float] = None, - min_traverse_height_at_beginning_of_command: Optional[float] = None, - ) -> Tuple[List[int], List[float], List[ChannelBatch]]: - """Validate channels, verify tips, position Z, resolve targets, plan batches. - - Shared setup for any batched channel operation (probing, aspirate, - dispense). Returns everything the caller needs to define its callback - and call ``execute_batched``. - - Returns: - (use_channels, tip_lengths, batches). - """ - if x_grouping_tolerance is None: - x_grouping_tolerance = self._x_grouping_tolerance_mm - - use_channels = validate_channel_selections( - containers=containers, - num_channels=self.num_channels, - use_channels=use_channels, - ) - - # Verify tips and query tip lengths - tip_presence = await self.request_tip_presence() - if not all(tip_presence[idx] for idx in use_channels): - raise RuntimeError("All specified channels must have tips attached.") - tip_lengths = [await self.request_tip_len_on_channel(channel_idx=idx) for idx in use_channels] - - # Z pre-positioning - idle_channels = sorted(set(range(self.num_channels)) - set(use_channels)) - if min_traverse_height_at_beginning_of_command is not None: - await asyncio.gather( - *[ - self.move_channel_stop_disk_z(channel_idx=ch_idx, z=self.MAXIMUM_CHANNEL_Z_POSITION) - for ch_idx in idle_channels - ] - ) - await self.position_channels_in_z_direction( - {ch: min_traverse_height_at_beginning_of_command for ch in use_channels} - ) - else: - await self.move_all_channels_in_z_safety() - - # Plan batches directly from containers (per-batch spread, no-go-zone aware). - batches = plan_batches( - use_channels=use_channels, - containers=containers, - channel_spacings=self._channels_minimum_y_spacing, - wrt_resource=self.deck, - x_tolerance=x_grouping_tolerance, - resource_offsets=resource_offsets, - ) - - return use_channels, tip_lengths, batches - - async def _run_lld_on_channel_batch( - self, - batch: ChannelBatch, - containers: List[Container], - tip_lengths: List[float], - z_cavity_bottom: List[float], - z_top: List[float], - lld_mode: List[LLDMode], - search_speed: float, - n_replicates: int, - ) -> Dict[int, List[Optional[float]]]: - """Per-batch liquid level detection. Override to substitute simulated sensing. - - *lld_mode* is indexed by original container position (``batch.indices[i]``), - so channels within a single batch may use different detection modes concurrently. - - Returns absolute heights keyed by job index (``batch.indices[i]``), so duplicate - channels across batches don't collide. One list per job, with ``None`` entries for - replicates where no liquid was detected. The caller subtracts ``z_cavity_bottom`` - to get heights relative to container bottom. - """ - - def _detect_func(mode: "STARBackend.LLDMode") -> Callable[..., Any]: - return ( - self._move_z_drive_to_liquid_surface_using_clld - if mode == self.LLDMode.GAMMA - else self._search_for_surface_using_plld - ) - - batch_lowest_immers = [ - z_cavity_bottom[i] + tip_lengths[i] - self.DEFAULT_TIP_FITTING_DEPTH for i in batch.indices - ] - batch_start_pos = [ - z_top[i] + tip_lengths[i] - self.DEFAULT_TIP_FITTING_DEPTH + self.SEARCH_START_CLEARANCE_MM - for i in batch.indices - ] - - measurements: Dict[int, List[Optional[float]]] = {orig_idx: [] for orig_idx in batch.indices} - - for _ in range(n_replicates): - results = await asyncio.gather( - *[ - _detect_func(lld_mode[orig_idx])( - channel_idx=channel, - lowest_immers_pos=lip, - start_pos_search=sps, - channel_speed=search_speed, - ) - for channel, lip, sps, orig_idx in zip( - batch.channels, batch_lowest_immers, batch_start_pos, batch.indices - ) - ], - return_exceptions=True, - ) - - current_absolute_liquid_heights = await self.request_pip_height_last_lld() - for local_idx, (ch_idx, result) in enumerate(zip(batch.channels, results)): - orig_idx = batch.indices[local_idx] - if isinstance(result, STARFirmwareError): - error_msg = str(result).lower() - if "no liquid level found" in error_msg or "no liquid was present" in error_msg: - height = None - msg = ( - f"Channel {ch_idx}: No liquid detected. Could be because there is " - f"no liquid in container {containers[orig_idx].name} or liquid level " - f"is too low." - ) - if lld_mode[orig_idx] == self.LLDMode.GAMMA: - msg += " Consider using pressure-based LLD if liquid is believed to exist." - logger.warning(msg) - else: - raise result - elif isinstance(result, Exception): - raise result - else: - height = current_absolute_liquid_heights[ch_idx] - measurements[orig_idx].append(height) - - return measurements - - async def probe_liquid_heights( - self, - containers: List[Container], - use_channels: Optional[List[int]] = None, - resource_offsets: Optional[List[Coordinate]] = None, - lld_mode: Union[LLDMode, List[LLDMode], None] = None, - search_speed: float = 10.0, - n_replicates: int = 1, - # Traverse height parameters (None = full Z safety, float = absolute Z position in mm) - min_traverse_height_at_beginning_of_command: Optional[float] = None, - min_traverse_height_during_command: Optional[float] = None, - z_position_at_end_of_command: Optional[float] = None, - x_grouping_tolerance: Optional[float] = None, - # Deprecated - move_to_z_safety_after: Optional[bool] = None, - ) -> List[float]: - """Probe liquid surface heights in containers using liquid level detection. - - Performs capacitive or pressure-based liquid level detection (LLD) by moving channels to - container positions and sensing the liquid surface. Heights are measured from the bottom - of each container's cavity. - - Uses ``plan_batches`` for X/Y partitioning with per-batch container spread - (respecting no-go zones), then ``execute_batched`` to iterate batches with - Z safety. - - Args: - containers: List of Container objects to probe, one per channel. - use_channels: Channel indices to use for probing (0-indexed). - resource_offsets: Optional XYZ offsets from container centers. When not provided, - ``plan_batches`` auto-spreads channels targeting the same container. - lld_mode: Detection mode. Either a single ``LLDMode`` applied to all containers - (deprecated, removed in v1b1) or a list of ``LLDMode``s (one per container) - allowing mixed GAMMA/PRESSURE within one call. ``None`` (default) applies - GAMMA to all containers. - search_speed: Z-axis search speed in mm/s. Default 10.0 mm/s. - n_replicates: Number of measurements per channel. Default 1. - min_traverse_height_at_beginning_of_command: Absolute Z height (mm) to move involved - channels to before the first batch. Must clear all deck obstacles since channels - travel laterally at this height. None (default) uses full Z safety. - min_traverse_height_during_command: Absolute Z height (mm) to move involved channels to - between batches. None (default) uses full Z safety. - z_position_at_end_of_command: Absolute Z height (mm) to move involved channels to after - probing. None (default) uses full Z safety. - x_grouping_tolerance: Containers within this X distance (mm) are grouped and probed - together. Defaults to ``_x_grouping_tolerance_mm`` (0.1 mm). - move_to_z_safety_after: Deprecated. Use ``z_position_at_end_of_command`` instead. - Returns: - Mean of measured liquid heights for each container (mm from cavity bottom). - - Raises: - ValueError: If ``use_channels`` is empty, contains out-of-range indices, - or if input list lengths don't match. - RuntimeError: If any specified channel lacks a tip. - """ - - if move_to_z_safety_after is not None: - warnings.warn( - "The 'move_to_z_safety_after' parameter is deprecated and will be removed in a " - "future release. Use 'z_position_at_end_of_command' with an appropriate Z height " - "instead. If not set, the default behavior will be to move to full Z safety after " - "the command.", - DeprecationWarning, - stacklevel=2, - ) - - if n_replicates < 1: - raise ValueError(f"n_replicates must be >= 1, got {n_replicates}.") - - if lld_mode is None: - lld_mode = [self.LLDMode.GAMMA] * len(containers) - elif isinstance(lld_mode, self.LLDMode): - warnings.warn( - "Passing a single LLDMode to probe_liquid_heights is deprecated and will be " - "removed in v1b1. Pass a list of LLDModes (one per container) instead.", - DeprecationWarning, - stacklevel=2, - ) - lld_mode = [lld_mode] * len(containers) - elif not isinstance(lld_mode, list): - raise TypeError(f"lld_mode must be List[LLDMode], got {type(lld_mode).__name__}") - - if len(lld_mode) != len(containers): - raise ValueError( - f"lld_mode list length must match containers: got {len(lld_mode)} LLD modes " - f"for {len(containers)} containers." - ) - for m in lld_mode: - if m not in (self.LLDMode.GAMMA, self.LLDMode.PRESSURE): - raise ValueError(f"Unsupported lld_mode: {m!r}") - - z_cavity_bottom = [ - r.get_location_wrt(self.deck, "c", "c", "cavity_bottom").z for r in containers - ] - z_top = [r.get_location_wrt(self.deck, "c", "c", "t").z for r in containers] - - use_channels, tip_lengths, batches = await self._prepare_batched( - containers=containers, - use_channels=use_channels, - resource_offsets=resource_offsets, - x_grouping_tolerance=x_grouping_tolerance, - min_traverse_height_at_beginning_of_command=min_traverse_height_at_beginning_of_command, - ) - batch_results = await self.execute_batched( - func=lambda b: self._run_lld_on_channel_batch( - batch=b, - containers=containers, - tip_lengths=tip_lengths, - z_cavity_bottom=z_cavity_bottom, - z_top=z_top, - lld_mode=lld_mode, - search_speed=search_speed, - n_replicates=n_replicates, - ), - batches=batches, - min_traverse_height_during_command=min_traverse_height_during_command, - ) - - absolute_heights_measurements: Dict[int, List[Optional[float]]] = {} - for batch_measurements in batch_results: - for orig_idx, heights in batch_measurements.items(): - absolute_heights_measurements.setdefault(orig_idx, []).extend(heights) - - # Compute liquid heights relative to well bottom - relative_to_well: List[float] = [] - inconsistent_channels: List[str] = [] - - for idx, (ch, container) in enumerate(zip(use_channels, containers)): - measurements = absolute_heights_measurements[idx] - valid = [m for m in measurements if m is not None] - cavity_bottom = z_cavity_bottom[idx] - - if len(valid) == 0: - relative_to_well.append(0.0) - elif len(valid) == len(measurements): - relative_to_well.append(sum(valid) / len(valid) - cavity_bottom) - else: - inconsistent_channels.append( - f"Channel {ch}: {len(valid)}/{len(measurements)} replicates detected liquid" - ) - - if inconsistent_channels: - raise RuntimeError( - "Inconsistent liquid detection across replicates. " - "This may indicate liquid levels near the detection limit:\n" - + "\n".join(inconsistent_channels) - ) - - if z_position_at_end_of_command is not None: - await self.position_channels_in_z_direction( - {ch: z_position_at_end_of_command for ch in use_channels} - ) - else: - await self.move_all_channels_in_z_safety() - - return relative_to_well - - async def probe_liquid_volumes( - self, - containers: List[Container], - use_channels: Optional[List[int]] = None, - resource_offsets: Optional[List[Coordinate]] = None, - lld_mode: Union[LLDMode, List[LLDMode], None] = None, - search_speed: float = 10.0, - n_replicates: int = 1, - # Traverse height parameters (None = full Z safety, float = absolute Z position in mm) - min_traverse_height_at_beginning_of_command: Optional[float] = None, - min_traverse_height_during_command: Optional[float] = None, - z_position_at_end_of_command: Optional[float] = None, - x_grouping_tolerance: Optional[float] = None, - # Deprecated - move_to_z_safety_after: Optional[bool] = None, - ) -> List[float]: - """Probe liquid volumes in containers by measuring heights and converting to volumes. - - Performs liquid level detection to measure surface heights, then converts heights to - volumes using each container's geometric model. This is a convenience wrapper around - probe_liquid_heights that handles the height-to-volume conversion. - - Args: - containers: List of Container objects to probe, one per channel. All must support height-to-volume conversion via compute_volume_from_height(). - use_channels: Channel indices to use for probing (0-indexed). None (default) uses channels [0, 1, ..., len(containers)-1]. - resource_offsets: Optional XYZ offsets from container centers. Auto-calculated for single containers with odd channel counts. Defaults to container centers. - lld_mode: Detection mode. Either a single ``LLDMode`` applied to all containers - (deprecated, removed in v1b1) or a list of ``LLDMode``s (one per container). ``None`` - (default) applies GAMMA (capacitive cLLD) to all containers. - search_speed: Z-axis search speed in mm/s. Default 10.0 mm/s. - n_replicates: Number of measurements per channel. Default 1. - min_traverse_height_at_beginning_of_command: Absolute Z height (mm) to move involved - channels to before the first batch. Must clear all deck obstacles since channels - travel laterally at this height. None (default) uses full Z safety. - min_traverse_height_during_command: Absolute Z height (mm) to move involved channels to - between batches. None (default) uses full Z safety. - z_position_at_end_of_command: Absolute Z height (mm) to move involved channels to after - probing. None (default) uses full Z safety. - x_grouping_tolerance: Containers within this X distance (mm) are grouped and probed - together. Defaults to ``_x_grouping_tolerance_mm`` (0.1 mm). - move_to_z_safety_after: Deprecated. Use ``z_position_at_end_of_command`` instead. - - Returns: - Volumes in each container (uL). - - Raises: - ValueError: If any container doesn't support height-to-volume conversion. - - Notes: - - Delegates all motion, LLD, validation, and safety logic to probe_liquid_heights - - All containers must support height-volume functions. Volume calculation uses Container.compute_volume_from_height() - """ - - if move_to_z_safety_after is not None: - warnings.warn( - "The 'move_to_z_safety_after' parameter is deprecated and will be removed in a " - "future release. Use 'z_position_at_end_of_command' with an appropriate Z height " - "instead. If not set, the default behavior will be to move to full Z safety after " - "the command.", - DeprecationWarning, - stacklevel=2, - ) - - if any(not resource.supports_compute_height_volume_functions() for resource in containers): - raise ValueError( - "probe_liquid_volumes can only be used with containers that support height<->volume functions." - ) - - liquid_heights = await self.probe_liquid_heights( - containers=containers, - use_channels=use_channels, - resource_offsets=resource_offsets, - lld_mode=lld_mode, - search_speed=search_speed, - n_replicates=n_replicates, - min_traverse_height_at_beginning_of_command=min_traverse_height_at_beginning_of_command, - min_traverse_height_during_command=min_traverse_height_during_command, - z_position_at_end_of_command=z_position_at_end_of_command, - x_grouping_tolerance=x_grouping_tolerance, - ) - - return [ - container.compute_volume_from_height(height) - for container, height in zip(containers, liquid_heights) - ] - - # # # Granular channel control methods # # # - - DISPENSING_DRIVE_VOL_LIMIT_BOTTOM = -45 # vol TODO: confirm with others - DISPENSING_DRIVE_VOL_LIMIT_TOP = 1_250 # vol - - async def channel_dispensing_drive_request_position(self, channel_idx: int) -> float: - """Request the current position of the channel's dispensing drive""" - - if not (0 <= channel_idx < self.num_channels): - raise ValueError(f"channel_idx must be between 0 and {self.num_channels - 1}") - - resp = await self.send_command( - module=STARBackend.channel_id(channel_idx), command="RD", fmt="rd##### #####" - ) - return STARBackend.dispensing_drive_increment_to_volume(resp["rd"]) - - async def channel_dispensing_drive_move_to_volume_position( - self, - channel_idx: int, - vol: float, - flow_rate: float = 200.0, # uL/sec - acceleration: float = 3000.0, # uL/sec**2, - current_limit: int = 5, - ): - """Move channel's dispensing drive to specified volume position - - Args: - channel_idx: Index of the channel to move (0-indexed). - vol: Target volume position to move the dispensing drive piston to (uL). - flow_rate: Speed of the movement (uL/sec). Default is 200.0 uL/sec. - acceleration: Acceleration of the movement (uL/sec**2). Default is 3000.0 uL/sec**2. - current_limit: Current limit for the drive (1-7). Default is 5. - """ - - if not (self.DISPENSING_DRIVE_VOL_LIMIT_BOTTOM <= vol <= self.DISPENSING_DRIVE_VOL_LIMIT_TOP): - raise ValueError( - f"Target dispensing Drive vol must be between {self.DISPENSING_DRIVE_VOL_LIMIT_BOTTOM}" - f" and {self.DISPENSING_DRIVE_VOL_LIMIT_TOP}, is {vol}" - ) - if not (0.9 <= flow_rate <= 632.8): - raise ValueError( - f"Dispensing drive speed must be between 0.9 and 632.8 uL/sec, is {flow_rate}" - ) - if not (234.4 <= acceleration <= 28125.6): - raise ValueError( - f"Dispensing drive acceleration must be between 234.4 and 28125.6 uL/sec**2, is {acceleration}" - ) - if not (1 <= current_limit <= 7): - raise ValueError( - f"Dispensing drive current limit must be between 1 and 7, is {current_limit}" - ) - - current_position = await self.channel_dispensing_drive_request_position(channel_idx=channel_idx) - relative_vol_movement = round(vol - current_position, 1) - relative_vol_movement_increment = STARBackend.dispensing_drive_vol_to_increment( - abs(relative_vol_movement) - ) - speed_increment = STARBackend.dispensing_drive_vol_to_increment(flow_rate) - acceleration_increment = STARBackend.dispensing_drive_vol_to_increment(acceleration) - acceleration_increment_thousands = round(acceleration_increment * 0.001) - - await self.send_command( - module=STARBackend.channel_id(channel_idx), - command="DS", - ds=f"{relative_vol_movement_increment:05}", - dt="0" if relative_vol_movement >= 0 else "1", - dv=f"{speed_increment:05}", - dr=f"{acceleration_increment_thousands:03}", - dw=f"{current_limit}", - ) - - async def empty_tip( - self, - channel_idx: int, - vol: Optional[float] = None, - flow_rate: float = 200.0, # vol/sec - acceleration: float = 3000.0, # vol/sec**2, - current_limit: int = 5, - reset_dispensing_drive_after: bool = True, - ): - """Empty tip by moving to `vol` (default bottom limit), optionally returning plunger position to 0. - - Args: - channel_idx: Index of the channel to empty (0-indexed). - vol: Target volume position to move the dispensing drive piston to (uL). If None, defaults to bottom limit. - flow_rate: Speed of the movement (uL/sec). Default is 200.0 uL/sec. - acceleration: Acceleration of the movement (uL/sec**2). Default is 3000.0 uL/sec**2. - current_limit: Current limit for the drive (1-7). Default is 5. - reset_dispensing_drive_after: Whether to return the dispensing drive to 0 after emptying. Default is True - """ - - if vol is None: - vol = self.DISPENSING_DRIVE_VOL_LIMIT_BOTTOM - - # Empty tip - await self.channel_dispensing_drive_move_to_volume_position( - channel_idx=channel_idx, - vol=vol, - flow_rate=flow_rate, - acceleration=acceleration, - current_limit=current_limit, - ) - - if reset_dispensing_drive_after: - # Reset only channel used back to vol=0.0 position - await self.channel_dispensing_drive_move_to_volume_position( - channel_idx=channel_idx, - vol=0, - flow_rate=flow_rate, - acceleration=acceleration, - current_limit=current_limit, - ) - - async def empty_tips( - self, - channels: Optional[List[int]] = None, - vol: Optional[float] = None, - flow_rate: float = 200.0, # vol/sec - acceleration: float = 3000.0, # vol/sec**2, - current_limit: int = 5, - reset_dispensing_drive_after: bool = True, - ): - """Empty multiple tips by moving to `vol` (default bottom limit), optionally returning plunger position to 0. - - Args: - channels: List of channel indices to empty (0-indexed). If None, all channels with tips mounted are emptied. - vol: Target volume position to move the dispensing drive piston to (uL). If None, defaults to bottom limit. - flow_rate: Speed of the movement (uL/sec). Default is 200.0 uL/sec. - acceleration: Acceleration of the movement (uL/sec**2). Default is 3000.0 uL/sec**2. - current_limit: Current limit for the drive (1-7). Default is 5. - reset_dispensing_drive_after: Whether to return the dispensing drive to 0 after emptying. Default is True - """ - - if channels is None: - channel_occupancy = await self.request_tip_presence() - channels = [ch for ch, occupied in enumerate(channel_occupancy) if occupied] - else: - # Validate that all provided channels are within valid range - if not all(0 <= ch < self.num_channels for ch in channels): - raise ValueError( - f"channel_idx must be between 0 and {self.num_channels - 1}, got {channels}" - ) - - await asyncio.gather( - *[ - self.empty_tip( - channel_idx=ch, - vol=vol, - flow_rate=flow_rate, - acceleration=acceleration, - current_limit=current_limit, - reset_dispensing_drive_after=reset_dispensing_drive_after, - ) - for ch in channels - ] - ) - - # # # Channel Liquid Handling Commands # # # - - async def aspirate( - self, - ops: List[SingleChannelAspiration], - use_channels: List[int], - jet: Optional[List[bool]] = None, - blow_out: Optional[List[bool]] = None, - lld_search_height: Optional[List[float]] = None, - clot_detection_height: Optional[List[float]] = None, - pull_out_distance_transport_air: Optional[List[float]] = None, - second_section_height: Optional[List[float]] = None, - second_section_ratio: Optional[List[float]] = None, - minimum_height: Optional[List[float]] = None, - immersion_depth: Optional[List[float]] = None, - surface_following_distance: Optional[List[float]] = None, - transport_air_volume: Optional[List[float]] = None, - pre_wetting_volume: Optional[List[float]] = None, - lld_mode: Optional[List[LLDMode]] = None, - gamma_lld_sensitivity: Optional[List[int]] = None, - dp_lld_sensitivity: Optional[List[int]] = None, - aspirate_position_above_z_touch_off: Optional[List[float]] = None, - detection_height_difference_for_dual_lld: Optional[List[float]] = None, - swap_speed: Optional[List[float]] = None, - settling_time: Optional[List[float]] = None, - mix_position_from_liquid_surface: Optional[List[float]] = None, - mix_surface_following_distance: Optional[List[float]] = None, - limit_curve_index: Optional[List[int]] = None, - use_2nd_section_aspiration: Optional[List[bool]] = None, - retract_height_over_2nd_section_to_empty_tip: Optional[List[float]] = None, - dispensation_speed_during_emptying_tip: Optional[List[float]] = None, - dosing_drive_speed_during_2nd_section_search: Optional[List[float]] = None, - z_drive_speed_during_2nd_section_search: Optional[List[float]] = None, - cup_upper_edge: Optional[List[float]] = None, - ratio_liquid_rise_to_tip_deep_in: Optional[List[int]] = None, - immersion_depth_2nd_section: Optional[List[float]] = None, - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - min_z_endpos: Optional[float] = None, - liquid_surface_no_lld: Optional[List[float]] = None, - # PLR: - probe_liquid_height: bool = False, - auto_surface_following_distance: bool = False, - hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None, - disable_volume_correction: Optional[List[bool]] = None, - # remove >2026-01 - mix_volume: Optional[List[float]] = None, - mix_cycles: Optional[List[int]] = None, - mix_speed: Optional[List[float]] = None, - immersion_depth_direction: Optional[List[int]] = None, - liquid_surfaces_no_lld: Optional[List[float]] = None, - ): - """Aspirate liquid from the specified channels. - - For all parameters where `None` is the default value, STAR will use the default value, based on - the aspirations. For all list parameters, the length of the list must be equal to the number of - operations. - - Args: - ops: The aspiration operations to perform. - use_channels: The channels to use for the operations. - jet: whether to search for a jet liquid class. Only used on dispense. Default is False. - blow_out: whether to blow out air. Only used on dispense. Note that in the VENUS Liquid - Editor, this is called "empty". Default is False. - - lld_search_height: The height to start searching for the liquid level when using LLD. - clot_detection_height: Unknown, but probably the height to search for clots when doing LLD. - pull_out_distance_transport_air: The distance to pull out when aspirating air, if LLD is - disabled. - second_section_height: The height to start the second section of aspiration. - second_section_ratio: - minimum_height: The minimum height to move to, this is the end of aspiration. The channel will move linearly from the liquid surface to this height over the course of the aspiration. - immersion_depth: The z distance to move after detecting the liquid, can be into or away from the liquid surface. - surface_following_distance: The distance to follow the liquid surface. - transport_air_volume: The volume of air to aspirate after the liquid. - pre_wetting_volume: The volume of liquid to use for pre-wetting. - lld_mode: The liquid level detection mode to use. - gamma_lld_sensitivity: The sensitivity of the gamma LLD. - dp_lld_sensitivity: The sensitivity of the DP LLD. - aspirate_position_above_z_touch_off: If the LLD mode is Z_TOUCH_OFF, this is the height above the bottom of the well (presumably) to aspirate from. - detection_height_difference_for_dual_lld: Difference between the gamma and DP LLD heights if the LLD mode is DUAL. - swap_speed: Swap speed (on leaving liquid) [mm/s]. Must be between 3 and 1600. Default 100. - settling_time: The time to wait after mix. - mix_position_from_liquid_surface: The height to aspirate from for mix (LLD or absolute terms). - mix_surface_following_distance: The distance to follow the liquid surface for mix. - limit_curve_index: The index of the limit curve to use. - - use_2nd_section_aspiration: Whether to use the second section of aspiration. - retract_height_over_2nd_section_to_empty_tip: Unknown. - dispensation_speed_during_emptying_tip: Unknown. - dosing_drive_speed_during_2nd_section_search: Unknown. - z_drive_speed_during_2nd_section_search: Unknown. - cup_upper_edge: Unknown. - - minimum_traverse_height_at_beginning_of_a_command: The minimum height to move to before starting an aspiration. - min_z_endpos: The minimum height to move to, this is the end of aspiration. - - hamilton_liquid_classes: Override the default liquid classes. See pylabrobot/liquid_handling/liquid_classes/hamilton/STARBackend.py - liquid_surface_no_lld: Liquid surface at function without LLD [mm]. Must be between 0 and 360. Defaults to well bottom + liquid height. Should use absolute z. - disable_volume_correction: Whether to disable liquid class volume correction for each operation. - - probe_liquid_height: PLR-specific parameter. If True, probe the liquid height using cLLD before aspirating to set the liquid_height of every operation instead of using the default 0. Liquid heights must not be set when using this function. - auto_surface_following_distance: automatically compute the surface following distance based on the container height<->volume functions. Requires liquid height to be specified or `probe_liquid_height=True`. - """ - - # # # TODO: delete > 2026-01 # # # - if mix_volume is not None or mix_cycles is not None or mix_speed is not None: - raise NotImplementedError( - "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.aspirate instead. " - "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" - ) - - if immersion_depth_direction is not None: - warnings.warn( - "The immersion_depth_direction parameter is deprecated and will be removed in the future. " - "Use positive values for immersion_depth to move into the liquid, and negative values to move " - "out of the liquid.", - DeprecationWarning, - ) - - if liquid_surfaces_no_lld is not None: - warnings.warn( - "The liquid_surfaces_no_lld parameter is deprecated and will be removed in the future. " - "Use liquid_surface_no_lld instead.", - DeprecationWarning, - ) - liquid_surface_no_lld = liquid_surface_no_lld or liquid_surfaces_no_lld - # # # delete # # # - - self.ensure_can_reach_position(use_channels, ops, "aspirate") - - x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) - - n = len(ops) - - if jet is None: - jet = [False] * n - if blow_out is None: - blow_out = [False] * n - - if hamilton_liquid_classes is None: - hamilton_liquid_classes = [] - for i, op in enumerate(ops): - hamilton_liquid_classes.append( - get_star_liquid_class( - tip_volume=op.tip.maximal_volume, - is_core=False, - is_tip=True, - has_filter=op.tip.has_filter, - liquid=Liquid.WATER, # default to WATER - jet=jet[i], - blow_out=blow_out[i], - ) - ) - - # correct volumes using the liquid class - disable_volume_correction = fill_in_defaults(disable_volume_correction, [False] * n) - volumes = [ - hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume - for op, hlc, disabled in zip(ops, hamilton_liquid_classes, disable_volume_correction) - ] - - well_bottoms = [ - op.resource.get_location_wrt(self.deck).z + op.offset.z + op.resource.material_z_thickness - for op in ops - ] - if lld_search_height is None: - lld_search_height = [ - ( - wb + op.resource.get_absolute_size_z() + (2.7 if isinstance(op.resource, Well) else 5) - ) # ? - for wb, op in zip(well_bottoms, ops) - ] - else: - lld_search_height = [(wb + sh) for wb, sh in zip(well_bottoms, lld_search_height)] - clot_detection_height = fill_in_defaults( - clot_detection_height, - default=[ - hlc.aspiration_clot_retract_height if hlc is not None else 0.0 - for hlc in hamilton_liquid_classes - ], - ) - pull_out_distance_transport_air = fill_in_defaults(pull_out_distance_transport_air, [10] * n) - second_section_height = fill_in_defaults(second_section_height, [3.2] * n) - second_section_ratio = fill_in_defaults(second_section_ratio, [618.0] * n) - minimum_height = fill_in_defaults(minimum_height, well_bottoms) - if immersion_depth is None: - immersion_depth = [0.0] * n - immersion_depth_direction = immersion_depth_direction or [ - 0 if (id_ >= 0) else 1 for id_ in immersion_depth - ] - immersion_depth = [ - im * (-1 if immersion_depth_direction[i] else 1) for i, im in enumerate(immersion_depth) - ] - flow_rates = [ - op.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 100.0) - for op, hlc in zip(ops, hamilton_liquid_classes) - ] - transport_air_volume = fill_in_defaults( - transport_air_volume, - default=[ - hlc.aspiration_air_transport_volume if hlc is not None else 0.0 - for hlc in hamilton_liquid_classes - ], - ) - blow_out_air_volumes = [ - (op.blow_out_air_volume or (hlc.aspiration_blow_out_volume if hlc is not None else 0.0)) - for op, hlc in zip(ops, hamilton_liquid_classes) - ] - pre_wetting_volume = fill_in_defaults(pre_wetting_volume, [0.0] * n) - lld_mode = fill_in_defaults(lld_mode, [self.__class__.LLDMode.OFF] * n) - gamma_lld_sensitivity = fill_in_defaults(gamma_lld_sensitivity, [1] * n) - dp_lld_sensitivity = fill_in_defaults(dp_lld_sensitivity, [1] * n) - aspirate_position_above_z_touch_off = fill_in_defaults( - aspirate_position_above_z_touch_off, [0.0] * n - ) - detection_height_difference_for_dual_lld = fill_in_defaults( - detection_height_difference_for_dual_lld, [0.0] * n - ) - swap_speed = fill_in_defaults( - swap_speed, - default=[ - hlc.aspiration_swap_speed if hlc is not None else 100.0 for hlc in hamilton_liquid_classes - ], - ) - settling_time = fill_in_defaults( - settling_time, - default=[ - hlc.aspiration_settling_time if hlc is not None else 0.0 for hlc in hamilton_liquid_classes - ], - ) - mix_volume = [op.mix.volume if op.mix is not None else 0.0 for op in ops] - mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] - mix_position_from_liquid_surface = fill_in_defaults(mix_position_from_liquid_surface, [0.0] * n) - mix_speed = [op.mix.flow_rate if op.mix is not None else 100.0 for op in ops] - mix_surface_following_distance = fill_in_defaults(mix_surface_following_distance, [0.0] * n) - limit_curve_index = fill_in_defaults(limit_curve_index, [0] * n) - - use_2nd_section_aspiration = fill_in_defaults(use_2nd_section_aspiration, [False] * n) - retract_height_over_2nd_section_to_empty_tip = fill_in_defaults( - retract_height_over_2nd_section_to_empty_tip, [0.0] * n - ) - dispensation_speed_during_emptying_tip = fill_in_defaults( - dispensation_speed_during_emptying_tip, [50.0] * n - ) - dosing_drive_speed_during_2nd_section_search = fill_in_defaults( - dosing_drive_speed_during_2nd_section_search, [50.0] * n - ) - z_drive_speed_during_2nd_section_search = fill_in_defaults( - z_drive_speed_during_2nd_section_search, [30.0] * n - ) - cup_upper_edge = fill_in_defaults(cup_upper_edge, [0.0] * n) - - # Deprecated params - warn if passed, but don't use them - if ratio_liquid_rise_to_tip_deep_in is not None: - warnings.warn( - "ratio_liquid_rise_to_tip_deep_in is deprecated and will be removed in a future version.", - DeprecationWarning, - stacklevel=2, - ) - if immersion_depth_2nd_section is not None: - warnings.warn( - "immersion_depth_2nd_section is deprecated and will be removed in a future version.", - DeprecationWarning, - stacklevel=2, - ) - - if probe_liquid_height: - if any(op.liquid_height is not None for op in ops): - raise ValueError("Cannot use probe_liquid_height when liquid heights are set.") - - liquid_heights = await self.probe_liquid_heights( - containers=[op.resource for op in ops], - use_channels=use_channels, - resource_offsets=[op.offset for op in ops], - z_position_at_end_of_command=100, - ) - - # override minimum traversal height because we don't want to move channels up. we are already above the liquid. - minimum_traverse_height_at_beginning_of_a_command = 100 - logger.info(f"Detected liquid heights: {liquid_heights}") - else: - liquid_heights = [op.liquid_height or 0 for op in ops] - - liquid_surfaces_no_lld = liquid_surface_no_lld or [ - wb + lh for wb, lh in zip(well_bottoms, liquid_heights) - ] - - if auto_surface_following_distance: - if any(op.liquid_height is None for op in ops) and not probe_liquid_height: - raise ValueError( - "To use auto_surface_following_distance all liquid heights must be set or probe_liquid_height must be True." - ) - - if any(not op.resource.supports_compute_height_volume_functions() for op in ops): - raise ValueError( - "automatic_surface_following can only be used with containers that support height<->volume functions." - ) - - current_volumes = [ - op.resource.compute_volume_from_height(liquid_heights[i]) for i, op in enumerate(ops) - ] - - # compute new liquid_height after aspiration - liquid_height_after_aspiration = [ - op.resource.compute_height_from_volume(current_volumes[i] - op.volume) - for i, op in enumerate(ops) - ] - - # compute new surface_following_distance - surface_following_distance = [ - liquid_heights[i] - liquid_height_after_aspiration[i] - for i in range(len(liquid_height_after_aspiration)) - ] - else: - surface_following_distance = fill_in_defaults(surface_following_distance, [0.0] * n) - - # check if the surface_following_distance would fall below the minimum height - # if lld is enabled, we expect to find liquid above the well bottom so we don't need to raise an error - if any( - ( - well_bottoms[i] + liquid_heights[i] - surface_following_distance[i] - minimum_height[i] - < -1e-6 - ) - and lld_mode[i] == STARBackend.LLDMode.OFF - for i in range(n) - ): - raise ValueError( - f"surface_following_distance would result in a height that goes below the minimum_height. " - f"Well bottom: {well_bottoms}, liquid height: {liquid_heights}, surface_following_distance: {surface_following_distance}, minimum_height: {minimum_height}" - ) - - # A tip fills to one of two transient peaks that never coexist: volume + pre_wetting - # (pre-wet over-aspiration) and volume + transport_air (air gap drawn above the liquid); - # blow-out air counts toward neither. - over_capacity = [] - for i, op in enumerate(ops): - for label, extra in ( - ("pre-wetting", pre_wetting_volume[i]), - ("transport-air", transport_air_volume[i]), - ): - peak = volumes[i] + extra - if peak > op.tip.maximal_volume: - over_capacity.append((i, label, peak, op.tip.maximal_volume)) - if over_capacity: - raise ValueError( - "Aspiration would exceed tip capacity: " - + "; ".join( - f"channel {i} {label} peak {peak:.1f} uL > tip maximal volume {tip_max:.1f} uL" - for i, label, peak, tip_max in over_capacity - ) - ) - - try: - return await self.aspirate_pip( - aspiration_type=[0 for _ in range(n)], - tip_pattern=channels_involved, - x_positions=x_positions, - y_positions=y_positions, - aspiration_volumes=[round(vol * 10) for vol in volumes], - lld_search_height=[round(lsh * 10) for lsh in lld_search_height], - clot_detection_height=[round(cd * 10) for cd in clot_detection_height], - liquid_surface_no_lld=[round(ls * 10) for ls in liquid_surfaces_no_lld], - pull_out_distance_transport_air=[round(po * 10) for po in pull_out_distance_transport_air], - second_section_height=[round(sh * 10) for sh in second_section_height], - second_section_ratio=[round(sr * 10) for sr in second_section_ratio], - minimum_height=[round(mh * 10) for mh in minimum_height], - immersion_depth=[round(id_ * 10) for id_ in immersion_depth], - immersion_depth_direction=immersion_depth_direction, - surface_following_distance=[round(sfd * 10) for sfd in surface_following_distance], - aspiration_speed=[round(fr * 10) for fr in flow_rates], - transport_air_volume=[round(tav * 10) for tav in transport_air_volume], - blow_out_air_volume=[round(boa * 10) for boa in blow_out_air_volumes], - pre_wetting_volume=[round(pwv * 10) for pwv in pre_wetting_volume], - lld_mode=[mode.value for mode in lld_mode], - gamma_lld_sensitivity=gamma_lld_sensitivity, - dp_lld_sensitivity=dp_lld_sensitivity, - aspirate_position_above_z_touch_off=[ - round(ap * 10) for ap in aspirate_position_above_z_touch_off - ], - detection_height_difference_for_dual_lld=[ - round(dh * 10) for dh in detection_height_difference_for_dual_lld - ], - swap_speed=[round(ss * 10) for ss in swap_speed], - settling_time=[round(st * 10) for st in settling_time], - mix_volume=[round(hv * 10) for hv in mix_volume], - mix_cycles=mix_cycles, - mix_position_from_liquid_surface=[ - round(hp * 10) for hp in mix_position_from_liquid_surface - ], - mix_speed=[round(hs * 10) for hs in mix_speed], - mix_surface_following_distance=[round(hsd * 10) for hsd in mix_surface_following_distance], - limit_curve_index=limit_curve_index, - use_2nd_section_aspiration=use_2nd_section_aspiration, - retract_height_over_2nd_section_to_empty_tip=[ - round(rh * 10) for rh in retract_height_over_2nd_section_to_empty_tip - ], - dispensation_speed_during_emptying_tip=[ - round(ds * 10) for ds in dispensation_speed_during_emptying_tip - ], - dosing_drive_speed_during_2nd_section_search=[ - round(ds * 10) for ds in dosing_drive_speed_during_2nd_section_search - ], - z_drive_speed_during_2nd_section_search=[ - round(zs * 10) for zs in z_drive_speed_during_2nd_section_search - ], - cup_upper_edge=[round(cue * 10) for cue in cup_upper_edge], - minimum_traverse_height_at_beginning_of_a_command=round( - (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 - ), - min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10), - ) - except STARFirmwareError as e: - if plr_e := convert_star_firmware_error_to_plr_error(e): - raise plr_e from e - raise e - - async def dispense( - self, - ops: List[SingleChannelDispense], - use_channels: List[int], - lld_search_height: Optional[List[float]] = None, - liquid_surface_no_lld: Optional[List[float]] = None, - pull_out_distance_transport_air: Optional[List[float]] = None, - second_section_height: Optional[List[float]] = None, - second_section_ratio: Optional[List[float]] = None, - minimum_height: Optional[List[float]] = None, - immersion_depth: Optional[List[float]] = None, - surface_following_distance: Optional[List[float]] = None, - cut_off_speed: Optional[List[float]] = None, - stop_back_volume: Optional[List[float]] = None, - transport_air_volume: Optional[List[float]] = None, - lld_mode: Optional[List[LLDMode]] = None, - dispense_position_above_z_touch_off: Optional[List[float]] = None, - gamma_lld_sensitivity: Optional[List[int]] = None, - dp_lld_sensitivity: Optional[List[int]] = None, - swap_speed: Optional[List[float]] = None, - settling_time: Optional[List[float]] = None, - mix_position_from_liquid_surface: Optional[List[float]] = None, - mix_surface_following_distance: Optional[List[float]] = None, - limit_curve_index: Optional[List[int]] = None, - minimum_traverse_height_at_beginning_of_a_command: Optional[int] = None, - min_z_endpos: Optional[float] = None, - side_touch_off_distance: float = 0, - jet: Optional[List[bool]] = None, - blow_out: Optional[List[bool]] = None, # "empty" in the VENUS liquid editor - empty: Optional[List[bool]] = None, # truly "empty", does not exist in liquid editor, dm4 - # PLR specific - probe_liquid_height: bool = False, - auto_surface_following_distance: bool = False, - hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None, - disable_volume_correction: Optional[List[bool]] = None, - # remove in the future - immersion_depth_direction: Optional[List[int]] = None, - mix_volume: Optional[List[float]] = None, - mix_cycles: Optional[List[int]] = None, - mix_speed: Optional[List[float]] = None, - dispensing_mode: Optional[List[int]] = None, - ): - """Dispense liquid from the specified channels. - - For all parameters where `None` is the default value, STAR will use the default value, based on - the dispenses. For all list parameters, the length of the list must be equal to the number of - operations. - - Args: - ops: The dispense operations to perform. - use_channels: The channels to use for the dispense operations. - lld_search_height: The height to start searching for the liquid level when using LLD. - liquid_surface_no_lld: Liquid surface at function without LLD. - pull_out_distance_transport_air: The distance to pull out the tip for aspirating transport air if LLD is disabled. - second_section_height: The height of the second section. - second_section_ratio: The ratio of [the bottom of the container * 10000] / [the height top of the container]. - minimum_height: The minimum height at the end of the dispense. - immersion_depth: The distance above or below to liquid level to start dispensing. - surface_following_distance: The distance to follow the liquid surface. - cut_off_speed: Unknown. - stop_back_volume: Unknown. - transport_air_volume: The volume of air to dispense before dispensing the liquid. - lld_mode: The liquid level detection mode to use. - dispense_position_above_z_touch_off: The height to move after LLD mode found the Z touch off - position. - gamma_lld_sensitivity: The gamma LLD sensitivity. (1 = high, 4 = low) - dp_lld_sensitivity: The dp LLD sensitivity. (1 = high, 4 = low) - swap_speed: Swap speed (on leaving liquid) [mm/s]. Must be between 3 and 1600. Default 100. - settling_time: The settling time. - mix_position_from_liquid_surface: The height to move above the liquid surface for - mix. - mix_surface_following_distance: The distance to follow the liquid surface for mix. - limit_curve_index: The limit curve to use for the dispense. - minimum_traverse_height_at_beginning_of_a_command: The minimum height to move to before - starting a dispense. - min_z_endpos: The minimum height to move to after a dispense. - side_touch_off_distance: The distance to move to the side from the well for a dispense. - - hamilton_liquid_classes: Override the default liquid classes. See - pylabrobot/liquid_handling/liquid_classes/hamilton/STARBackend.py - disable_volume_correction: Whether to disable liquid class volume correction for each operation. - - jet: Whether to use jetting for each dispense. Defaults to `False` for all. Used for - determining the dispense mode. True for dispense mode 0 or 1. - blow_out: Whether to use "blow out" dispense mode for each dispense. Defaults to `False` for - all. This is labelled as "empty" in the VENUS liquid editor, but "blow out" in the firmware - documentation. True for dispense mode 1 or 3. - empty: Whether to use "empty" dispense mode for each dispense. Defaults to `False` for all. - Truly empty the tip, not available in the VENUS liquid editor, but is in the firmware - documentation. Dispense mode 4. - - probe_liquid_height: PLR-specific parameter. If True, probe the liquid height using cLLD before aspirating to set the liquid_height of every operation instead of using the default 0. Liquid heights must not be set when using this function. - auto_surface_following_distance: automatically compute the surface following distance based on the container height<->volume functions. Requires liquid height to be specified or `probe_liquid_height=True`. - """ - - self.ensure_can_reach_position(use_channels, ops, "dispense") - - n = len(ops) - - if jet is None: - jet = [False] * n - if empty is None: - empty = [False] * n - if blow_out is None: - blow_out = [False] * n - - # # # TODO: delete > 2026-01 # # # - if mix_volume is not None or mix_cycles is not None or mix_speed is not None: - raise NotImplementedError( - "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense instead. " - "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" - ) - - if immersion_depth_direction is not None: - warnings.warn( - "The immersion_depth_direction parameter is deprecated and will be removed in the future. " - "Use positive values for immersion_depth to move into the liquid, and negative values to move " - "out of the liquid.", - DeprecationWarning, - ) - - if dispensing_mode is not None: - warnings.warn( - "The dispensing_mode parameter is deprecated and will be removed in the future. " - "Use the jet, blow_out and empty parameters instead. " - "dispensing_mode currently supersedes the other three parameters if both are provided.", - DeprecationWarning, - ) - dispensing_modes = dispensing_mode - else: - dispensing_modes = [ - _dispensing_mode_for_op(empty=empty[i], jet=jet[i], blow_out=blow_out[i]) - for i in range(len(ops)) - ] - # # # delete # # # - - x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) - - if hamilton_liquid_classes is None: - hamilton_liquid_classes = [] - for i, op in enumerate(ops): - hamilton_liquid_classes.append( - get_star_liquid_class( - tip_volume=op.tip.maximal_volume, - is_core=False, - is_tip=True, - has_filter=op.tip.has_filter, - liquid=Liquid.WATER, # default to WATER - jet=jet[i], - blow_out=blow_out[i], - ) - ) - - # correct volumes using the liquid class - disable_volume_correction = fill_in_defaults(disable_volume_correction, [False] * n) - volumes = [ - hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume - for op, hlc, disabled in zip(ops, hamilton_liquid_classes, disable_volume_correction) - ] - - well_bottoms = [ - op.resource.get_location_wrt(self.deck).z + op.offset.z + op.resource.material_z_thickness - for op in ops - ] - if lld_search_height is None: - lld_search_height = [ - ( - wb + op.resource.get_absolute_size_z() + (2.7 if isinstance(op.resource, Well) else 5) - ) # ? - for wb, op in zip(well_bottoms, ops) - ] - else: - lld_search_height = [wb + sh for wb, sh in zip(well_bottoms, lld_search_height)] - - pull_out_distance_transport_air = fill_in_defaults(pull_out_distance_transport_air, [10.0] * n) - second_section_height = fill_in_defaults(second_section_height, [3.2] * n) - second_section_ratio = fill_in_defaults(second_section_ratio, [618.0] * n) - minimum_height = fill_in_defaults(minimum_height, well_bottoms) - if immersion_depth is None: - immersion_depth = [0.0] * n - immersion_depth_direction = immersion_depth_direction or [ - 0 if (id_ >= 0) else 1 for id_ in immersion_depth - ] - immersion_depth = [ - im * (-1 if immersion_depth_direction[i] else 1) for i, im in enumerate(immersion_depth) - ] - flow_rates = [ - op.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 120.0) - for op, hlc in zip(ops, hamilton_liquid_classes) - ] - cut_off_speed = fill_in_defaults(cut_off_speed, [5.0] * n) - stop_back_volume = fill_in_defaults( - stop_back_volume, - default=[ - hlc.dispense_stop_back_volume if hlc is not None else 0.0 for hlc in hamilton_liquid_classes - ], - ) - transport_air_volume = fill_in_defaults( - transport_air_volume, - default=[ - hlc.dispense_air_transport_volume if hlc is not None else 0.0 - for hlc in hamilton_liquid_classes - ], - ) - blow_out_air_volumes = [ - (op.blow_out_air_volume or (hlc.dispense_blow_out_volume if hlc is not None else 0.0)) - for op, hlc in zip(ops, hamilton_liquid_classes) - ] - lld_mode = fill_in_defaults(lld_mode, [self.__class__.LLDMode.OFF] * n) - dispense_position_above_z_touch_off = fill_in_defaults( - dispense_position_above_z_touch_off, default=[0] * n - ) - gamma_lld_sensitivity = fill_in_defaults(gamma_lld_sensitivity, [1] * n) - dp_lld_sensitivity = fill_in_defaults(dp_lld_sensitivity, [1] * n) - swap_speed = fill_in_defaults( - swap_speed, - default=[ - hlc.dispense_swap_speed if hlc is not None else 10.0 for hlc in hamilton_liquid_classes - ], - ) - settling_time = fill_in_defaults( - settling_time, - default=[ - hlc.dispense_settling_time if hlc is not None else 0.0 for hlc in hamilton_liquid_classes - ], - ) - mix_volume = [op.mix.volume if op.mix is not None else 0.0 for op in ops] - mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] - mix_position_from_liquid_surface = fill_in_defaults(mix_position_from_liquid_surface, [0.0] * n) - mix_speed = [op.mix.flow_rate if op.mix is not None else 1.0 for op in ops] - mix_surface_following_distance = fill_in_defaults(mix_surface_following_distance, [0.0] * n) - limit_curve_index = fill_in_defaults(limit_curve_index, [0] * n) - - if probe_liquid_height: - if any(op.liquid_height is not None for op in ops): - raise ValueError("Cannot use probe_liquid_height when liquid heights are set.") - - liquid_heights = await self.probe_liquid_heights( - containers=[op.resource for op in ops], - use_channels=use_channels, - resource_offsets=[op.offset for op in ops], - z_position_at_end_of_command=100, - ) - - # override minimum traversal height because we don't want to move channels up. we are already above the liquid. - minimum_traverse_height_at_beginning_of_a_command = 100 - logger.info(f"Detected liquid heights: {liquid_heights}") - else: - liquid_heights = [op.liquid_height or 0 for op in ops] - - if auto_surface_following_distance: - if any(op.liquid_height is None for op in ops) and not probe_liquid_height: - raise ValueError( - "To use auto_surface_following_distance all liquid heights must be set or probe_liquid_height must be True." - ) - - if any(not op.resource.supports_compute_height_volume_functions() for op in ops): - raise ValueError( - "automatic_surface_following can only be used with containers that support height<->volume functions." - ) - - current_volumes = [ - op.resource.compute_volume_from_height(liquid_heights[i]) for i, op in enumerate(ops) - ] - - # compute new liquid_height after aspiration - liquid_height_after_aspiration = [ - op.resource.compute_height_from_volume(current_volumes[i] + op.volume) - for i, op in enumerate(ops) - ] - - # compute new surface_following_distance - surface_following_distance = [ - liquid_height_after_aspiration[i] - liquid_heights[i] - for i in range(len(liquid_height_after_aspiration)) - ] - else: - surface_following_distance = fill_in_defaults(surface_following_distance, [0.0] * n) - - liquid_surfaces_no_lld = liquid_surface_no_lld or [ - wb + lh for wb, lh in zip(well_bottoms, liquid_heights) - ] - - try: - ret = await self.dispense_pip( - tip_pattern=channels_involved, - x_positions=x_positions, - y_positions=y_positions, - dispensing_mode=dispensing_modes, - dispense_volumes=[round(vol * 10) for vol in volumes], - lld_search_height=[round(lsh * 10) for lsh in lld_search_height], - liquid_surface_no_lld=[round(ls * 10) for ls in liquid_surfaces_no_lld], - pull_out_distance_transport_air=[round(po * 10) for po in pull_out_distance_transport_air], - second_section_height=[round(sh * 10) for sh in second_section_height], - second_section_ratio=[round(sr * 10) for sr in second_section_ratio], - minimum_height=[round(mh * 10) for mh in minimum_height], - immersion_depth=[round(id_ * 10) for id_ in immersion_depth], - immersion_depth_direction=immersion_depth_direction, - surface_following_distance=[round(sfd * 10) for sfd in surface_following_distance], - dispense_speed=[round(fr * 10) for fr in flow_rates], - cut_off_speed=[round(cs * 10) for cs in cut_off_speed], - stop_back_volume=[round(sbv * 10) for sbv in stop_back_volume], - transport_air_volume=[round(tav * 10) for tav in transport_air_volume], - blow_out_air_volume=[round(boa * 10) for boa in blow_out_air_volumes], - lld_mode=[mode.value for mode in lld_mode], - dispense_position_above_z_touch_off=[ - round(dp * 10) for dp in dispense_position_above_z_touch_off - ], - gamma_lld_sensitivity=gamma_lld_sensitivity, - dp_lld_sensitivity=dp_lld_sensitivity, - swap_speed=[round(ss * 10) for ss in swap_speed], - settling_time=[round(st * 10) for st in settling_time], - mix_volume=[round(mv * 10) for mv in mix_volume], - mix_cycles=mix_cycles, - mix_position_from_liquid_surface=[ - round(mp * 10) for mp in mix_position_from_liquid_surface - ], - mix_speed=[round(ms * 10) for ms in mix_speed], - mix_surface_following_distance=[ - round(msfd * 10) for msfd in mix_surface_following_distance - ], - limit_curve_index=limit_curve_index, - minimum_traverse_height_at_beginning_of_a_command=round( - (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 - ), - min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10), - side_touch_off_distance=round(side_touch_off_distance * 10), - ) - except STARFirmwareError as e: - if plr_e := convert_star_firmware_error_to_plr_error(e): - raise plr_e from e - raise e - - return ret - - @_requires_head96 - async def pick_up_tips96( - self, - pickup: PickupTipRack, - tip_pickup_method: Literal["from_rack", "from_waste", "full_blowout"] = "from_rack", - minimum_height_command_end: Optional[float] = None, - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - experimental_alignment_tipspot_identifier: str = "A1", - ): - """Pick up tips using the 96 head. - - `tip_pickup_method` can be one of the following: - - "from_rack": standard tip pickup from a tip rack. this moves the plunger all the way down before mounting tips. - - "from_waste": - 1. it actually moves the plunger all the way up - 2. mounts tips - 3. moves up like 10mm - 4. moves plunger all the way down - 5. moves to traversal height (tips out of rack) - - "full_blowout": - 1. it actually moves the plunger all the way up - 2. mounts tips - 3. moves to traversal height (tips out of rack) - - Args: - pickup: The standard `PickupTipRack` operation. - tip_pickup_method: The method to use for picking up tips. One of "from_rack", "from_waste", "full_blowout". - minimum_height_command_end: The minimum height to move to at the end of the command. - minimum_traverse_height_at_beginning_of_a_command: The minimum height to move to at the beginning of the command. - experimental_alignment_tipspot_identifier: The tipspot to use for alignment with head's A1 channel. Defaults to "tipspot A1". allowed range is A1 to H12. - """ - - if isinstance(tip_pickup_method, int): - warnings.warn( - "tip_pickup_method as int is deprecated and will be removed in the future. Use string literals instead.", - DeprecationWarning, - ) - tip_pickup_method = {0: "from_rack", 1: "from_waste", 2: "full_blowout"}[tip_pickup_method] - - if tip_pickup_method not in {"from_rack", "from_waste", "full_blowout"}: - raise ValueError(f"Invalid tip_pickup_method: '{tip_pickup_method}'.") - - prototypical_tip = next((tip for tip in pickup.tips if tip is not None), None) - if prototypical_tip is None: - raise ValueError("No tips found in the tip rack.") - if not isinstance(prototypical_tip, HamiltonTip): - raise TypeError("Tip type must be HamiltonTip.") - - ttti = await self.get_or_assign_tip_type_index(prototypical_tip) - - tip_length = prototypical_tip.total_tip_length - fitting_depth = prototypical_tip.fitting_depth - tip_engage_height_from_tipspot = tip_length - fitting_depth - - # Adjust tip engage height based on tip size - if prototypical_tip.tip_size == TipSize.LOW_VOLUME: - tip_engage_height_from_tipspot += 2 - elif prototypical_tip.tip_size != TipSize.STANDARD_VOLUME: - tip_engage_height_from_tipspot -= 2 - - # Compute pickup Z - alignment_tipspot = pickup.resource.get_item(experimental_alignment_tipspot_identifier) - tip_spot_z = alignment_tipspot.get_location_wrt(self.deck).z + pickup.offset.z - z_pickup_position = tip_spot_z + tip_engage_height_from_tipspot - - # Compute full position (used for x/y) - pickup_position = ( - alignment_tipspot.get_location_wrt(self.deck) + alignment_tipspot.center() + pickup.offset - ) - pickup_position.z = round(z_pickup_position, 2) - - self._check_96_position_legal(pickup_position, skip_z=True) - - if tip_pickup_method == "from_rack": - # the STAR will not automatically move the dispensing drive down if it is still up - # so we need to move it down here - # see https://github.com/PyLabRobot/pylabrobot/pull/835 - lowest_dispensing_drive_height_no_tips = 218.19 - await self.head96_dispensing_drive_move_to_position(lowest_dispensing_drive_height_no_tips) - - try: - await self.pick_up_tips_core96( - x_position=abs(round(pickup_position.x * 10)), - x_direction=0 if pickup_position.x >= 0 else 1, - y_position=round(pickup_position.y * 10), - tip_type_idx=ttti, - tip_pickup_method={ - "from_rack": 0, - "from_waste": 1, - "full_blowout": 2, - }[tip_pickup_method], - z_deposit_position=round(pickup_position.z * 10), - minimum_traverse_height_at_beginning_of_a_command=round( - (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 - ), - minimum_height_command_end=round( - (minimum_height_command_end or self._channel_traversal_height) * 10 - ), - ) - except STARFirmwareError as e: - if plr_e := convert_star_firmware_error_to_plr_error(e): - raise plr_e from e - raise e - - @_requires_head96 - async def drop_tips96( - self, - drop: DropTipRack, - minimum_height_command_end: Optional[float] = None, - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - experimental_alignment_tipspot_identifier: str = "A1", - ): - """Drop tips from the 96 head.""" - - if isinstance(drop.resource, TipRack): - tip_spot_a1 = drop.resource.get_item(experimental_alignment_tipspot_identifier) - position = tip_spot_a1.get_location_wrt(self.deck) + tip_spot_a1.center() + drop.offset - tip_rack = tip_spot_a1.parent - assert tip_rack is not None - position.z = tip_rack.get_location_wrt(self.deck).z + 1.45 - # This should be the case for all normal hamilton tip carriers + racks - # In the future, we might want to make this more flexible - assert abs(position.z - 216.4) < 1e-6, f"z position must be 216.4, got {position.z}" - else: - position = self._position_96_head_in_resource(drop.resource) + drop.offset - - self._check_96_position_legal(position, skip_z=True) - - x_direction = 0 if position.x >= 0 else 1 - - return await self.discard_tips_core96( - x_position=abs(round(position.x * 10)), - x_direction=x_direction, - y_position=round(position.y * 10), - z_deposit_position=round(position.z * 10), - minimum_traverse_height_at_beginning_of_a_command=round( - (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 - ), - minimum_height_command_end=round( - (minimum_height_command_end or self._channel_traversal_height) * 10 - ), - ) - - def _is_core96_slave_timeout(self, error: STARFirmwareError) -> bool: - """Check if a firmware error is a slave command timeout from the CoRe 96 head. - - The firmware master has an internal ~5 minute timeout for slave commands. For slow liquid - handling operations (e.g. large volumes at low flow rates), the master may report a timeout - error even though the CoRe 96 head is still working and will finish successfully. - - The error looks like: C0EAid####er99/00 H002/11 - H0 error_code=02 (HardwareError), trace_information=11 (not a standard H0 code, but the - master's "Slave command time out" forwarded to the H0 module). - """ - h0_error = error.errors.get("CoRe 96 Head") - return ( - h0_error is not None - and isinstance(h0_error, HardwareError) - and h0_error.trace_information == 11 - ) - - async def _core96_wait_for_idle(self, timeout: float = 600, poll_interval: float = 5): - """Poll the CoRe 96 head until it finishes its current operation. - - Sends the "move to Z safety" command (C0 EV). If the head is busy, the firmware rejects - with H0 CommandSyntaxError trace 40 ("No parallel processes permitted"). When the head - finishes, EV succeeds and harmlessly ensures the Z axis is at the safe position. - """ - start = asyncio.get_event_loop().time() - while asyncio.get_event_loop().time() - start < timeout: - await asyncio.sleep(poll_interval) - try: - await self.send_command(module="C0", command="EV", read_timeout=10) - logger.info("CoRe 96 head finished (EV succeeded)") - return - except STARFirmwareError as e: - h0_error = e.errors.get("CoRe 96 Head") - if ( - h0_error is not None - and isinstance(h0_error, CommandSyntaxError) - and h0_error.trace_information == 40 - ): - logger.debug("CoRe 96 head still busy, waiting...") - continue - raise - raise TimeoutError("CoRe 96 head did not become idle within timeout") - - @_requires_head96 - async def aspirate96( - self, - aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer], - jet: bool = False, - blow_out: bool = False, - use_lld: bool = False, - pull_out_distance_transport_air: float = 10, - hlc: Optional[HamiltonLiquidClass] = None, - aspiration_type: int = 0, - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - min_z_endpos: Optional[float] = None, - lld_search_height: float = 199.9, - minimum_height: Optional[float] = None, - second_section_height: float = 3.2, - second_section_ratio: float = 618.0, - immersion_depth: float = 0, - surface_following_distance: float = 0, - transport_air_volume: float = 5.0, - pre_wetting_volume: float = 5.0, - gamma_lld_sensitivity: int = 1, - swap_speed: float = 2.0, - settling_time: float = 1.0, - mix_position_from_liquid_surface: float = 0, - mix_surface_following_distance: float = 0, - limit_curve_index: int = 0, - disable_volume_correction: bool = False, - # Deprecated parameters, to be removed in future versions - # rm: >2026-01 - liquid_surface_sink_distance_at_the_end_of_aspiration: float = 0, - minimal_end_height: Optional[float] = None, - air_transport_retract_dist: Optional[float] = None, - maximum_immersion_depth: Optional[float] = None, - surface_following_distance_during_mix: float = 0, - tube_2nd_section_height_measured_from_zm: float = 3.2, - tube_2nd_section_ratio: float = 618.0, - immersion_depth_direction: Optional[int] = None, - mix_volume: float = 0, - mix_cycles: int = 0, - speed_of_mix: float = 0.0, - ): - """Aspirate using the Core96 head. - - Args: - aspiration: The aspiration to perform. - - jet: Whether to search for a jet liquid class. Only used on dispense. - blow_out: Whether to use "blow out" dispense mode. Only used on dispense. Note that this is - labelled as "empty" in the VENUS liquid editor, but "blow out" in the firmware - documentation. - hlc: The Hamiltonian liquid class to use. If `None`, the liquid class will be determined - automatically. - - use_lld: If True, use gamma liquid level detection. If False, use liquid height. - pull_out_distance_transport_air: The distance to retract after aspirating, in millimeters. - - aspiration_type: The type of aspiration to perform. (0 = simple; 1 = sequence; 2 = cup emptied) - minimum_traverse_height_at_beginning_of_a_command: The minimum height to move to before - starting the command. - min_z_endpos: The minimum height to move to after the command. - lld_search_height: The height to search for the liquid level. - minimum_height: Minimum height (maximum immersion depth) - second_section_height: Height of the second section. - second_section_ratio: Ratio of [the diameter of the bottom * 10000] / [the diameter of the top] - immersion_depth: The immersion depth above or below the liquid level. - surface_following_distance: The distance to follow the liquid surface when aspirating. - transport_air_volume: The volume of air to aspirate after the liquid. - pre_wetting_volume: The volume of liquid to use for pre-wetting. - gamma_lld_sensitivity: The sensitivity of the gamma liquid level detection. - swap_speed: Swap speed (on leaving liquid) [1mm/s]. Must be between 0.3 and 160. Default 2. - settling_time: The time to wait after aspirating. - mix_position_from_liquid_surface: The position of the mix from the liquid surface. - mix_surface_following_distance: The distance to follow the liquid surface during mix. - limit_curve_index: The index of the limit curve to use. - disable_volume_correction: Whether to disable liquid class volume correction. - """ - - # # # TODO: delete > 2026-01 # # # - if mix_volume != 0 or mix_cycles != 0 or speed_of_mix != 0: - raise NotImplementedError( - "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.aspirate96 instead. " - "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" - ) - - if immersion_depth_direction is not None: - warnings.warn( - "The immersion_depth_direction parameter is deprecated and will be removed in the future. " - "Use positive values for immersion_depth to move into the liquid, and negative values to move " - "out of the liquid.", - DeprecationWarning, - ) - - if liquid_surface_sink_distance_at_the_end_of_aspiration != 0: - surface_following_distance = liquid_surface_sink_distance_at_the_end_of_aspiration - warnings.warn( - "The liquid_surface_sink_distance_at_the_end_of_aspiration parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard surface_following_distance parameter instead.\n" - "liquid_surface_sink_distance_at_the_end_of_aspiration currently superseding surface_following_distance.", - DeprecationWarning, - ) - - if minimal_end_height is not None: - min_z_endpos = minimal_end_height - warnings.warn( - "The minimal_end_height parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard min_z_endpos parameter instead.\n" - "min_z_endpos currently superseding minimal_end_height.", - DeprecationWarning, - ) - - if air_transport_retract_dist is not None: - pull_out_distance_transport_air = air_transport_retract_dist - warnings.warn( - "The air_transport_retract_dist parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard pull_out_distance_transport_air parameter instead.\n" - "pull_out_distance_transport_air currently superseding air_transport_retract_dist.", - DeprecationWarning, - ) - - if maximum_immersion_depth is not None: - minimum_height = maximum_immersion_depth - warnings.warn( - "The maximum_immersion_depth parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard minimum_height parameter instead.\n" - "minimum_height currently superseding maximum_immersion_depth.", - DeprecationWarning, - ) - - if surface_following_distance_during_mix != 0: - mix_surface_following_distance = surface_following_distance_during_mix - warnings.warn( - "The surface_following_distance_during_mix parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard mix_surface_following_distance parameter instead.\n" - "mix_surface_following_distance currently superseding surface_following_distance_during_mix.", - DeprecationWarning, - ) - - if tube_2nd_section_height_measured_from_zm != 3.2: - second_section_height = tube_2nd_section_height_measured_from_zm - warnings.warn( - "The tube_2nd_section_height_measured_from_zm parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard second_section_height parameter instead.\n" - "second_section_height_measured_from_zm currently superseding second_section_height.", - DeprecationWarning, - ) - - if tube_2nd_section_ratio != 618.0: - second_section_ratio = tube_2nd_section_ratio - warnings.warn( - "The tube_2nd_section_ratio parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard second_section_ratio parameter instead.\n" - "second_section_ratio currently superseding tube_2nd_section_ratio.", - DeprecationWarning, - ) - # # # delete # # # - - # get the first well and tip as representatives - if isinstance(aspiration, MultiHeadAspirationPlate): - plate = aspiration.wells[0].parent - assert isinstance(plate, Plate), "MultiHeadAspirationPlate well parent must be a Plate" - rot = plate.get_absolute_rotation() - if rot.x % 360 != 0 or rot.y % 360 != 0: - raise ValueError("Plate rotation around x or y is not supported for 96 head operations") - if rot.z % 360 == 180: - ref_well = aspiration.wells[-1] - elif rot.z % 360 == 0: - ref_well = aspiration.wells[0] - else: - raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") - - position = ( - ref_well.get_location_wrt(self.deck) - + ref_well.center() - + Coordinate(z=ref_well.material_z_thickness) - + aspiration.offset - ) - else: - x_width = (12 - 1) * 9 # 12 tips in a row, 9 mm between them - y_width = (8 - 1) * 9 # 8 tips in a column, 9 mm between them - x_position = (aspiration.container.get_absolute_size_x() - x_width) / 2 - y_position = (aspiration.container.get_absolute_size_y() - y_width) / 2 + y_width - position = ( - aspiration.container.get_location_wrt(self.deck, z="cavity_bottom") - + Coordinate(x=x_position, y=y_position) - + aspiration.offset - ) - self._check_96_position_legal(position, skip_z=True) - - tip = next(tip for tip in aspiration.tips if tip is not None) - - liquid_height = position.z + (aspiration.liquid_height or 0) - - hlc = hlc or get_star_liquid_class( - tip_volume=tip.maximal_volume, - is_core=True, - is_tip=True, - has_filter=tip.has_filter, - # get last liquid in pipette, first to be dispensed - liquid=Liquid.WATER, # default to WATER - jet=jet, - blow_out=blow_out, # see comment in method docstring - ) - - if disable_volume_correction or hlc is None: - volume = aspiration.volume - else: # hlc is not None and not disable_volume_correction - volume = hlc.compute_corrected_volume(aspiration.volume) - - # Get better default values from the HLC if available - transport_air_volume = transport_air_volume or ( - hlc.aspiration_air_transport_volume if hlc is not None else 0 - ) - blow_out_air_volume = aspiration.blow_out_air_volume or ( - hlc.aspiration_blow_out_volume if hlc is not None else 0 - ) - flow_rate = aspiration.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 250) - swap_speed = swap_speed or (hlc.aspiration_swap_speed if hlc is not None else 100) - settling_time = settling_time or (hlc.aspiration_settling_time if hlc is not None else 0.5) - - x_direction = 0 if position.x >= 0 else 1 - try: - return await self.aspirate_core_96( - x_position=abs(round(position.x * 10)), - x_direction=x_direction, - y_positions=round(position.y * 10), - aspiration_type=aspiration_type, - minimum_traverse_height_at_beginning_of_a_command=round( - (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 - ), - min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10), - lld_search_height=round(lld_search_height * 10), - liquid_surface_no_lld=round(liquid_height * 10), - pull_out_distance_transport_air=round(pull_out_distance_transport_air * 10), - minimum_height=round((minimum_height or position.z) * 10), - second_section_height=round(second_section_height * 10), - second_section_ratio=round(second_section_ratio * 10), - immersion_depth=round(immersion_depth * 10), - immersion_depth_direction=immersion_depth_direction or (0 if (immersion_depth >= 0) else 1), - surface_following_distance=round(surface_following_distance * 10), - aspiration_volumes=round(volume * 10), - aspiration_speed=round(flow_rate * 10), - transport_air_volume=round(transport_air_volume * 10), - blow_out_air_volume=round(blow_out_air_volume * 10), - pre_wetting_volume=round(pre_wetting_volume * 10), - lld_mode=int(use_lld), - gamma_lld_sensitivity=gamma_lld_sensitivity, - swap_speed=round(swap_speed * 10), - settling_time=round(settling_time * 10), - mix_volume=round(aspiration.mix.volume * 10) if aspiration.mix is not None else 0, - mix_cycles=aspiration.mix.repetitions if aspiration.mix is not None else 0, - mix_position_from_liquid_surface=round(mix_position_from_liquid_surface * 10), - mix_surface_following_distance=round(mix_surface_following_distance * 10), - speed_of_mix=round(aspiration.mix.flow_rate * 10) if aspiration.mix is not None else 1200, - channel_pattern=[True] * 12 * 8, - limit_curve_index=limit_curve_index, - tadm_algorithm=False, - recording_mode=0, - ) - except STARFirmwareError as e: - if self._is_core96_slave_timeout(e): - logger.warning("Firmware slave timeout during aspirate96, polling for completion") - await self._core96_wait_for_idle() - else: - raise - - @_requires_head96 - async def dispense96( - self, - dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer], - jet: bool = False, - empty: bool = False, - blow_out: bool = False, - hlc: Optional[HamiltonLiquidClass] = None, - pull_out_distance_transport_air=10, - use_lld: bool = False, - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - min_z_endpos: Optional[float] = None, - lld_search_height: float = 199.9, - minimum_height: Optional[float] = None, - second_section_height: float = 3.2, - second_section_ratio: float = 618.0, - immersion_depth: float = 0, - surface_following_distance: float = 0, - transport_air_volume: float = 5.0, - gamma_lld_sensitivity: int = 1, - swap_speed: float = 2.0, - settling_time: float = 0, - mix_position_from_liquid_surface: float = 0, - mix_surface_following_distance: float = 0, - limit_curve_index: int = 0, - cut_off_speed: float = 5.0, - stop_back_volume: float = 0, - disable_volume_correction: bool = False, - # Deprecated parameters, to be removed in future versions - # rm: >2026-01 - liquid_surface_sink_distance_at_the_end_of_dispense: float = 0, # surface_following_distance! - maximum_immersion_depth: Optional[float] = None, - minimal_end_height: Optional[float] = None, - mixing_position_from_liquid_surface: float = 0, - surface_following_distance_during_mixing: float = 0, - air_transport_retract_dist=10, - tube_2nd_section_ratio: float = 618.0, - tube_2nd_section_height_measured_from_zm: float = 3.2, - immersion_depth_direction: Optional[int] = None, - mixing_volume: float = 0, - mixing_cycles: int = 0, - speed_of_mixing: float = 0.0, - dispense_mode: Optional[int] = None, - ): - """Dispense using the Core96 head. - - Args: - dispense: The Dispense command to execute. - jet: Whether to use jet dispense mode. - empty: Whether to use empty dispense mode. - blow_out: Whether to blow out after dispensing. - pull_out_distance_transport_air: The distance to retract after dispensing, in mm. - use_lld: Whether to use gamma LLD. - - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a - command, in mm. - min_z_endpos: Minimal end height, in mm. - lld_search_height: LLD search height, in mm. - minimum_height: Maximum immersion depth, in mm. Equals Minimum height during command. - second_section_height: Height of the second section, in mm. - second_section_ratio: Ratio of [the diameter of the bottom * 10000] / [the diameter of the top]. - immersion_depth: Immersion depth, in mm. - surface_following_distance: Surface following distance, in mm. Default 0. - transport_air_volume: Transport air volume, to dispense before aspiration. - gamma_lld_sensitivity: Gamma LLD sensitivity. - swap_speed: Swap speed (on leaving liquid) [mm/s]. Must be between 0.3 and 160. Default 10. - settling_time: Settling time, in seconds. - mix_position_from_liquid_surface: Mixing position from liquid surface, in mm. - mix_surface_following_distance: Surface following distance during mixing, in mm. - limit_curve_index: Limit curve index. - cut_off_speed: Unknown. - stop_back_volume: Unknown. - disable_volume_correction: Whether to disable liquid class volume correction. - """ - - # # # TODO: delete > 2026-01 # # # - if mixing_volume != 0 or mixing_cycles != 0 or speed_of_mixing != 0: - raise NotImplementedError( - "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense instead. " - "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" - ) - - if immersion_depth_direction is not None: - warnings.warn( - "The immersion_depth_direction parameter is deprecated and will be removed in the future. " - "Use positive values for immersion_depth to move into the liquid, and negative values to move " - "out of the liquid.", - DeprecationWarning, - ) - - if liquid_surface_sink_distance_at_the_end_of_dispense != 0: - surface_following_distance = liquid_surface_sink_distance_at_the_end_of_dispense - warnings.warn( - "The liquid_surface_sink_distance_at_the_end_of_dispense parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard surface_following_distance parameter instead.\n" - "liquid_surface_sink_distance_at_the_end_of_dispense currently superseding surface_following_distance.", - DeprecationWarning, - ) - - if maximum_immersion_depth is not None: - minimum_height = maximum_immersion_depth - warnings.warn( - "The maximum_immersion_depth parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard minimum_height parameter instead.\n" - "minimum_height currently superseding maximum_immersion_depth.", - DeprecationWarning, - ) - - if minimal_end_height is not None: - min_z_endpos = minimal_end_height - warnings.warn( - "The minimal_end_height parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard min_z_endpos parameter instead.\n" - "min_z_endpos currently superseding minimal_end_height.", - DeprecationWarning, - ) - - if mixing_position_from_liquid_surface != 0: - mix_position_from_liquid_surface = mixing_position_from_liquid_surface - warnings.warn( - "The mixing_position_from_liquid_surface parameter is deprecated and will be removed in the future " - "Use the Hamilton-standard mix_position_from_liquid_surface parameter instead.\n" - "mix_position_from_liquid_surface currently superseding mixing_position_from_liquid_surface.", - DeprecationWarning, - ) - - if surface_following_distance_during_mixing != 0: - mix_surface_following_distance = surface_following_distance_during_mixing - warnings.warn( - "The surface_following_distance_during_mixing parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard mix_surface_following_distance parameter instead.\n" - "mix_surface_following_distance currently superseding surface_following_distance_during_mixing.", - DeprecationWarning, - ) - - if air_transport_retract_dist != 10: - pull_out_distance_transport_air = air_transport_retract_dist - warnings.warn( - "The air_transport_retract_dist parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard pull_out_distance_transport_air parameter instead.\n" - "pull_out_distance_transport_air currently superseding air_transport_retract_dist.", - DeprecationWarning, - ) - - if tube_2nd_section_ratio != 618.0: - second_section_ratio = tube_2nd_section_ratio - warnings.warn( - "The tube_2nd_section_ratio parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard second_section_ratio parameter instead.\n" - "second_section_ratio currently superseding tube_2nd_section_ratio.", - DeprecationWarning, - ) - - if tube_2nd_section_height_measured_from_zm != 3.2: - second_section_height = tube_2nd_section_height_measured_from_zm - warnings.warn( - "The tube_2nd_section_height_measured_from_zm parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard second_section_height parameter instead.\n" - "second_section_height currently superseding tube_2nd_section_height_measured_from_zm.", - DeprecationWarning, - ) - - if dispense_mode is not None: - warnings.warn( - "The dispense_mode parameter is deprecated and will be removed in the future. " - "Use the combination of the `jet`, `empty` and `blow_out` parameters instead. " - "dispense_mode currently superseding those parameters.", - DeprecationWarning, - ) - else: - dispense_mode = _dispensing_mode_for_op(empty=empty, jet=jet, blow_out=blow_out) - # # # delete # # # - - # get the first well and tip as representatives - if isinstance(dispense, MultiHeadDispensePlate): - plate = dispense.wells[0].parent - assert isinstance(plate, Plate), "MultiHeadDispensePlate well parent must be a Plate" - rot = plate.get_absolute_rotation() - if rot.x % 360 != 0 or rot.y % 360 != 0: - raise ValueError("Plate rotation around x or y is not supported for 96 head operations") - if rot.z % 360 == 180: - ref_well = dispense.wells[-1] - elif rot.z % 360 == 0: - ref_well = dispense.wells[0] - else: - raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") - - position = ( - ref_well.get_location_wrt(self.deck) - + ref_well.center() - + Coordinate(z=ref_well.material_z_thickness) - + dispense.offset - ) - else: - # dispense in the center of the container - # but we have to get the position of the center of tip A1 - x_width = (12 - 1) * 9 # 12 tips in a row, 9 mm between them - y_width = (8 - 1) * 9 # 8 tips in a column, 9 mm between them - x_position = (dispense.container.get_absolute_size_x() - x_width) / 2 - y_position = (dispense.container.get_absolute_size_y() - y_width) / 2 + y_width - position = ( - dispense.container.get_location_wrt(self.deck, z="cavity_bottom") - + Coordinate(x=x_position, y=y_position) - + dispense.offset - ) - self._check_96_position_legal(position, skip_z=True) - tip = next(tip for tip in dispense.tips if tip is not None) - - liquid_height = position.z + (dispense.liquid_height or 0) - - hlc = hlc or get_star_liquid_class( - tip_volume=tip.maximal_volume, - is_core=True, - is_tip=True, - has_filter=tip.has_filter, - # get last liquid in pipette, first to be dispensed - liquid=Liquid.WATER, # default to WATER - jet=jet, - blow_out=blow_out, # see comment in method docstring - ) - - if disable_volume_correction or hlc is None: - volume = dispense.volume - else: # hlc is not None and not disable_volume_correction - volume = hlc.compute_corrected_volume(dispense.volume) - - transport_air_volume = transport_air_volume or ( - hlc.dispense_air_transport_volume if hlc is not None else 0 - ) - blow_out_air_volume = dispense.blow_out_air_volume or ( - hlc.dispense_blow_out_volume if hlc is not None else 0 - ) - flow_rate = dispense.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 120) - swap_speed = swap_speed or (hlc.dispense_swap_speed if hlc is not None else 100) - settling_time = settling_time or (hlc.dispense_settling_time if hlc is not None else 5) - - try: - return await self.dispense_core_96( - dispensing_mode=dispense_mode, - x_position=abs(round(position.x * 10)), - x_direction=0 if position.x >= 0 else 1, - y_position=round(position.y * 10), - minimum_traverse_height_at_beginning_of_a_command=round( - (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 - ), - min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10), - lld_search_height=round(lld_search_height * 10), - liquid_surface_no_lld=round(liquid_height * 10), - pull_out_distance_transport_air=round(pull_out_distance_transport_air * 10), - minimum_height=round((minimum_height or position.z) * 10), - second_section_height=round(second_section_height * 10), - second_section_ratio=round(second_section_ratio * 10), - immersion_depth=round(immersion_depth * 10), - immersion_depth_direction=immersion_depth_direction or (0 if (immersion_depth >= 0) else 1), - surface_following_distance=round(surface_following_distance * 10), - dispense_volume=round(volume * 10), - dispense_speed=round(flow_rate * 10), - transport_air_volume=round(transport_air_volume * 10), - blow_out_air_volume=round(blow_out_air_volume * 10), - lld_mode=int(use_lld), - gamma_lld_sensitivity=gamma_lld_sensitivity, - swap_speed=round(swap_speed * 10), - settling_time=round(settling_time * 10), - mixing_volume=round(dispense.mix.volume * 10) if dispense.mix is not None else 0, - mixing_cycles=dispense.mix.repetitions if dispense.mix is not None else 0, - mix_position_from_liquid_surface=round(mix_position_from_liquid_surface * 10), - mix_surface_following_distance=round(mix_surface_following_distance * 10), - speed_of_mixing=round(dispense.mix.flow_rate * 10) if dispense.mix is not None else 1200, - channel_pattern=[True] * 12 * 8, - limit_curve_index=limit_curve_index, - tadm_algorithm=False, - recording_mode=0, - cut_off_speed=round(cut_off_speed * 10), - stop_back_volume=round(stop_back_volume * 10), - ) - except STARFirmwareError as e: - if self._is_core96_slave_timeout(e): - logger.warning( - "Firmware slave command timeout during dispense96, waiting for head to finish" - ) - await self._core96_wait_for_idle() - else: - raise - - async def iswap_move_picked_up_resource( - self, - center: Coordinate, - grip_direction: GripDirection, - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - collision_control_level: int = 1, - acceleration_index_high_acc: int = 4, - acceleration_index_low_acc: int = 1, - ): - """After a resource is picked up, move it to a new location but don't release it yet. - Low level component of :meth:`move_resource` - """ - - assert self.extended_conf.left_x_drive.iswap_installed, "iswap must be installed" - - x_direction = 0 if center.x >= 0 else 1 - y_direction = 0 if center.y >= 0 else 1 - - await self.move_plate_to_position( - x_position=round(abs(center.x) * 10), - x_direction=x_direction, - y_position=round(abs(center.y) * 10), - y_direction=y_direction, - z_position=round(center.z * 10), - z_direction=0, - grip_direction={ - GripDirection.FRONT: 1, - GripDirection.RIGHT: 2, - GripDirection.BACK: 3, - GripDirection.LEFT: 4, - }[grip_direction], - minimum_traverse_height_at_beginning_of_a_command=round( - (minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height) * 10 - ), - collision_control_level=collision_control_level, - acceleration_index_high_acc=acceleration_index_high_acc, - acceleration_index_low_acc=acceleration_index_low_acc, - ) - - async def core_pick_up_resource( - self, - resource: Resource, - pickup_distance_from_top: float, - offset: Coordinate = Coordinate.zero(), - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - minimum_z_position_at_the_command_end: Optional[float] = None, - grip_strength: int = 15, - z_speed: float = 50.0, - y_gripping_speed: float = 5.0, - front_channel: int = 7, - ): - """Pick up resource with CoRe gripper tool - Low level component of :meth:`move_resource` - - Args: - resource: Resource to pick up. - offset: Offset from resource position in mm. - pickup_distance_from_top: Distance from top of resource to pick up. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a - command [mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be - between 0 and 360. - grip_strength: Grip strength (0 = weak, 99 = strong). Must be between 0 and 99. Default 15. - z_speed: Z speed [mm/s]. Must be between 0.4 and 128.7. Default 50.0. - y_gripping_speed: Y gripping speed [mm/s]. Must be between 0 and 370.0. Default 5.0. - front_channel: Channel 1. Must be between 1 and self._num_channels - 1. Default 7. - """ - - # Get center of source plate. Also gripping height and plate width. - center = resource.get_location_wrt(self.deck, x="c", y="c", z="b") + offset - grip_height = center.z + resource.get_absolute_size_z() - pickup_distance_from_top - grip_width = resource.get_absolute_size_y() # grip width is y size of resource - - if self.core_parked: - await self.pick_up_core_gripper_tools(front_channel=front_channel) - - await self.core_get_plate( - x_position=round(center.x * 10), - x_direction=0, - y_position=round(center.y * 10), - y_gripping_speed=round(y_gripping_speed * 10), - z_position=round(grip_height * 10), - z_speed=round(z_speed * 10), - open_gripper_position=round(grip_width * 10) + 30, - plate_width=round(grip_width * 10) - 30, - grip_strength=grip_strength, - minimum_traverse_height_at_beginning_of_a_command=round( - (minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height) * 10 - ), - minimum_z_position_at_the_command_end=round( - (minimum_z_position_at_the_command_end or self._iswap_traversal_height) * 10 - ), - ) - - async def core_move_picked_up_resource( - self, - center: Coordinate, - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - acceleration_index: int = 4, - z_speed: float = 50.0, - ): - """After a resource is picked up, move it to a new location but don't release it yet. - Low level component of :meth:`move_resource` - - Args: - location: Location to move to. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a - command [0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be - between 0 and 3600. Default 3600. - acceleration_index: Acceleration index (0 = 0.1 mm/s2, 1 = 0.2 mm/s2, 2 = 0.5 mm/s2, - 3 = 1.0 mm/s2, 4 = 2.0 mm/s2, 5 = 5.0 mm/s2, 6 = 10.0 mm/s2, 7 = 20.0 mm/s2). Must be - between 0 and 7. Default 4. - z_speed: Z speed [0.1mm/s]. Must be between 3 and 1600. Default 500. - """ - - await self.core_move_plate_to_position( - x_position=round(center.x * 10), - x_direction=0, - x_acceleration_index=acceleration_index, - y_position=round(center.y * 10), - z_position=round(center.z * 10), - z_speed=round(z_speed * 10), - minimum_traverse_height_at_beginning_of_a_command=round( - (minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height) * 10 - ), - ) - - async def core_release_picked_up_resource( - self, - location: Coordinate, - resource: Resource, - pickup_distance_from_top: float, - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - z_position_at_the_command_end: Optional[float] = None, - return_tool: bool = True, - ): - """Place resource with CoRe gripper tool - Low level component of :meth:`move_resource` - - Args: - resource: Location to place. - pickup_distance_from_top: Distance from top of resource to place. - offset: Offset from resource position in mm. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a - command [mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be - between 0 and 360.0. - z_position_at_the_command_end: Minimum z-Position at end of a command [mm] (refers to all - channels independent of tip pattern parameter 'tm'). Must be between 0 and 360.0 - return_tool: Return tool to wasteblock mount after placing. Default True. - """ - - # Get center of destination location. Also gripping height and plate width. - grip_height = location.z + resource.get_absolute_size_z() - pickup_distance_from_top - grip_width = resource.get_absolute_size_y() - - await self.core_put_plate( - x_position=round(location.x * 10), - x_direction=0, - y_position=round(location.y * 10), - z_position=round(grip_height * 10), - z_press_on_distance=0, - z_speed=500, - open_gripper_position=round(grip_width * 10) + 30, - minimum_traverse_height_at_beginning_of_a_command=round( - (minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height) * 10 - ), - z_position_at_the_command_end=round( - (z_position_at_the_command_end or self._iswap_traversal_height) * 10 - ), - return_tool=return_tool, - ) - - async def pick_up_resource( - self, - pickup: ResourcePickup, - use_arm: Literal["iswap", "core"] = "iswap", - core_front_channel: int = 7, - iswap_grip_strength: int = 4, - core_grip_strength: int = 15, - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - z_position_at_the_command_end: Optional[float] = None, - plate_width_tolerance: float = 2.0, - open_gripper_position: Optional[float] = None, - hotel_depth=160.0, - hotel_clearance_height=7.5, - high_speed=False, - plate_width: Optional[float] = None, - use_unsafe_hotel: bool = False, - iswap_collision_control_level: int = 0, - iswap_fold_up_sequence_at_the_end_of_process: bool = False, - # deprecated - channel_1: Optional[int] = None, - channel_2: Optional[int] = None, - ): - if use_arm == "iswap": - assert ( - pickup.resource.get_absolute_rotation().x == 0 - and pickup.resource.get_absolute_rotation().y == 0 - ) - assert pickup.resource.get_absolute_rotation().z % 90 == 0 - if plate_width is None: - if pickup.direction in (GripDirection.FRONT, GripDirection.BACK): - plate_width = pickup.resource.get_absolute_size_x() - else: - plate_width = pickup.resource.get_absolute_size_y() - - center_in_absolute_space = pickup.resource.center().rotated( - pickup.resource.get_absolute_rotation() - ) - x, y, z = ( - pickup.resource.get_location_wrt(self.deck, "l", "f", "t") - + center_in_absolute_space - + pickup.offset - ) - z -= pickup.pickup_distance_from_top - - traverse_height_at_beginning = ( - minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height - ) - z_position_at_the_command_end = z_position_at_the_command_end or self._iswap_traversal_height - - if open_gripper_position is None: - if use_unsafe_hotel: - open_gripper_position = plate_width + 5 - else: - open_gripper_position = plate_width + 3 - - if use_unsafe_hotel: - await self.unsafe.get_from_hotel( - hotel_center_x_coord=round(abs(x) * 10), - hotel_center_y_coord=round(abs(y) * 10), - # hotel_center_z_coord=int((z * 10)+0.5), # use sensible rounding (.5 goes up) - hotel_center_z_coord=round(abs(z) * 10), - hotel_center_x_direction=0 if x >= 0 else 1, - hotel_center_y_direction=0 if y >= 0 else 1, - hotel_center_z_direction=0 if z >= 0 else 1, - clearance_height=round(hotel_clearance_height * 10), - hotel_depth=round(hotel_depth * 10), - grip_direction=pickup.direction, - open_gripper_position=round(open_gripper_position * 10), - traverse_height_at_beginning=round(traverse_height_at_beginning * 10), - z_position_at_end=round(z_position_at_the_command_end * 10), - high_acceleration_index=4 if high_speed else 1, - low_acceleration_index=1, - plate_width=round(plate_width * 10), - plate_width_tolerance=round(plate_width_tolerance * 10), - ) - else: - await self.iswap_get_plate( - x_position=round(abs(x) * 10), - y_position=round(abs(y) * 10), - z_position=round(abs(z) * 10), - x_direction=0 if x >= 0 else 1, - y_direction=0 if y >= 0 else 1, - z_direction=0 if z >= 0 else 1, - grip_direction={ - GripDirection.FRONT: 1, - GripDirection.RIGHT: 2, - GripDirection.BACK: 3, - GripDirection.LEFT: 4, - }[pickup.direction], - minimum_traverse_height_at_beginning_of_a_command=round( - traverse_height_at_beginning * 10 - ), - z_position_at_the_command_end=round(z_position_at_the_command_end * 10), - grip_strength=iswap_grip_strength, - open_gripper_position=round(open_gripper_position * 10), - plate_width=round(plate_width * 10) - 33, - plate_width_tolerance=round(plate_width_tolerance * 10), - collision_control_level=iswap_collision_control_level, - acceleration_index_high_acc=4 if high_speed else 1, - acceleration_index_low_acc=1, - iswap_fold_up_sequence_at_the_end_of_process=iswap_fold_up_sequence_at_the_end_of_process, - ) - elif use_arm == "core": - if use_unsafe_hotel: - raise ValueError("Cannot use iswap hotel mode with core grippers") - - if pickup.direction != GripDirection.FRONT: - raise NotImplementedError("Core grippers only support FRONT (default)") - - if channel_1 is not None or channel_2 is not None: - warnings.warn( - "The channel_1 and channel_2 parameters are deprecated and will be removed in future versions. " - "Please use the core_front_channel parameter instead.", - DeprecationWarning, - ) - assert channel_1 is not None and channel_2 is not None, ( - "Both channel_1 and channel_2 must be provided" - ) - assert channel_1 + 1 == channel_2, "channel_2 must be channel_1 + 1" - core_front_channel = ( - channel_2 - 1 - ) # core_front_channel is the first channel of the gripper tool - - await self.core_pick_up_resource( - resource=pickup.resource, - pickup_distance_from_top=pickup.pickup_distance_from_top, - offset=pickup.offset, - minimum_traverse_height_at_beginning_of_a_command=self._iswap_traversal_height, - minimum_z_position_at_the_command_end=self._iswap_traversal_height, - front_channel=core_front_channel, - grip_strength=core_grip_strength, - ) - else: - raise ValueError(f"use_arm must be either 'iswap' or 'core', not {use_arm}") - - async def move_picked_up_resource( - self, move: ResourceMove, use_arm: Literal["iswap", "core"] = "iswap" - ): - center = ( - move.location - + move.resource.get_anchor("c", "c", "t") - - Coordinate(z=move.pickup_distance_from_top) - + move.offset - ) - - if use_arm == "iswap": - await self.iswap_move_picked_up_resource( - center=center, - grip_direction=move.gripped_direction, - minimum_traverse_height_at_beginning_of_a_command=self._iswap_traversal_height, - collision_control_level=1, - acceleration_index_high_acc=4, - acceleration_index_low_acc=1, - ) - else: - await self.core_move_picked_up_resource( - center=center, - minimum_traverse_height_at_beginning_of_a_command=self._iswap_traversal_height, - acceleration_index=4, - ) - - async def drop_resource( - self, - drop: ResourceDrop, - use_arm: Literal["iswap", "core"] = "iswap", - return_core_gripper: bool = True, - minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, - z_position_at_the_command_end: Optional[float] = None, - open_gripper_position: Optional[float] = None, - hotel_depth=160.0, - hotel_clearance_height=7.5, - hotel_high_speed=False, - use_unsafe_hotel: bool = False, - iswap_collision_control_level: int = 0, - iswap_fold_up_sequence_at_the_end_of_process: bool = False, - ): - # Get center of source plate in absolute space. - # The computation of the center has to be rotated so that the offset is in absolute space. - # center_in_absolute_space will be the vector pointing from the destination origin to the - # center of the moved the resource after drop. - # This means that the center vector has to be rotated from the child local space by the - # new child absolute rotation. The moved resource's rotation will be the original child - # rotation plus the rotation applied by the movement. - # The resource is moved by drop.rotation - # The new resource absolute location is - # drop.resource.get_absolute_rotation().z + drop.rotation - center_in_absolute_space = drop.resource.center().rotated( - Rotation(z=drop.resource.get_absolute_rotation().z + drop.rotation) - ) - x, y, z = drop.destination + center_in_absolute_space + drop.offset - - if use_arm == "iswap": - traversal_height_start = ( - minimum_traverse_height_at_beginning_of_a_command or self._iswap_traversal_height - ) - z_position_at_the_command_end = z_position_at_the_command_end or self._iswap_traversal_height - assert ( - drop.resource.get_absolute_rotation().x == 0 - and drop.resource.get_absolute_rotation().y == 0 - ) - assert drop.resource.get_absolute_rotation().z % 90 == 0 - - # Use the pickup direction to determine how wide the plate is gripped. - # Note that the plate is still in the original orientation at this point, - # so get_absolute_size_{x,y}() will return the size of the plate in the original orientation. - if ( - drop.pickup_direction == GripDirection.FRONT or drop.pickup_direction == GripDirection.BACK - ): - plate_width = drop.resource.get_absolute_size_x() - elif ( - drop.pickup_direction == GripDirection.RIGHT or drop.pickup_direction == GripDirection.LEFT - ): - plate_width = drop.resource.get_absolute_size_y() - else: - raise ValueError("Invalid grip direction") - - z = z + drop.resource.get_absolute_size_z() - drop.pickup_distance_from_top - - if open_gripper_position is None: - if use_unsafe_hotel: - open_gripper_position = plate_width + 5 - else: - open_gripper_position = plate_width + 3 - - if use_unsafe_hotel: - # hotel: down forward down. - # down to level of the destination + the clearance height (so clearance height can be subtracted) - # hotel_depth is forward. - # clearance height is second down. - - await self.unsafe.put_in_hotel( - hotel_center_x_coord=round(abs(x) * 10), - hotel_center_y_coord=round(abs(y) * 10), - hotel_center_z_coord=round(abs(z) * 10), - hotel_center_x_direction=0 if x >= 0 else 1, - hotel_center_y_direction=0 if y >= 0 else 1, - hotel_center_z_direction=0 if z >= 0 else 1, - clearance_height=round(hotel_clearance_height * 10), - hotel_depth=round(hotel_depth * 10), - grip_direction=drop.direction, - open_gripper_position=round(open_gripper_position * 10), - traverse_height_at_beginning=round(traversal_height_start * 10), - z_position_at_end=round(z_position_at_the_command_end * 10), - high_acceleration_index=4 if hotel_high_speed else 1, - low_acceleration_index=1, - ) - else: - await self.iswap_put_plate( - x_position=round(abs(x) * 10), - y_position=round(abs(y) * 10), - z_position=round(abs(z) * 10), - x_direction=0 if x >= 0 else 1, - y_direction=0 if y >= 0 else 1, - z_direction=0 if z >= 0 else 1, - grip_direction={ - GripDirection.FRONT: 1, - GripDirection.RIGHT: 2, - GripDirection.BACK: 3, - GripDirection.LEFT: 4, - }[drop.direction], - minimum_traverse_height_at_beginning_of_a_command=round(traversal_height_start * 10), - z_position_at_the_command_end=round(z_position_at_the_command_end * 10), - open_gripper_position=round(open_gripper_position * 10), - collision_control_level=iswap_collision_control_level, - iswap_fold_up_sequence_at_the_end_of_process=iswap_fold_up_sequence_at_the_end_of_process, - ) - elif use_arm == "core": - if use_unsafe_hotel: - raise ValueError("Cannot use iswap hotel mode with core grippers") - - if drop.direction != GripDirection.FRONT: - raise NotImplementedError("Core grippers only support FRONT direction (default)") - - await self.core_release_picked_up_resource( - location=Coordinate(x, y, z), - resource=drop.resource, - pickup_distance_from_top=drop.pickup_distance_from_top, - minimum_traverse_height_at_beginning_of_a_command=self._iswap_traversal_height, - z_position_at_the_command_end=self._iswap_traversal_height, - # int(previous_location.z + move.resource.get_size_z() / 2) * 10, - return_tool=return_core_gripper, - ) - else: - raise ValueError(f"use_arm must be either 'iswap' or 'core', not {use_arm}") - - async def prepare_for_manual_channel_operation(self, channel: int): - """Prepare for manual operation.""" - - await self.position_max_free_y_for_n(pipetting_channel_index=channel) - - async def move_channel_x(self, channel: int, x: float): - """Move a channel in the x direction.""" - if self.left_side_panel_installed and x < self.PIP_X_MIN_WITH_LEFT_SIDE_PANEL: - raise ValueError( - f"PIP channel x={x}mm is below the minimum {self.PIP_X_MIN_WITH_LEFT_SIDE_PANEL}mm " - f"(left side panel is installed)" - ) - self._check_x_arm_reachable(x) - await self.position_left_x_arm_(round(x * 10)) - - @need_iswap_parked - async def move_channel_y(self, channel: int, y: float): - """Move a channel safely in the y direction.""" - - # Anti-channel-crash feature - if channel > 0: - max_y_pos = await self.request_y_pos_channel_n(channel - 1) - if y > max_y_pos: - raise ValueError( - f"channel {channel} y-target must be <= {max_y_pos} mm " - f"(channel {channel - 1} y-position is {round(y, 2)} mm)" - ) - else: - max_y_pos = self.extended_conf.pip_maximal_y_position - if y > max_y_pos: - raise ValueError(f"channel {channel} y-target must be <= {max_y_pos} mm") - - if channel < (self.num_channels - 1): - min_y_pos = await self.request_y_pos_channel_n(channel + 1) - if y < min_y_pos: - raise ValueError( - f"channel {channel} y-target must be >= {min_y_pos} mm " - f"(channel {channel + 1} y-position is {round(y, 2)} mm)" - ) - else: - # STAR machines do not allow channels y < minimum - if y < self.extended_conf.left_arm_min_y_position: - raise ValueError( - f"channel {channel} y-target must be >= {self.extended_conf.left_arm_min_y_position} mm" - ) - - await self.position_single_pipetting_channel_in_y_direction( - pipetting_channel_index=channel + 1, y_position=round(y * 10) - ) - - async def move_channel_z(self, channel: int, z: float): - """Move a channel in the Z direction. - - .. deprecated:: - Use :meth:`move_channel_stop_disk_z` for moves without a tip attached (stop disk) - or :meth:`move_channel_tool_z` when a tip or tool is attached (tip/tool end). - - The Hamilton firmware interprets this Z position based on its internal - "tip mounted" state for the specified channel. When the firmware state - indicates that no tip is mounted, the absolute Z position refers to the - bottom of the stop disk. In that case, this command is effectively - equivalent to :meth:`move_channel_stop_disk_z` for the same numeric Z value. - - When the firmware state indicates that a tip is mounted on the channel, - the same Z position instead refers to the physical end of the tip. In - this case, the numeric Z value used with this method may differ from the - stop disk Z position used with :meth:`move_channel_stop_disk_z` for the same - physical height above the deck. - """ - # TODO: remove in v1 - warnings.warn( - "move_channel_z is deprecated and will be removed in v1. " - "Use move_channel_stop_disk_z() for moves without a tip attached " - "or move_channel_tool_z() when a tip/tool is attached.", - DeprecationWarning, - stacklevel=2, - ) - await self.position_single_pipetting_channel_in_z_direction( - pipetting_channel_index=channel + 1, z_position=round(z * 10) - ) - - async def move_channel_stop_disk_z( - self, - channel_idx: int, - z: float, - speed: float = 125.0, - acceleration: float = 800.0, - current_limit: int = 3, - ): - """Move a channel's Z-drive to an absolute stop disk position. - - Communicates directly with the individual channel rather than through the - master module. - - Args: - channel_idx: Channel index (0-based, backmost = 0). - z: Target Z position in mm (stop disk). - speed: Max Z-drive speed in mm/sec. Default 125.0 mm/s. - acceleration: Acceleration in mm/sec². Default 800.0. Valid range: ~53.6 to 1609. - current_limit: Current limit (0-7). Default 3. - """ - - z_increment = STARBackend.mm_to_z_drive_increment(z) - speed_increment = STARBackend.mm_to_z_drive_increment(speed) - acceleration_increment = STARBackend.mm_to_z_drive_increment(acceleration / 1000) - - if not isinstance(channel_idx, int): - raise ValueError(f"channel_idx must be an int, got {type(channel_idx).__name__}") - if not (0 <= channel_idx < self.num_channels): - raise ValueError( - f"channel index {channel_idx} out of range for instrument with {self.num_channels} channels" - ) - assert 9320 <= z_increment <= 31200, ( - f"z must be between {STARBackend.z_drive_increment_to_mm(9320)} and " - f"{STARBackend.z_drive_increment_to_mm(31200)} mm, got {z} mm" - ) - assert 20 <= speed_increment <= 15000, ( - f"speed must be between {STARBackend.z_drive_increment_to_mm(20)} and " - f"{STARBackend.z_drive_increment_to_mm(15000)} mm/s, got {speed} mm/s" - ) - assert 5 <= acceleration_increment <= 150, ( - f"acceleration must be between ~53.6 and ~1609 mm/s², got {acceleration} mm/s²" - ) - assert 0 <= current_limit <= 7, f"current_limit must be between 0 and 7, got {current_limit}" - - return await self.send_command( - module=STARBackend.channel_id(channel_idx), - command="ZA", - za=f"{z_increment:05}", - zv=f"{speed_increment:05}", - zr=f"{acceleration_increment:03}", - zw=f"{current_limit:01}", - ) - - async def move_channel_tool_z(self, channel_idx: int, z: float): - """Move a channel in the Z direction (tip/tool end reference). - - Requires a tip or tool to be attached. Use :meth:`move_channel_stop_disk_z` - for moves without a tip. - - Args: - channel_idx: Channel index (0-based, backmost = 0). - z: Target Z position in mm (tip/tool end). - """ - - if not isinstance(channel_idx, int): - raise ValueError(f"channel_idx must be an int, got {type(channel_idx).__name__}") - if not (0 <= channel_idx < self.num_channels): - raise ValueError( - f"channel index {channel_idx} out of range for instrument with {self.num_channels} channels" - ) - - tip_presence = await self.request_tip_presence() - - if not tip_presence[channel_idx]: - raise ValueError( - f"Channel {channel_idx} does not have a tip or tool attached. " - "Use move_channel_stop_disk_z() for Z moves without a tip attached." - ) - - tip_len = await self.request_tip_len_on_channel(channel_idx) - - # The firmware command operates in "tip space" (Z refers to the tip/tool end). - # Convert the head-space limits to tip-space limits: - # tip_space = head_space - tip_len + fitting_depth - max_tip_z = ( - STARBackend.MAXIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH - ) - min_tip_z = ( - STARBackend.MINIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH - ) - - if not (min_tip_z <= z <= max_tip_z): - raise ValueError( - f"z={z} mm out of safe range [{min_tip_z}, {max_tip_z}] mm " - f"for tip length {tip_len} mm on channel {channel_idx}" - ) - - await self.position_single_pipetting_channel_in_z_direction( - pipetting_channel_index=channel_idx + 1, z_position=round(z * 10) - ) - - async def move_channel_x_relative(self, channel: int, distance: float): - """Move a channel in the x direction by a relative amount.""" - current_x = await self.request_x_pos_channel_n(channel) - await self.move_channel_x(channel, current_x + distance) - - async def move_channel_y_relative(self, channel: int, distance: float): - """Move a channel in the y direction by a relative amount.""" - current_y = await self.request_y_pos_channel_n(channel) - await self.move_channel_y(channel, current_y + distance) - - async def move_channel_z_relative(self, channel: int, distance: float): - """Move a channel in the z direction by a relative amount.""" - # TODO: determine whether this refers to stop disk or tip bottom - current_z = await self.request_z_pos_channel_n(channel) - await self.move_channel_z(channel, current_z + distance) - - def get_channel_spacings(self, use_channels: List[int]) -> List[float]: - return [self._channels_minimum_y_spacing[ch] for ch in sorted(use_channels)] - - def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: - if not isinstance(tip, HamiltonTip): - return False - if tip.tip_size in {TipSize.XL}: - return False - return True - - async def core_check_resource_exists_at_location_center( - self, - location: Coordinate, - resource: Resource, - gripper_y_margin: float = 0.5, - offset: Coordinate = Coordinate.zero(), - minimum_traverse_height_at_beginning_of_a_command: float = 275.0, - z_position_at_the_command_end: float = 275.0, - enable_recovery: bool = True, - audio_feedback: bool = True, - ) -> bool: - """Check existence of resource with CoRe gripper tool - a "Get plate using CO-RE gripper" + error handling - Which channels are used for resource check is dependent on which channels have been used for - `STARBackend.get_core(p1: int, p2: int)` (channel indices are 0-based) which is a prerequisite - for this check function. - - Args: - location: Location to check for resource - resource: Resource to check for. - gripper_y_margin = Distance between the front / back wall of the resource - and the grippers during "bumping" / checking - offset: Offset from resource position in mm. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of - a command [mm] (refers to all channels independent of tip pattern parameter 'tm'). - Must be between 0 and 360.0. - z_position_at_the_command_end: Minimum z-Position at end of a command [mm] (refers to - all channels independent of tip pattern parameter 'tm'). Must be between 0 and 360.0. - enable_recovery: if True will ask for user input if resource was not found - audio_feedback: enable controlling computer to emit different sounds when - finding/not finding the resource - - Returns: - True if resource was found, False if resource was not found - """ - - center = location + resource.centers()[0] + offset - y_width_to_gripper_bump = resource.get_absolute_size_y() - gripper_y_margin * 2 - max_spacing = max(self._channels_minimum_y_spacing) - assert max_spacing <= y_width_to_gripper_bump <= round(resource.get_absolute_size_y()), ( - f"width between channels must be between {max_spacing} and " - f"{resource.get_absolute_size_y()} mm" - " (i.e. the maximal distance between channels and the max y size of the resource" - ) - - # Check if CoRe gripper currently in use - cores_used = not self._core_parked - if not cores_used: - raise ValueError("CoRe grippers not yet picked up.") - - # Enable recovery of failed checks - resource_found = False - try_counter = 0 - while not resource_found: - try: - await self.core_get_plate( - x_position=round(center.x * 10), - y_position=round(center.y * 10), - z_position=round(center.z * 10), - open_gripper_position=round(y_width_to_gripper_bump * 10), - plate_width=round(y_width_to_gripper_bump * 10), - # Set default values based on VENUS check_plate commands - y_gripping_speed=50, - x_direction=0, - z_speed=600, - grip_strength=20, - # Enable mods of channel z position for check acceleration - minimum_traverse_height_at_beginning_of_a_command=round( - minimum_traverse_height_at_beginning_of_a_command * 10 - ), - minimum_z_position_at_the_command_end=round(z_position_at_the_command_end * 10), - ) - except STARFirmwareError as exc: - for module_error in exc.errors.values(): - if module_error.trace_information == 62: - resource_found = True - else: - raise ValueError(f"Unexpected error encountered: {exc}") from exc - else: - if audio_feedback: - audio.play_not_found() - if enable_recovery: - print( - f"\nWARNING: Resource '{resource.name}' not found at center" - f" location {(center.x, center.y, center.z)} during check no {try_counter}." - ) - user_prompt = input( - "Have you checked resource is present?" - "\n [ yes ] -> machine will check location again" - "\n [ abort ] -> machine will abort run\n Answer:" - ) - if user_prompt == "yes": - try_counter += 1 - elif user_prompt == "abort": - raise ValueError( - f"Resource '{resource.name}' not found at center" - f" location {(center.x, center.y, center.z)}" - " & error not resolved -> aborted resource movement." - ) - else: - # Resource was not found - return False - - # Resource was found - if audio_feedback: - audio.play_got_item() - return True - - def _position_96_head_in_resource(self, resource: Resource) -> Coordinate: - """The firmware command expects location of tip A1 of the head. We center the head in the given - resource.""" - head_size_x = 9 * 11 # 12 channels, 9mm spacing in between - head_size_y = 9 * 7 # 8 channels, 9mm spacing in between - channel_size = 9 - loc = resource.get_location_wrt(self.deck) - loc.x += (resource.get_size_x() - head_size_x) / 2 + channel_size / 2 - loc.y += (resource.get_size_y() - head_size_y) / 2 + channel_size / 2 - return loc - - def _check_96_position_legal(self, c: Coordinate, skip_z=False) -> None: - """Validate that a coordinate is within the allowed range for the 96 head. - - Args: - c: The coordinate of the A1 position of the head. - skip_z: If True, the z coordinate is not checked. This is useful for commands that handle - the z coordinate separately, such as the big four. - - Raises: - ValueError: If one or more components are out of range. The error message contains all offending components. - """ - - # TODO: these are values for a STARBackend. Find them for a STARlet. - - x_min = self.HEAD96_X_MIN_WITH_LEFT_SIDE_PANEL if self.left_side_panel_installed else -271.0 - - errors = [] - if not (x_min <= c.x <= 974.0): - errors.append(f"x={c.x}") - if not (108.0 <= c.y <= 560.0): - errors.append(f"y={c.y}") - if not (180.5 <= c.z <= 342.5) and not skip_z: - errors.append(f"z={c.z}") - - if len(errors) > 0: - raise ValueError( - "Illegal 96 head position: " - + ", ".join(errors) - + f" (allowed ranges: x [{x_min}, 974], y [108, 560], z [180.5, 342.5])" - ) - - # ============== Firmware Commands ============== - - # -------------- 3.2 System general commands -------------- - - async def pre_initialize_instrument(self): - """Pre-initialize instrument""" - return await self.send_command(module="C0", command="VI", read_timeout=300) - - async def define_tip_needle( - self, - tip_type_table_index: int, - has_filter: bool, - tip_length: int, - maximum_tip_volume: int, - tip_size: TipSize, - pickup_method: TipPickupMethod, - ): - """Tip/needle definition. - - Args: - tip_type_table_index: tip_table_index - has_filter: with(out) filter - tip_length: Tip length [0.1mm] - maximum_tip_volume: Maximum volume of tip [0.1ul] - Note! it's automatically limited to max. channel capacity - tip_type: Type of tip collar (Tip type identification) - pickup_method: pick up method. - Attention! The values set here are temporary and apply only until - power OFF or RESET. After power ON the default values apply. (see Table 3) - """ - - assert 0 <= tip_type_table_index <= 99, "tip_type_table_index must be between 0 and 99" - assert 0 <= tip_type_table_index <= 99, "tip_type_table_index must be between 0 and 99" - assert 1 <= tip_length <= 1999, "tip_length must be between 1 and 1999" - assert 1 <= maximum_tip_volume <= 56000, "maximum_tip_volume must be between 1 and 56000" - - return await self.send_command( - module="C0", - command="TT", - tt=f"{tip_type_table_index:02}", - tf=has_filter, - tl=f"{tip_length:04}", - tv=f"{maximum_tip_volume:05}", - tg=tip_size.value, - tu=pickup_method.value, - ) - - # -------------- 3.2.1 System query -------------- - - async def request_error_code(self): - """Request error code - - Here the last saved error messages can be retrieved. The error buffer is automatically voided - when a new command is started. All configured nodes are displayed. - - Returns: - TODO: - X0##/##: X0 slave - ..##/## see node definitions ( chapter 5) - """ - - return await self.send_command(module="C0", command="RE") - - async def request_firmware_version(self): - """Request firmware version - - Returns: TODO: Rfid0001rf1.0S 2009-06-24 A - """ - - return await self.send_command(module="C0", command="RF") - - async def request_parameter_value(self): - """Request parameter value - - Returns: TODO: Raid1111er00/00yg1200 - """ - - return await self.send_command(module="C0", command="RA") - - class BoardType(enum.Enum): - C167CR_SINGLE_PROCESSOR_BOARD = 0 - C167CR_DUAL_PROCESSOR_BOARD = 1 - LPC2468_XE167_DUAL_PROCESSOR_BOARD = 2 - LPC2468_SINGLE_PROCESSOR_BOARD = 5 - UNKNOWN = -1 - - async def request_electronic_board_type(self): - """Request electronic board type - - Returns: - The board type. - """ - - resp = await self.send_command(module="C0", command="QB") - try: - return STARBackend.BoardType(resp["qb"]) - except ValueError: - return STARBackend.BoardType.UNKNOWN - - # TODO: parse response. - async def request_supply_voltage(self): - """Request supply voltage - - Request supply voltage (for LDPB only) - """ - - return await self.send_command(module="C0", command="MU") - - async def request_instrument_initialization_status(self) -> bool: - """Request instrument initialization status""" - - resp = await self.send_command(module="C0", command="QW", fmt="qw#") - return resp is not None and resp["qw"] == 1 - - async def request_autoload_initialization_status(self) -> bool: - """Request autoload initialization status""" - - resp = await self.send_command(module="I0", command="QW", fmt="qw#") - return resp is not None and resp["qw"] == 1 - - async def request_name_of_last_faulty_parameter(self): - """Request name of last faulty parameter - - Returns: TODO: - Name of last parameter with syntax error - (optional) received value separated with blank - (optional) minimal permitted value separated with blank (optional) - maximal permitted value separated with blank example with min max data: - Vpid2233er00/00vpth 00000 03500 example without min max data: Vpid2233er00/00vpcd - """ - - return await self.send_command(module="C0", command="VP", fmt="vp&&") - - async def request_master_status(self): - """Request master status - - Returns: TODO: see page 19 (SFCO.0036) - """ - - return await self.send_command(module="C0", command="RQ") - - async def request_number_of_presence_sensors_installed(self): - """Request number of presence sensors installed - - Returns: - number of sensors installed (1...103) - """ - - resp = await self.send_command(module="C0", command="SR") - return resp["sr"] - - async def request_eeprom_data_correctness(self): - """Request EEPROM data correctness - - Returns: TODO: (SFCO.0149) - """ - - return await self.send_command(module="C0", command="QV") - - # -------------- 3.3 Settings -------------- - - # -------------- 3.3.1 Volatile Settings -------------- - - async def set_single_step_mode(self, single_step_mode: bool = False): - """Set Single step mode - - Args: - single_step_mode: Single Step Mode. Default False. - """ - - return await self.send_command( - module="C0", - command="AM", - am=single_step_mode, - ) - - async def trigger_next_step(self): - """Trigger next step (Single step mode)""" - - # TODO: this command has no reply!!!! - return await self.send_command(module="C0", command="NS") - - async def halt(self): - """Halt - - Intermediate sequences not yet carried out and the commands in - the command stack are discarded. Sequence already in process is - completed. - """ - - return await self.send_command(module="C0", command="HD") - - async def save_all_cycle_counters(self): - """Save all cycle counters - - Save all cycle counters of the instrument - """ - - return await self.send_command(module="C0", command="AZ") - - async def set_not_stop(self, non_stop): - """Set not stop mode - - Args: - non_stop: True if non stop mode should be turned on after command is sent. - """ - - if non_stop: - # TODO: this command has no reply!!!! - return await self.send_command(module="C0", command="AB") - else: - return await self.send_command(module="C0", command="AW") - - # -------------- 3.3.2 Non volatile settings (stored in EEPROM) -------------- - - async def store_installation_data( - self, - date: datetime.datetime = datetime.datetime.now(), - serial_number: str = "0000", - ): - """Store installation data - - Args: - date: installation date. - """ - - assert len(serial_number) == 4, "serial number must be 4 chars long" - - return await self.send_command(module="C0", command="SI", si=date, sn=serial_number) - - async def store_verification_data( - self, - verification_subject: int = 0, - date: datetime.datetime = datetime.datetime.now(), - verification_status: bool = False, - ): - """Store verification data - - Args: - verification_subject: verification subject. Default 0. Must be between 0 and 24. - date: verification date. - verification_status: verification status. - """ - - assert 0 <= verification_subject <= 24, "verification_subject must be between 0 and 24" - - return await self.send_command( - module="C0", - command="AV", - vo=verification_subject, - vd=date, - vs=verification_status, - ) - - async def additional_time_stamp(self): - """Additional time stamp""" - - return await self.send_command(module="C0", command="AT") - - async def set_x_offset_x_axis_iswap(self, x_offset: int): - """Set X-offset X-axis <-> iSWAP - - Args: - x_offset: X-offset [0.1mm] - """ - - return await self.send_command(module="C0", command="AG", x_offset=x_offset) - - async def set_x_offset_x_axis_core_96_head(self, x_offset: int): - """Set X-offset X-axis <-> CoRe 96 head - - Args: - x_offset: X-offset [0.1mm] - """ - - return await self.send_command(module="C0", command="AF", x_offset=x_offset) - - async def _head96_request_x_offset(self) -> float: - """Read the X-offset i.e. X-arm carriage center <-> CoRe 96 head channel A1, in mm. - - Stored in the master EEPROM as parameter `kf` (set via the AF command), read with the - generic master-EEPROM read RA - mirroring the iSWAP rotation-drive x-offset (`kg`). - Required for deriving the head's X-arm carriage X from a target A1 X. Cached on the - backend as `head96_information.x_offset` during setup. - """ - if not self.extended_conf.left_x_drive.core_96_head_installed: - raise RuntimeError("96-head is not installed") - # 4-digit field: the head96 offset is ~10x the iSWAP's (~368 mm vs ~34 mm), so it exceeds - # 3 digits in 0.1 mm units - "kf###" silently truncates 3684 -> 368. - resp = await self.send_command(module="C0", command="RA", ra="kf", fmt="kf####") - return cast(int, resp["kf"]) / 10.0 - - async def set_x_offset_x_axis_core_nano_pipettor_head(self, x_offset: int): - """Set X-offset X-axis <-> CoRe 96 head - - Args: - x_offset: X-offset [0.1mm] - """ - - return await self.send_command(module="C0", command="AF", x_offset=x_offset) - - async def save_download_date(self, date: datetime.datetime = datetime.datetime.now()): - """Save Download date - - Args: - date: download date. Default now. - """ - - return await self.send_command( - module="C0", - command="AO", - ao=date, - ) - - async def save_technical_status_of_assemblies(self, processor_board: str, power_supply: str): - """Save technical status of assemblies - - Args: - processor_board: Processor board. Art.Nr./Rev./Ser.No. (000000/00/0000) - power_supply: Power supply. Art.Nr./Rev./Ser.No. (000000/00/0000) - """ - - return await self.send_command( - module="C0", - command="BT", - qt=processor_board + " " + power_supply, - ) - - async def set_instrument_configuration( - self, - configuration_data_1: Optional[str] = None, # TODO: configuration byte - configuration_data_2: Optional[str] = None, # TODO: configuration byte - configuration_data_3: Optional[str] = None, # TODO: configuration byte - instrument_size_in_slots_x_range: int = 54, - auto_load_size_in_slots: int = 54, - tip_waste_x_position: int = 13400, - right_x_drive_configuration_byte_1: int = 0, - right_x_drive_configuration_byte_2: int = 0, - minimal_iswap_collision_free_position: int = 3500, - maximal_iswap_collision_free_position: int = 11400, - left_x_arm_width: int = 3700, - right_x_arm_width: int = 3700, - num_pip_channels: int = 0, - num_xl_channels: int = 0, - num_robotic_channels: int = 0, - minimal_raster_pitch_of_pip_channels: int = 90, - minimal_raster_pitch_of_xl_channels: int = 360, - minimal_raster_pitch_of_robotic_channels: int = 360, - pip_maximal_y_position: int = 6065, - left_arm_minimal_y_position: int = 60, - right_arm_minimal_y_position: int = 60, - ): - """Set instrument configuration - - Args: - configuration_data_1: configuration data 1. - configuration_data_2: configuration data 2. - configuration_data_3: configuration data 3. - instrument_size_in_slots_x_range: instrument size in slots (X range). - Must be between 10 and 99. Default 54. - auto_load_size_in_slots: auto load size in slots. Must be between 10 - and 54. Default 54. - tip_waste_x_position: tip waste X-position. Must be between 1000 and - 25000. Default 13400. - right_x_drive_configuration_byte_1: right X drive configuration byte 1 (see - xl parameter bits). Must be between 0 and 1. Default 0. # TODO: this. - right_x_drive_configuration_byte_2: right X drive configuration byte 2 (see - xn parameter bits). Must be between 0 and 1. Default 0. # TODO: this. - minimal_iswap_collision_free_position: minimal iSWAP collision free position for - direct X access. For explanation of calculation see Fig. 4. Must be between 0 and 30000. - Default 3500. - maximal_iswap_collision_free_position: maximal iSWAP collision free position for - direct X access. For explanation of calculation see Fig. 4. Must be between 0 and 30000. - Default 11400 - left_x_arm_width: width of left X arm [0.1 mm]. Must be between 0 and 9999. Default 3700. - right_x_arm_width: width of right X arm [0.1 mm]. Must be between 0 and 9999. Default 3700. - num_pip_channels: number of PIP channels. Must be between 0 and 16. Default 0. - num_xl_channels: number of XL channels. Must be between 0 and 8. Default 0. - num_robotic_channels: number of Robotic channels. Must be between 0 and 8. Default 0. - minimal_raster_pitch_of_pip_channels: minimal raster pitch of PIP channels [0.1 mm]. Must - be between 0 and 999. Default 90. - minimal_raster_pitch_of_xl_channels: minimal raster pitch of XL channels [0.1 mm]. Must be - between 0 and 999. Default 360. - minimal_raster_pitch_of_robotic_channels: minimal raster pitch of Robotic channels [0.1 mm]. - Must be between 0 and 999. Default 360. - pip_maximal_y_position: PIP maximal Y position [0.1 mm]. Must be between 0 and 9999. - Default 6065. - left_arm_minimal_y_position: left arm minimal Y position [0.1 mm]. Must be between 0 and 9999. - Default 60. - right_arm_minimal_y_position: right arm minimal Y position [0.1 mm]. Must be between 0 - and 9999. Default 60. - """ - - assert 1 <= instrument_size_in_slots_x_range <= 9, ( - "instrument_size_in_slots_x_range must be between 1 and 99" - ) - assert 1 <= auto_load_size_in_slots <= 54, "auto_load_size_in_slots must be between 1 and 54" - assert 1000 <= tip_waste_x_position <= 25000, "tip_waste_x_position must be between 1 and 25000" - assert 0 <= right_x_drive_configuration_byte_1 <= 1, ( - "right_x_drive_configuration_byte_1 must be between 0 and 1" - ) - assert 0 <= right_x_drive_configuration_byte_2 <= 1, ( - "right_x_drive_configuration_byte_2 must be between 0 and must1" - ) - assert 0 <= minimal_iswap_collision_free_position <= 30000, ( - "minimal_iswap_collision_free_position must be between 0 and 30000" - ) - assert 0 <= maximal_iswap_collision_free_position <= 30000, ( - "maximal_iswap_collision_free_position must be between 0 and 30000" - ) - assert 0 <= left_x_arm_width <= 9999, "left_x_arm_width must be between 0 and 9999" - assert 0 <= right_x_arm_width <= 9999, "right_x_arm_width must be between 0 and 9999" - assert 0 <= num_pip_channels <= 16, "num_pip_channels must be between 0 and 16" - assert 0 <= num_xl_channels <= 8, "num_xl_channels must be between 0 and 8" - assert 0 <= num_robotic_channels <= 8, "num_robotic_channels must be between 0 and 8" - assert 0 <= minimal_raster_pitch_of_pip_channels <= 999, ( - "minimal_raster_pitch_of_pip_channels must be between 0 and 999" - ) - assert 0 <= minimal_raster_pitch_of_xl_channels <= 999, ( - "minimal_raster_pitch_of_xl_channels must be between 0 and 999" - ) - assert 0 <= minimal_raster_pitch_of_robotic_channels <= 999, ( - "minimal_raster_pitch_of_robotic_channels must be between 0 and 999" - ) - assert 0 <= pip_maximal_y_position <= 9999, "pip_maximal_y_position must be between 0 and 9999" - assert 0 <= left_arm_minimal_y_position <= 9999, ( - "left_arm_minimal_y_position must be between 0 and 9999" - ) - assert 0 <= right_arm_minimal_y_position <= 9999, ( - "right_arm_minimal_y_position must be between 0 and 9999" - ) - - return await self.send_command( - module="C0", - command="AK", - kb=configuration_data_1, - ka=configuration_data_2, - ke=configuration_data_3, - xt=instrument_size_in_slots_x_range, - xa=auto_load_size_in_slots, - xw=tip_waste_x_position, - xr=right_x_drive_configuration_byte_1, - xo=right_x_drive_configuration_byte_2, - xm=minimal_iswap_collision_free_position, - xx=maximal_iswap_collision_free_position, - xu=left_x_arm_width, - xv=right_x_arm_width, - kp=num_pip_channels, - kc=num_xl_channels, - kr=num_robotic_channels, - ys=minimal_raster_pitch_of_pip_channels, - kl=minimal_raster_pitch_of_xl_channels, - km=minimal_raster_pitch_of_robotic_channels, - ym=pip_maximal_y_position, - yu=left_arm_minimal_y_position, - yx=right_arm_minimal_y_position, - ) - - async def save_pip_channel_validation_status(self, validation_status: bool = False): - """Save PIP channel validation status - - Args: - validation_status: PIP channel validation status. Default False. - """ - - return await self.send_command( - module="C0", - command="AJ", - tq=validation_status, - ) - - async def save_xl_channel_validation_status(self, validation_status: bool = False): - """Save XL channel validation status - - Args: - validation_status: XL channel validation status. Default False. - """ - - return await self.send_command( - module="C0", - command="AE", - tx=validation_status, - ) - - # TODO: response - async def configure_node_names(self): - """Configure node names""" - - return await self.send_command(module="C0", command="AJ") - - async def set_deck_data(self, data_index: int = 0, data_stream: str = "0"): - """set deck data - - Args: - data_index: data index. Must be between 0 and 9. Default 0. - data_stream: data stream (12 characters). Default . - """ - - assert 0 <= data_index <= 9, "data_index must be between 0 and 9" - assert len(data_stream) == 12, "data_stream must be 12 chars" - - return await self.send_command( - module="C0", - command="DD", - vi=data_index, - vj=data_stream, - ) - - # -------------- 3.3.3 Settings query (stored in EEPROM) -------------- - - async def request_technical_status_of_assemblies(self): - """Request Technical status of assemblies""" - - # TODO: parse res - return await self.send_command(module="C0", command="QT") - - async def request_installation_data(self): - """Request installation data""" - - # TODO: parse res - return await self.send_command(module="C0", command="RI") - - async def request_device_serial_number(self) -> str: - """Request device serial number""" - return (await self.send_command("C0", "RI", fmt="si####sn&&&&sn&&&&"))["sn"] # type: ignore - - async def request_download_date(self): - """Request download date""" - - # TODO: parse res - return await self.send_command(module="C0", command="RO") - - async def request_verification_data(self, verification_subject: int = 0): - """Request download date - - Args: - verification_subject: verification subject. Must be between 0 and 24. Default 0. - """ - - assert 0 <= verification_subject <= 24, "verification_subject must be between 0 and 24" - - # TODO: parse results. - return await self.send_command(module="C0", command="RO", vo=verification_subject) - - async def request_additional_timestamp_data(self): - """Request additional timestamp data""" - - # TODO: parse res - return await self.send_command(module="C0", command="RS") - - async def request_pip_channel_validation_status(self): - """Request PIP channel validation status""" - - # TODO: parse res - return await self.send_command(module="C0", command="RJ") - - async def request_xl_channel_validation_status(self): - """Request XL channel validation status""" - - # TODO: parse res - return await self.send_command(module="C0", command="UJ") - - async def request_machine_configuration(self) -> MachineConfiguration: - """Request machine configuration (RM command) [SFCO.0035]. - - Returns the basic machine configuration including configuration data 1 (kb) - and number of PIP channels (kp). - """ - - resp = await self.send_command(module="C0", command="RM", fmt="kb**kp##") - kb = resp["kb"] - return MachineConfiguration( - pip_type_1000ul=bool(kb & (1 << 0)), - kb_iswap_installed=bool(kb & (1 << 1)), - main_front_cover_monitoring_installed=bool(kb & (1 << 2)), - auto_load_installed=bool(kb & (1 << 3)), - wash_station_1_installed=bool(kb & (1 << 4)), - wash_station_2_installed=bool(kb & (1 << 5)), - temp_controlled_carrier_1_installed=bool(kb & (1 << 6)), - temp_controlled_carrier_2_installed=bool(kb & (1 << 7)), - num_pip_channels=resp["kp"], - ) - - async def request_extended_configuration(self) -> ExtendedConfiguration: - """Request extended configuration (QM command) with X-arm geometry resolved. - - Returns the full instrument configuration matching the AK - (Set Instrument Configuration) [SFCO.0026] parameter set. Each installed X-drive's - geometry (width, travel range, workspace range) is resolved from the X-drive range - (RU) and working-envelope (UA) queries; `right_x_drive` is None when no second arm - is installed. - """ - - resp = await self.send_command( - module="C0", - command="QM", - fmt="ka******ke********xt##xa##xw#####xl**xn**xr**xo**xm#####xx#####xu####xv####kc#kr#" - + "ys###kl###km###ym####yu####yx####", - ) - - ranges = await self.request_maximal_ranges_of_x_drives() - wraps = await self.request_working_envelopes_per_arm() - - def _build_drive( - byte1: int, byte2: int, side: Literal["left", "right"], width: float - ) -> Optional[DriveConfiguration]: - wrap, workspace_range = wraps[side] - if wrap == 0: # arm not installed - return None - return DriveConfiguration( - pip_installed=bool(byte1 & (1 << 0)), - iswap_installed=bool(byte1 & (1 << 1)), - core_96_head_installed=bool(byte1 & (1 << 2)), - nano_pipettor_installed=bool(byte1 & (1 << 3)), - dispensing_head_384_installed=bool(byte1 & (1 << 4)), - xl_channels_installed=bool(byte1 & (1 << 5)), - tube_gripper_installed=bool(byte1 & (1 << 6)), - imaging_channel_installed=bool(byte1 & (1 << 7)), - robotic_channel_installed=bool(byte2 & (1 << 0)), - width=width, - x_range=ranges[side], - workspace_range=workspace_range, - ) - - left_x_drive = _build_drive(resp["xl"], resp["xn"], "left", resp["xu"] / 10) - assert left_x_drive is not None, "STAR must have a left X-arm" - - ka = resp["ka"] - return ExtendedConfiguration( - left_x_drive_large=bool(ka & (1 << 0)), - ka_core_96_head_installed=bool(ka & (1 << 1)), - right_x_drive_large=bool(ka & (1 << 2)), - pump_station_1_installed=bool(ka & (1 << 3)), - pump_station_2_installed=bool(ka & (1 << 4)), - wash_station_1_type_cr=bool(ka & (1 << 5)), - wash_station_2_type_cr=bool(ka & (1 << 6)), - left_cover_installed=bool(ka & (1 << 7)), - right_cover_installed=bool(ka & (1 << 8)), - additional_front_cover_monitoring_installed=bool(ka & (1 << 9)), - pump_station_3_installed=bool(ka & (1 << 10)), - multi_channel_nano_pipettor_installed=bool(ka & (1 << 11)), - dispensing_head_384_installed=bool(ka & (1 << 12)), - xl_channels_installed=bool(ka & (1 << 13)), - tube_gripper_installed=bool(ka & (1 << 14)), - waste_direction_left=bool(ka & (1 << 15)), - iswap_gripper_wide=bool(ka & (1 << 16)), - additional_channel_nano_pipettor_installed=bool(ka & (1 << 17)), - imaging_channel_installed=bool(ka & (1 << 18)), - robotic_channel_installed=bool(ka & (1 << 19)), - channel_order_ox_first=bool(ka & (1 << 20)), - x0_interface_ham_can=bool(ka & (1 << 21)), - park_heads_with_iswap_off=bool(ka & (1 << 22)), - configuration_data_3=resp["ke"], - instrument_size_slots=resp["xt"], - auto_load_size_slots=resp["xa"], - tip_waste_x_position=resp["xw"] / 10, - left_x_drive=left_x_drive, - right_x_drive=_build_drive(resp["xr"], resp["xo"], "right", resp["xv"] / 10), - min_iswap_collision_free_position=resp["xm"] / 10, - max_iswap_collision_free_position=resp["xx"] / 10, - left_x_arm_width=resp["xu"] / 10, - right_x_arm_width=resp["xv"] / 10, - num_xl_channels=resp["kc"], - num_robotic_channels=resp["kr"], - min_raster_pitch_pip_channels=resp["ys"] / 10, - min_raster_pitch_xl_channels=resp["kl"] / 10, - min_raster_pitch_robotic_channels=resp["km"] / 10, - pip_maximal_y_position=resp["ym"] / 10, - left_arm_min_y_position=resp["yu"] / 10, - right_arm_min_y_position=resp["yx"] / 10, - ) - - async def request_node_names(self): - """Request node names""" - - # TODO: parse res - return await self.send_command(module="C0", command="RK") - - async def request_deck_data(self): - """Request deck data""" - - # TODO: parse res - return await self.send_command(module="C0", command="VD") - - # -------------- 3.4 X-Axis control -------------- - - # -------------- 3.4.1 Movements -------------- - - async def position_left_x_arm_(self, x_position: int = 0): - """Position left X-Arm - - Collision risk! - - Args: - x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. - """ - - assert 0 <= x_position <= 30000, "x_position_ must be between 0 and 30000" - - return await self.send_command( - module="C0", - command="JX", - xs=f"{x_position:05}", - ) - - async def position_right_x_arm_(self, x_position: int = 0): - """Position right X-Arm - - Collision risk! - - Args: - x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. - """ - - assert 0 <= x_position <= 30000, "x_position_ must be between 0 and 30000" - - return await self.send_command( - module="C0", - command="JS", - xs=f"{x_position:05}", - ) - - async def move_left_x_arm_to_position_with_all_attached_components_in_z_safety_position( - self, x_position: int = 0 - ): - """Move left X-arm to position with all attached components in Z-safety position - - Args: - x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. - """ - - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - - return await self.send_command( - module="C0", - command="KX", - xs=x_position, - ) - - async def move_right_x_arm_to_position_with_all_attached_components_in_z_safety_position( - self, x_position: int = 0 - ): - """Move right X-arm to position with all attached components in Z-safety position - - Args: - x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. - """ - - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - - return await self.send_command( - module="C0", - command="KR", - xs=x_position, - ) - - # -------------- 3.4.2 X-Area reservation for external access -------------- - - async def occupy_and_provide_area_for_external_access( - self, - taken_area_identification_number: int = 0, - taken_area_left_margin: int = 0, - taken_area_left_margin_direction: int = 0, - taken_area_size: int = 0, - arm_preposition_mode_related_to_taken_areas: int = 0, - ): - """Occupy and provide area for external access - - Args: - taken_area_identification_number: taken area identification number. Must be between 0 and - 9999. Default 0. - taken_area_left_margin: taken area left margin. Must be between 0 and 99. Default 0. - taken_area_left_margin_direction: taken area left margin direction. 1 = negative. Must be - between 0 and 1. Default 0. - taken_area_size: taken area size. Must be between 0 and 50000. Default 0. - arm_preposition_mode_related_to_taken_areas: 0) left arm to left & right arm to right. - 1) all arms left. 2) all arms right. - """ - - assert 0 <= taken_area_identification_number <= 9999, ( - "taken_area_identification_number must be between 0 and 9999" - ) - assert 0 <= taken_area_left_margin <= 99, "taken_area_left_margin must be between 0 and 99" - assert 0 <= taken_area_left_margin_direction <= 1, ( - "taken_area_left_margin_direction must be between 0 and 1" - ) - assert 0 <= taken_area_size <= 50000, "taken_area_size must be between 0 and 50000" - assert 0 <= arm_preposition_mode_related_to_taken_areas <= 2, ( - "arm_preposition_mode_related_to_taken_areas must be between 0 and 2" - ) - - return await self.send_command( - module="C0", - command="BA", - aq=taken_area_identification_number, - al=taken_area_left_margin, - ad=taken_area_left_margin_direction, - ar=taken_area_size, - ap=arm_preposition_mode_related_to_taken_areas, - ) - - async def release_occupied_area(self, taken_area_identification_number: int = 0): - """Release occupied area - - Args: - taken_area_identification_number: taken area identification number. - Must be between 0 and 9999. Default 0. - """ - - assert 0 <= taken_area_identification_number <= 999, ( - "taken_area_identification_number must be between 0 and 9999" - ) - - return await self.send_command( - module="C0", - command="BB", - aq=taken_area_identification_number, - ) - - async def release_all_occupied_areas(self): - """Release all occupied areas""" - - return await self.send_command(module="C0", command="BC") - - # -------------- 3.4.3 X-query -------------- - - async def request_left_x_arm_position(self) -> float: - """Request left X-Arm position""" - resp_dmm = await self.send_command(module="C0", command="RX", fmt="rx#####") - return cast(float, resp_dmm["rx"]) / 10 - - async def request_right_x_arm_position(self) -> float: - """Request right X-Arm position""" - - resp_dmm = await self.send_command(module="C0", command="QX", fmt="rx#####") - return cast(float, resp_dmm["rx"]) / 10 - - async def request_maximal_ranges_of_x_drives(self) -> Dict[str, Tuple[float, float]]: - """Request the maximal travel range of each X drive. - - Returns: - The `(minimum, maximum)` X position in mm each drive can reach, keyed by side: - `{"left": (min, max), "right": (min, max)}`. - """ - resp = await self.send_command(module="C0", command="RU") - values = [int(v) / 10 for v in resp.split("ru")[-1].strip().split()] - left_min, left_max, right_min, right_max = values - return {"left": (left_min, left_max), "right": (right_min, right_max)} - - async def request_working_envelopes_per_arm( - self, - ) -> Dict[str, Tuple[float, Tuple[float, float]]]: - """Request the working envelope of each installed arm. - - Returns: - Per side, `(wrap_size, (workspace_min, workspace_max))` in mm, keyed by side. A - `wrap_size` of 0 means that arm is not installed. - """ - resp = await self.send_command(module="C0", command="UA") - values = [int(v) / 10 for v in resp.split("ua")[-1].strip().split()] - left_wrap, right_wrap, left_min, left_max, right_min, right_max = values - return { - "left": (left_wrap, (left_min, left_max)), - "right": (right_wrap, (right_min, right_max)), - } - - async def request_left_x_arm_last_collision_type(self): - """Request left X-Arm last collision type (after error 27) - - Returns: - False if present positions collide (not reachable), - True if position is never reachable. - """ - - resp = await self.send_command(module="C0", command="XX", fmt="xq#") - return resp["xq"] == 1 - - async def request_right_x_arm_last_collision_type(self) -> bool: - """Request right X-Arm last collision type (after error 27) - - Returns: - False if present positions collide (not reachable), - True if position is never reachable. - """ - - resp = await self.send_command(module="C0", command="XR", fmt="xq#") - return cast(int, resp["xq"]) == 1 - - # -------------- 3.5 Pipetting channel commands -------------- - - # -------------- 3.5.1 Initialization -------------- - - async def initialize_pip(self): - """Wrapper around initialize_pipetting_channels firmware command.""" - dy = (4050 - 2175) // (self.num_channels - 1) - y_positions = [4050 - i * dy for i in range(self.num_channels)] - - await self.initialize_pipetting_channels( - x_positions=[ - int(self.extended_conf.tip_waste_x_position * 10) - ], # Tip eject waste X position. - y_positions=y_positions, - begin_of_tip_deposit_process=int(self._channel_traversal_height * 10), - end_of_tip_deposit_process=1220, - z_position_at_end_of_a_command=3600, - tip_pattern=[True] * self.num_channels, - tip_type=4, # TODO: get from tip types - discarding_method=0, - ) - - async def initialize_pipetting_channels( - self, - x_positions: List[int] = [0], - y_positions: List[int] = [0], - begin_of_tip_deposit_process: int = 0, - end_of_tip_deposit_process: int = 0, - z_position_at_end_of_a_command: int = 3600, - tip_pattern: List[bool] = [True], - tip_type: int = 16, - discarding_method: int = 1, - ): - """Initialize pipetting channels - - Initialize pipetting channels (discard tips) - - Args: - x_positions: X-Position [0.1mm] (discard position). Must be between 0 and 25000. Default 0. - y_positions: y-Position [0.1mm] (discard position). Must be between 0 and 6500. Default 0. - begin_of_tip_deposit_process: Begin of tip deposit process (Z-discard range) [0.1mm]. Must be - between 0 and 3600. Default 0. - end_of_tip_deposit_process: End of tip deposit process (Z-discard range) [0.1mm]. Must be - between 0 and 3600. Default 0. - z-position_at_end_of_a_command: Z-Position at end of a command [0.1mm]. Must be between 0 and - 3600. Default 3600. - tip_pattern: Tip pattern ( channels involved). Default True. - tip_type: Tip type (recommended is index of longest tip see command 'TT') [0.1mm]. Must be - between 0 and 99. Default 16. - discarding_method: discarding method. 0 = place & shift (tp/ tz = tip cone end height), 1 = - drop (no shift) (tp/ tz = stop disk height). Must be between 0 and 1. Default 1. - """ - - assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" - assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" - assert 0 <= begin_of_tip_deposit_process <= 3600, ( - "begin_of_tip_deposit_process must be between 0 and 3600" - ) - assert 0 <= end_of_tip_deposit_process <= 3600, ( - "end_of_tip_deposit_process must be between 0 and 3600" - ) - assert 0 <= z_position_at_end_of_a_command <= 3600, ( - "z_position_at_end_of_a_command must be between 0 and 3600" - ) - assert 0 <= tip_type <= 99, "tip must be between 0 and 99" - assert 0 <= discarding_method <= 1, "discarding_method must be between 0 and 1" - - return await self.send_command( - module="C0", - command="DI", - read_timeout=120, - xp=[f"{xp:05}" for xp in x_positions], - yp=[f"{yp:04}" for yp in y_positions], - tp=f"{begin_of_tip_deposit_process:04}", - tz=f"{end_of_tip_deposit_process:04}", - te=f"{z_position_at_end_of_a_command:04}", - tm=[f"{tm:01}" for tm in tip_pattern], - tt=f"{tip_type:02}", - ti=discarding_method, - ) - - # -------------- 3.5.2 Tip handling commands using PIP -------------- - - @need_iswap_parked - async def pick_up_tip( - self, - x_positions: List[int], - y_positions: List[int], - tip_pattern: List[bool], - tip_type_idx: int, - begin_tip_pick_up_process: int = 0, - end_tip_pick_up_process: int = 0, - minimum_traverse_height_at_beginning_of_a_command: int = 3600, - pickup_method: TipPickupMethod = TipPickupMethod.OUT_OF_RACK, - ): - """Tip Pick-up - - Args: - x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. - y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. - tip_pattern: Tip pattern (channels involved). - tip_type_idx: Tip type. - begin_tip_pick_up_process: Begin of tip picking up process (Z- range) [0.1mm]. Must be - between 0 and 3600. Default 0. - end_tip_pick_up_process: End of tip picking up process (Z- range) [0.1mm]. Must be - between 0 and 3600. Default 0. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning - of a command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). - Must be between 0 and 3600. Default 3600. - pickup_method: Pick up method. - """ - - assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" - assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" - assert 0 <= begin_tip_pick_up_process <= 3600, ( - "begin_tip_pick_up_process must be between 0 and 3600" - ) - assert 0 <= end_tip_pick_up_process <= 3600, ( - "end_tip_pick_up_process must be between 0 and 3600" - ) - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" - ) - - return await self.send_command( - module="C0", - command="TP", - tip_pattern=tip_pattern, - read_timeout=max(120, self.read_timeout), - xp=[f"{x:05}" for x in x_positions], - yp=[f"{y:04}" for y in y_positions], - tm=tip_pattern, - tt=f"{tip_type_idx:02}", - tp=f"{begin_tip_pick_up_process:04}", - tz=f"{end_tip_pick_up_process:04}", - th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", - td=pickup_method.value, - ) - - @need_iswap_parked - async def discard_tip( - self, - x_positions: List[int], - y_positions: List[int], - tip_pattern: List[bool], - begin_tip_deposit_process: int = 0, - end_tip_deposit_process: int = 0, - minimum_traverse_height_at_beginning_of_a_command: int = 3600, - z_position_at_end_of_a_command: int = 3600, - discarding_method: TipDropMethod = TipDropMethod.DROP, - ): - """discard tip - - Args: - x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. - y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. - tip_pattern: Tip pattern (channels involved). Must be between 0 and 1. Default 1. - begin_tip_deposit_process: Begin of tip deposit process (Z- range) [0.1mm]. Must be between - 0 and 3600. Default 0. - end_tip_deposit_process: End of tip deposit process (Z- range) [0.1mm]. Must be between 0 - and 3600. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a - command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must - be between 0 and 3600. - z-position_at_end_of_a_command: Z-Position at end of a command [0.1mm]. - Must be between 0 and 3600. - discarding_method: Pick up method Pick up method. 0 = auto selection (see command TT - parameter tu) 1 = pick up out of rack. 2 = pick up out of wash liquid (slowly). Must be - between 0 and 2. - - If discarding is PLACE_SHIFT (0), tp/ tz = tip cone end height. - Otherwise, tp/ tz = stop disk height. - """ - - assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" - assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" - assert 0 <= begin_tip_deposit_process <= 3600, ( - "begin_tip_deposit_process must be between 0 and 3600" - ) - assert 0 <= end_tip_deposit_process <= 3600, ( - "end_tip_deposit_process must be between 0 and 3600" - ) - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" - ) - assert 0 <= z_position_at_end_of_a_command <= 3600, ( - "z_position_at_end_of_a_command must be between 0 and 3600" - ) - - return await self.send_command( - module="C0", - command="TR", - tip_pattern=tip_pattern, - read_timeout=max(120, self.read_timeout), - xp=[f"{x:05}" for x in x_positions], - yp=[f"{y:04}" for y in y_positions], - tm=tip_pattern, - tp=begin_tip_deposit_process, - tz=end_tip_deposit_process, - th=minimum_traverse_height_at_beginning_of_a_command, - te=z_position_at_end_of_a_command, - ti=discarding_method.value, - ) - - # TODO:(command:TW) Tip Pick-up for DC wash procedure - - # -------------- 3.5.3 Liquid handling commands using PIP -------------- - - # TODO:(command:DC) Set multiple dispense values using PIP - - @need_iswap_parked - async def aspirate_pip( - self, - aspiration_type: List[int] = [0], - tip_pattern: List[bool] = [True], - x_positions: List[int] = [0], - y_positions: List[int] = [0], - minimum_traverse_height_at_beginning_of_a_command: int = 3600, - min_z_endpos: int = 3600, - lld_search_height: List[int] = [0], - clot_detection_height: List[int] = [60], - liquid_surface_no_lld: List[int] = [3600], - pull_out_distance_transport_air: List[int] = [50], - second_section_height: List[int] = [0], - second_section_ratio: List[int] = [0], - minimum_height: List[int] = [3600], - immersion_depth: List[int] = [0], - immersion_depth_direction: List[int] = [0], - surface_following_distance: List[int] = [0], - aspiration_volumes: List[int] = [0], - aspiration_speed: List[int] = [500], - transport_air_volume: List[int] = [0], - blow_out_air_volume: List[int] = [200], - pre_wetting_volume: List[int] = [0], - lld_mode: List[int] = [1], - gamma_lld_sensitivity: List[int] = [1], - dp_lld_sensitivity: List[int] = [1], - aspirate_position_above_z_touch_off: List[int] = [5], - detection_height_difference_for_dual_lld: List[int] = [0], - swap_speed: List[int] = [100], - settling_time: List[int] = [5], - mix_volume: List[int] = [0], - mix_cycles: List[int] = [0], - mix_position_from_liquid_surface: List[int] = [250], - mix_speed: List[int] = [500], - mix_surface_following_distance: List[int] = [0], - limit_curve_index: List[int] = [0], - tadm_algorithm: bool = False, - recording_mode: int = 0, - # For second section aspiration only - use_2nd_section_aspiration: List[bool] = [False], - retract_height_over_2nd_section_to_empty_tip: List[int] = [60], - dispensation_speed_during_emptying_tip: List[int] = [468], - dosing_drive_speed_during_2nd_section_search: List[int] = [468], - z_drive_speed_during_2nd_section_search: List[int] = [215], - cup_upper_edge: List[int] = [3600], - # deprecated, remove >2026-06 - ratio_liquid_rise_to_tip_deep_in: Optional[List[int]] = None, - immersion_depth_2nd_section: Optional[List[int]] = None, - ): - """aspirate pip - - Aspiration of liquid using PIP. - - It's not really clear what second section aspiration is, but it does not seem to be used - very often. Probably safe to ignore it. - - LLD restrictions! - - "dP and Dual LLD" are used in aspiration only. During dispensation LLD is set to OFF. - - "side touch off" turns LLD & "Z touch off" to OFF , is not available for simultaneous - Asp/Disp. command - - Args: - aspiration_type: Type of aspiration (0 = simple;1 = sequence; 2 = cup emptied). - Must be between 0 and 2. Default 0. - tip_pattern: Tip pattern (channels involved). Default True. - x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. - y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of - a command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). - Must be between 0 and 3600. Default 3600. - min_z_endpos: Minimum z-Position at end of a command [0.1 mm] (refers to all channels - independent of tip pattern parameter 'tm'). Must be between 0 and 3600. Default 3600. - lld_search_height: LLD search height [0.1 mm]. Must be between 0 and 3600. Default 0. - clot_detection_height: Check height of clot detection above current surface (as computed) - of the liquid [0.1mm]. Must be between 0 and 500. Default 60. - liquid_surface_no_lld: Liquid surface at function without LLD [0.1mm]. Must be between 0 - and 3600. Default 3600. - pull_out_distance_transport_air: pull out distance to take transport air in function - without LLD [0.1mm]. Must be between 0 and 3600. Default 50. - second_section_height: Tube 2nd section height measured from "zx" [0.1mm]. Must be - between 0 and 3600. Default 0. - second_section_ratio: Tube 2nd section ratio (see Fig. 2 in fw guide). Must be between - 0 and 10000. Default 0. - minimum_height: Minimum height (maximum immersion depth) [0.1 mm]. Must be between 0 and - 3600. Default 3600. - immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0. - immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out - of liquid). Must be between 0 and 1. Default 0. - surface_following_distance: Surface following distance during aspiration [0.1mm]. Must - be between 0 and 3600. Default 0. - aspiration_volumes: Aspiration volume [0.1ul]. Must be between 0 and 12500. Default 0. - aspiration_speed: Aspiration speed [0.1ul/s]. Must be between 4 and 5000. Default 500. - transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0. - blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 9999. Default 200. - pre_wetting_volume: Pre-wetting volume. Must be between 0 and 999. Default 0. - lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be - between 0 and 4. Default 1. - gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and - 4. Default 1. - dp_lld_sensitivity: delta p LLD sensitivity (1= high, 4=low). Must be between 1 and - 4. Default 1. - aspirate_position_above_z_touch_off: aspirate position above Z touch off [0.1mm]. Must - be between 0 and 100. Default 5. - detection_height_difference_for_dual_lld: Difference in detection height for dual - LLD [0.1 mm]. Must be between 0 and 99. Default 0. - swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1600. - Default 100. - settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5. - mix_volume: mix volume [0.1ul]. Must be between 0 and 12500. Default 0 - mix_cycles: Number of mix cycles. Must be between 0 and 99. Default 0. - mix_position_from_liquid_surface: mix position in Z- direction from - liquid surface (LLD or absolute terms) [0.1mm]. Must be between 0 and 900. Default 250. - mix_speed: Speed of mix [0.1ul/s]. Must be between 4 and 5000. - Default 500. - mix_surface_following_distance: Surface following distance during - mix [0.1mm]. Must be between 0 and 3600. Default 0. - limit_curve_index: limit curve index. Must be between 0 and 999. Default 0. - tadm_algorithm: TADM algorithm. Default False. - recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. Must - be between 0 and 2. Default 0. - use_2nd_section_aspiration: 2nd section aspiration. Default False. - retract_height_over_2nd_section_to_empty_tip: Retract height over 2nd section to empty - tip [0.1mm]. Must be between 0 and 3600. Default 60. - dispensation_speed_during_emptying_tip: Dispensation speed during emptying tip [0.1ul/s] - Must be between 4 and 5000. Default 468. - dosing_drive_speed_during_2nd_section_search: Dosing drive speed during 2nd section - search [0.1ul/s]. Must be between 4 and 5000. Default 468. - z_drive_speed_during_2nd_section_search: Z drive speed during 2nd section search [0.1mm/s]. - Must be between 3 and 1600. Default 215. - cup_upper_edge: Cup upper edge [0.1mm]. Must be between 0 and 3600. Default 3600. - """ - - if ratio_liquid_rise_to_tip_deep_in is not None: - warnings.warn( - "ratio_liquid_rise_to_tip_deep_in is deprecated and will be removed in a future version.", - DeprecationWarning, - stacklevel=2, - ) - if immersion_depth_2nd_section is not None: - warnings.warn( - "immersion_depth_2nd_section is deprecated and will be removed in a future version.", - DeprecationWarning, - stacklevel=2, - ) - - assert all(0 <= x <= 2 for x in aspiration_type), "aspiration_type must be between 0 and 2" - assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" - assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" - ) - assert 0 <= min_z_endpos <= 3600, "min_z_endpos must be between 0 and 3600" - assert all(0 <= x <= 3600 for x in lld_search_height), ( - "lld_search_height must be between 0 and 3600" - ) - assert all(0 <= x <= 500 for x in clot_detection_height), ( - "clot_detection_height must be between 0 and 500" - ) - assert all(0 <= x <= 3600 for x in liquid_surface_no_lld), ( - "liquid_surface_no_lld must be between 0 and 3600" - ) - assert all(0 <= x <= 3600 for x in pull_out_distance_transport_air), ( - "pull_out_distance_transport_air must be between 0 and 3600" - ) - assert all(0 <= x <= 3600 for x in second_section_height), ( - "second_section_height must be between 0 and 3600" - ) - assert all(0 <= x <= 10000 for x in second_section_ratio), ( - "second_section_ratio must be between 0 and 10000" - ) - assert all(0 <= x <= 3600 for x in minimum_height), "minimum_height must be between 0 and 3600" - assert all(0 <= x <= 3600 for x in immersion_depth), ( - "immersion_depth must be between 0 and 3600" - ) - assert all(0 <= x <= 1 for x in immersion_depth_direction), ( - "immersion_depth_direction must be between 0 and 1" - ) - assert all(0 <= x <= 3600 for x in surface_following_distance), ( - "surface_following_distance must be between 0 and 3600" - ) - assert all(0 <= x <= 12500 for x in aspiration_volumes), ( - "aspiration_volumes must be between 0 and 12500" - ) - assert all(4 <= x <= 5000 for x in aspiration_speed), ( - "aspiration_speed must be between 4 and 5000" - ) - assert all(0 <= x <= 500 for x in transport_air_volume), ( - "transport_air_volume must be between 0 and 500" - ) - assert all(0 <= x <= 9999 for x in blow_out_air_volume), ( - "blow_out_air_volume must be between 0 and 9999" - ) - assert all(0 <= x <= 999 for x in pre_wetting_volume), ( - "pre_wetting_volume must be between 0 and 999" - ) - assert all(0 <= x <= 4 for x in lld_mode), "lld_mode must be between 0 and 4" - assert all(1 <= x <= 4 for x in gamma_lld_sensitivity), ( - "gamma_lld_sensitivity must be between 1 and 4" - ) - assert all(1 <= x <= 4 for x in dp_lld_sensitivity), ( - "dp_lld_sensitivity must be between 1 and 4" - ) - assert all(0 <= x <= 100 for x in aspirate_position_above_z_touch_off), ( - "aspirate_position_above_z_touch_off must be between 0 and 100" - ) - assert all(0 <= x <= 99 for x in detection_height_difference_for_dual_lld), ( - "detection_height_difference_for_dual_lld must be between 0 and 99" - ) - assert all(3 <= x <= 1600 for x in swap_speed), "swap_speed must be between 3 and 1600" - assert all(0 <= x <= 99 for x in settling_time), "settling_time must be between 0 and 99" - assert all(0 <= x <= 12500 for x in mix_volume), "mix_volume must be between 0 and 12500" - assert all(0 <= x <= 99 for x in mix_cycles), "mix_cycles must be between 0 and 99" - assert all(0 <= x <= 900 for x in mix_position_from_liquid_surface), ( - "mix_position_from_liquid_surface must be between 0 and 900" - ) - assert all(4 <= x <= 5000 for x in mix_speed), "mix_speed must be between 4 and 5000" - assert all(0 <= x <= 3600 for x in mix_surface_following_distance), ( - "mix_surface_following_distance must be between 0 and 3600" - ) - assert all(0 <= x <= 999 for x in limit_curve_index), ( - "limit_curve_index must be between 0 and 999" - ) - assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" - assert all(0 <= x <= 3600 for x in retract_height_over_2nd_section_to_empty_tip), ( - "retract_height_over_2nd_section_to_empty_tip must be between 0 and 3600" - ) - assert all(4 <= x <= 5000 for x in dispensation_speed_during_emptying_tip), ( - "dispensation_speed_during_emptying_tip must be between 4 and 5000" - ) - assert all(4 <= x <= 5000 for x in dosing_drive_speed_during_2nd_section_search), ( - "dosing_drive_speed_during_2nd_section_search must be between 4 and 5000" - ) - assert all(3 <= x <= 1600 for x in z_drive_speed_during_2nd_section_search), ( - "z_drive_speed_during_2nd_section_search must be between 3 and 1600" - ) - assert all(0 <= x <= 3600 for x in cup_upper_edge), "cup_upper_edge must be between 0 and 3600" - - return await self.send_command( - module="C0", - command="AS", - tip_pattern=tip_pattern, - read_timeout=max(300, self.read_timeout), - at=[f"{at:01}" for at in aspiration_type], - tm=tip_pattern, - xp=[f"{xp:05}" for xp in x_positions], - yp=[f"{yp:04}" for yp in y_positions], - th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", - te=f"{min_z_endpos:04}", - lp=[f"{lp:04}" for lp in lld_search_height], - ch=[f"{ch:03}" for ch in clot_detection_height], - zl=[f"{zl:04}" for zl in liquid_surface_no_lld], - po=[f"{po:04}" for po in pull_out_distance_transport_air], - zu=[f"{zu:04}" for zu in second_section_height], - zr=[f"{zr:05}" for zr in second_section_ratio], - zx=[f"{zx:04}" for zx in minimum_height], - ip=[f"{ip:04}" for ip in immersion_depth], - it=[f"{it}" for it in immersion_depth_direction], - fp=[f"{fp:04}" for fp in surface_following_distance], - av=[f"{av:05}" for av in aspiration_volumes], - as_=[f"{as_:04}" for as_ in aspiration_speed], - ta=[f"{ta:03}" for ta in transport_air_volume], - ba=[f"{ba:04}" for ba in blow_out_air_volume], - oa=[f"{oa:03}" for oa in pre_wetting_volume], - lm=[f"{lm}" for lm in lld_mode], - ll=[f"{ll}" for ll in gamma_lld_sensitivity], - lv=[f"{lv}" for lv in dp_lld_sensitivity], - zo=[f"{zo:03}" for zo in aspirate_position_above_z_touch_off], - ld=[f"{ld:02}" for ld in detection_height_difference_for_dual_lld], - de=[f"{de:04}" for de in swap_speed], - wt=[f"{wt:02}" for wt in settling_time], - mv=[f"{mv:05}" for mv in mix_volume], - mc=[f"{mc:02}" for mc in mix_cycles], - mp=[f"{mp:03}" for mp in mix_position_from_liquid_surface], - ms=[f"{ms:04}" for ms in mix_speed], - mh=[f"{mh:04}" for mh in mix_surface_following_distance], - gi=[f"{gi:03}" for gi in limit_curve_index], - gj=tadm_algorithm, - gk=recording_mode, - lk=[1 if lk else 0 for lk in use_2nd_section_aspiration], - ik=[f"{ik:04}" for ik in retract_height_over_2nd_section_to_empty_tip], - sd=[f"{sd:04}" for sd in dispensation_speed_during_emptying_tip], - se=[f"{se:04}" for se in dosing_drive_speed_during_2nd_section_search], - sz=[f"{sz:04}" for sz in z_drive_speed_during_2nd_section_search], - io=[f"{io:04}" for io in cup_upper_edge], - ) - - @need_iswap_parked - async def dispense_pip( - self, - tip_pattern: List[bool], - dispensing_mode: List[int] = [0], - x_positions: List[int] = [0], - y_positions: List[int] = [0], - minimum_height: List[int] = [3600], - lld_search_height: List[int] = [0], - liquid_surface_no_lld: List[int] = [3600], - pull_out_distance_transport_air: List[int] = [50], - immersion_depth: List[int] = [0], - immersion_depth_direction: List[int] = [0], - surface_following_distance: List[int] = [0], - second_section_height: List[int] = [0], - second_section_ratio: List[int] = [0], - minimum_traverse_height_at_beginning_of_a_command: int = 3600, - min_z_endpos: int = 3600, # - dispense_volumes: List[int] = [0], - dispense_speed: List[int] = [500], - cut_off_speed: List[int] = [250], - stop_back_volume: List[int] = [0], - transport_air_volume: List[int] = [0], - blow_out_air_volume: List[int] = [200], - lld_mode: List[int] = [1], - side_touch_off_distance: int = 1, - dispense_position_above_z_touch_off: List[int] = [5], - gamma_lld_sensitivity: List[int] = [1], - dp_lld_sensitivity: List[int] = [1], - swap_speed: List[int] = [100], - settling_time: List[int] = [5], - mix_volume: List[int] = [0], - mix_cycles: List[int] = [0], - mix_position_from_liquid_surface: List[int] = [250], - mix_speed: List[int] = [500], - mix_surface_following_distance: List[int] = [0], - limit_curve_index: List[int] = [0], - tadm_algorithm: bool = False, - recording_mode: int = 0, - ): - """dispense pip - - Dispensing of liquid using PIP. - - LLD restrictions! - - "dP and Dual LLD" are used in aspiration only. During dispensation all pressure-based - LLD is set to OFF. - - "side touch off" turns LLD & "Z touch off" to OFF , is not available for simultaneous - Asp/Disp. command - - Args: - dispensing_mode: Type of dispensing mode 0 = Partial volume in jet mode - 1 = Blow out in jet mode 2 = Partial volume at surface - 3 = Blow out at surface 4 = Empty tip at fix position. - tip_pattern: Tip pattern (channels involved). Default True. - x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. - y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. - minimum_height: Minimum height (maximum immersion depth) [0.1 mm]. Must be between 0 and - 3600. Default 3600. - lld_search_height: LLD search height [0.1 mm]. Must be between 0 and 3600. Default 0. - liquid_surface_no_lld: Liquid surface at function without LLD [0.1mm]. Must be between 0 and - 3600. Default 3600. - pull_out_distance_transport_air: pull out distance to take transport air in function without - LLD [0.1mm]. Must be between 0 and 3600. Default 50. - immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0. - immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out of - liquid). Must be between 0 and 1. Default 0. - surface_following_distance: Surface following distance during aspiration [0.1mm]. Must be - between 0 and 3600. Default 0. - second_section_height: Tube 2nd section height measured from "zx" [0.1mm]. Must be between - 0 and 3600. Default 0. - second_section_ratio: Tube 2nd section ratio (see Fig. 2 in fw guide). Must be between 0 and - 10000. Default 0. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a - command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be - between 0 and 3600. Default 3600. - min_z_endpos: Minimum z-Position at end of a command [0.1 mm] (refers to all channels - independent of tip pattern parameter 'tm'). Must be between 0 and 3600. Default 3600. - dispense_volumes: Dispense volume [0.1ul]. Must be between 0 and 12500. Default 0. - dispense_speed: Dispense speed [0.1ul/s]. Must be between 4 and 5000. Default 500. - cut_off_speed: Cut-off speed [0.1ul/s]. Must be between 4 and 5000. Default 250. - stop_back_volume: Stop back volume [0.1ul]. Must be between 0 and 180. Default 0. - transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0. - blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 9999. Default 200. - lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be between 0 - and 4. Default 1. - side_touch_off_distance: side touch off distance [0.1 mm] (0 = OFF). Must be between 0 and 45. - Default 1. - dispense_position_above_z_touch_off: dispense position above Z touch off [0.1 s] (0 = OFF) - Turns LLD & Z touch off to OFF if ON!. Must be between 0 and 100. Default 5. - gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and 4. - Default 1. - dp_lld_sensitivity: delta p LLD sensitivity (1= high, 4=low). Must be between 1 and 4. - Default 1. - swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1600. - Default 100. - settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5. - mix_volume: Mix volume [0.1ul]. Must be between 0 and 12500. Default 0. - mix_cycles: Number of mix cycles. Must be between 0 and 99. Default 0. - mix_position_from_liquid_surface: Mix position in Z- direction from liquid surface (LLD or - absolute terms) [0.1mm]. Must be between 0 and 900. Default 250. - mix_speed: Speed of mixing [0.1ul/s]. Must be between 4 and 5000. Default 500. - mix_surface_following_distance: Surface following distance during mixing [0.1mm]. Must be - between 0 and 3600. Default 0. - limit_curve_index: limit curve index. Must be between 0 and 999. Default 0. - tadm_algorithm: TADM algorithm. Default False. - recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. Must - be between 0 and 2. Default 0. - """ - - assert all(0 <= x <= 4 for x in dispensing_mode), "dispensing_mode must be between 0 and 4" - assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" - assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" - assert any(0 <= x <= 3600 for x in minimum_height), "minimum_height must be between 0 and 3600" - assert any(0 <= x <= 3600 for x in lld_search_height), ( - "lld_search_height must be between 0 and 3600" - ) - assert any(0 <= x <= 3600 for x in liquid_surface_no_lld), ( - "liquid_surface_no_lld must be between 0 and 3600" - ) - assert any(0 <= x <= 3600 for x in pull_out_distance_transport_air), ( - "pull_out_distance_transport_air must be between 0 and 3600" - ) - assert any(0 <= x <= 3600 for x in immersion_depth), ( - "immersion_depth must be between 0 and 3600" - ) - assert any(0 <= x <= 1 for x in immersion_depth_direction), ( - "immersion_depth_direction must be between 0 and 1" - ) - assert any(0 <= x <= 3600 for x in surface_following_distance), ( - "surface_following_distance must be between 0 and 3600" - ) - assert any(0 <= x <= 3600 for x in second_section_height), ( - "second_section_height must be between 0 and 3600" - ) - assert any(0 <= x <= 10000 for x in second_section_ratio), ( - "second_section_ratio must be between 0 and 10000" - ) - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" - ) - assert 0 <= min_z_endpos <= 3600, "min_z_endpos must be between 0 and 3600" - assert any(0 <= x <= 12500 for x in dispense_volumes), ( - "dispense_volume must be between 0 and 12500" - ) - assert any(4 <= x <= 5000 for x in dispense_speed), "dispense_speed must be between 4 and 5000" - assert any(4 <= x <= 5000 for x in cut_off_speed), "cut_off_speed must be between 4 and 5000" - assert any(0 <= x <= 180 for x in stop_back_volume), ( - "stop_back_volume must be between 0 and 180" - ) - assert any(0 <= x <= 500 for x in transport_air_volume), ( - "transport_air_volume must be between 0 and 500" - ) - assert any(0 <= x <= 9999 for x in blow_out_air_volume), ( - "blow_out_air_volume must be between 0 and 9999" - ) - assert any(0 <= x <= 4 for x in lld_mode), "lld_mode must be between 0 and 4" - assert 0 <= side_touch_off_distance <= 45, "side_touch_off_distance must be between 0 and 45" - assert any(0 <= x <= 100 for x in dispense_position_above_z_touch_off), ( - "dispense_position_above_z_touch_off must be between 0 and 100" - ) - assert any(1 <= x <= 4 for x in gamma_lld_sensitivity), ( - "gamma_lld_sensitivity must be between 1 and 4" - ) - assert any(1 <= x <= 4 for x in dp_lld_sensitivity), ( - "dp_lld_sensitivity must be between 1 and 4" - ) - assert any(3 <= x <= 1600 for x in swap_speed), "swap_speed must be between 3 and 1600" - assert any(0 <= x <= 99 for x in settling_time), "settling_time must be between 0 and 99" - assert any(0 <= x <= 12500 for x in mix_volume), "mix_volume must be between 0 and 12500" - assert any(0 <= x <= 99 for x in mix_cycles), "mix_cycles must be between 0 and 99" - assert any(0 <= x <= 900 for x in mix_position_from_liquid_surface), ( - "mix_position_from_liquid_surface must be between 0 and 900" - ) - assert any(4 <= x <= 5000 for x in mix_speed), "mix_speed must be between 4 and 5000" - assert any(0 <= x <= 3600 for x in mix_surface_following_distance), ( - "mix_surface_following_distance must be between 0 and 3600" - ) - assert any(0 <= x <= 999 for x in limit_curve_index), ( - "limit_curve_index must be between 0 and 999" - ) - assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" - - return await self.send_command( - module="C0", - command="DS", - tip_pattern=tip_pattern, - read_timeout=max(300, self.read_timeout), - dm=[f"{dm:01}" for dm in dispensing_mode], - tm=[f"{tm:01}" for tm in tip_pattern], - xp=[f"{xp:05}" for xp in x_positions], - yp=[f"{yp:04}" for yp in y_positions], - zx=[f"{zx:04}" for zx in minimum_height], - lp=[f"{lp:04}" for lp in lld_search_height], - zl=[f"{zl:04}" for zl in liquid_surface_no_lld], - po=[f"{po:04}" for po in pull_out_distance_transport_air], - ip=[f"{ip:04}" for ip in immersion_depth], - it=[f"{it:01}" for it in immersion_depth_direction], - fp=[f"{fp:04}" for fp in surface_following_distance], - zu=[f"{zu:04}" for zu in second_section_height], - zr=[f"{zr:05}" for zr in second_section_ratio], - th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", - te=f"{min_z_endpos:04}", - dv=[f"{dv:05}" for dv in dispense_volumes], - ds=[f"{ds:04}" for ds in dispense_speed], - ss=[f"{ss:04}" for ss in cut_off_speed], - rv=[f"{rv:03}" for rv in stop_back_volume], - ta=[f"{ta:03}" for ta in transport_air_volume], - ba=[f"{ba:04}" for ba in blow_out_air_volume], - lm=[f"{lm:01}" for lm in lld_mode], - dj=f"{side_touch_off_distance:02}", # - zo=[f"{zo:03}" for zo in dispense_position_above_z_touch_off], - ll=[f"{ll:01}" for ll in gamma_lld_sensitivity], - lv=[f"{lv:01}" for lv in dp_lld_sensitivity], - de=[f"{de:04}" for de in swap_speed], - wt=[f"{wt:02}" for wt in settling_time], - mv=[f"{mv:05}" for mv in mix_volume], - mc=[f"{mc:02}" for mc in mix_cycles], - mp=[f"{mp:03}" for mp in mix_position_from_liquid_surface], - ms=[f"{ms:04}" for ms in mix_speed], - mh=[f"{mh:04}" for mh in mix_surface_following_distance], - gi=[f"{gi:03}" for gi in limit_curve_index], - gj=tadm_algorithm, # - gk=recording_mode, # - ) - - # TODO:(command:DA) Simultaneous aspiration & dispensation of liquid - - # TODO:(command:DF) Dispense on fly using PIP (Partial volume in jet mode) - - # TODO:(command:LW) DC Wash procedure using PIP - - # -------------- 3.5.5 CoRe gripper commands -------------- - - def _get_core_front_back(self): - core_grippers = self.deck.get_resource("core_grippers") - assert isinstance(core_grippers, HamiltonCoreGrippers), "core_grippers must be CoReGrippers" - back_channel_y_center = int( - ( - core_grippers.get_location_wrt(self.deck).y - + core_grippers.back_channel_y_center - + self.core_adjustment.y - ) - ) - front_channel_y_center = int( - ( - core_grippers.get_location_wrt(self.deck).y - + core_grippers.front_channel_y_center - + self.core_adjustment.y - ) - ) - assert back_channel_y_center > front_channel_y_center, ( - "back_channel_y_center must be greater than front_channel_y_center" - ) - assert front_channel_y_center > self.extended_conf.left_arm_min_y_position, ( - f"front_channel_y_center must be greater than {self.extended_conf.left_arm_min_y_position}mm" - ) - return back_channel_y_center, front_channel_y_center - - def _get_core_x(self) -> float: - """Get the X coordinate for the CoRe grippers based on deck size and adjustment.""" - core_grippers = self.deck.get_resource("core_grippers") - assert isinstance(core_grippers, HamiltonCoreGrippers), "core_grippers must be CoReGrippers" - return core_grippers.get_location_wrt(self.deck).x + self.core_adjustment.x - - async def get_core(self, p1: int, p2: int): - warnings.warn("Deprecated. Use pick_up_core_gripper_tools instead.", DeprecationWarning) - assert p1 + 1 == p2, "p2 must be p1 + 1" - return await self.pick_up_core_gripper_tools(front_channel=p2 - 1) # p1 here is 1-indexed - - @need_iswap_parked - async def pick_up_core_gripper_tools( - self, - front_channel: int, - front_offset: Optional[Coordinate] = None, - back_offset: Optional[Coordinate] = None, - ): - """Get CoRe gripper tool from wasteblock mount.""" - - if not 0 < front_channel < self.num_channels: - raise ValueError(f"front_channel must be between 1 and {self.num_channels - 1} (inclusive)") - back_channel = front_channel - 1 - - # Only enforce x equality if both offsets are explicitly provided. - if front_offset is not None and back_offset is not None and front_offset.x != back_offset.x: - raise ValueError("front_offset.x and back_offset.x must be the same") - - xs = self._get_core_x() + (front_offset.x if front_offset is not None else 0) - - back_channel_y_center, front_channel_y_center = self._get_core_front_back() - if back_offset is not None: - back_channel_y_center += back_offset.y - if front_offset is not None: - front_channel_y_center += front_offset.y - - if front_offset is not None and back_offset is not None and front_offset.z != back_offset.z: - raise ValueError("front_offset.z and back_offset.z must be the same") - z_offset = 0 if front_offset is None else front_offset.z - begin_z_coord = round(235.0 + self.core_adjustment.z + z_offset) - end_z_coord = round(225.0 + self.core_adjustment.z + z_offset) - - command_output = await self.send_command( - module="C0", - command="ZT", - xs=f"{round(xs * 10):05}", - xd="0", - ya=f"{round(back_channel_y_center * 10):04}", - yb=f"{round(front_channel_y_center * 10):04}", - pa=f"{back_channel + 1:02}", # star is 1-indexed - pb=f"{front_channel + 1:02}", # star is 1-indexed - tp=f"{round(begin_z_coord * 10):04}", - tz=f"{round(end_z_coord * 10):04}", - th=round(self._iswap_traversal_height * 10), - tt="14", - ) - self._core_parked = False - return command_output - - async def put_core(self): - warnings.warn("Deprecated. Use return_core_gripper_tools instead.", DeprecationWarning) - return await self.return_core_gripper_tools() - - @need_iswap_parked - async def return_core_gripper_tools( - self, - front_offset: Optional[Coordinate] = None, - back_offset: Optional[Coordinate] = None, - ): - """Put CoRe gripper tool at wasteblock mount.""" - - # Only enforce x equality if both offsets are explicitly provided. - if front_offset is not None and back_offset is not None and back_offset.x != front_offset.x: - raise ValueError("back_offset.x and front_offset.x must be the same") - - xs = self._get_core_x() + (front_offset.x if front_offset is not None else 0) - - back_channel_y_center, front_channel_y_center = self._get_core_front_back() - if back_offset is not None: - back_channel_y_center += back_offset.y - if front_offset is not None: - front_channel_y_center += front_offset.y - - if front_offset is not None and back_offset is not None and back_offset.z != front_offset.z: - raise ValueError("back_offset.z and front_offset.z must be the same") - z_offset = 0 if front_offset is None else front_offset.z - begin_z_coord = round(215.0 + self.core_adjustment.z + z_offset) - end_z_coord = round(205.0 + self.core_adjustment.z + z_offset) - - command_output = await self.send_command( - module="C0", - command="ZS", - xs=f"{round(xs * 10):05}", - xd="0", - ya=f"{round(back_channel_y_center * 10):04}", - yb=f"{round(front_channel_y_center * 10):04}", - tp=f"{round(begin_z_coord * 10):04}", - tz=f"{round(end_z_coord * 10):04}", - th=round(self._iswap_traversal_height * 10), - te=round(self._iswap_traversal_height * 10), - ) - self._core_parked = True - return command_output - - async def core_open_gripper(self): - """Open CoRe gripper tool.""" - return await self.send_command(module="C0", command="ZO") - - @need_iswap_parked - async def core_get_plate( - self, - x_position: int = 0, - x_direction: int = 0, - y_position: int = 0, - y_gripping_speed: int = 50, - z_position: int = 0, - z_speed: int = 500, - open_gripper_position: int = 0, - plate_width: int = 0, - grip_strength: int = 15, - minimum_traverse_height_at_beginning_of_a_command: int = 2750, - minimum_z_position_at_the_command_end: int = 2750, - ): - """Get plate with CoRe gripper tool from wasteblock mount.""" - - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" - assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" - assert 0 <= y_gripping_speed <= 3700, "y_gripping_speed must be between 0 and 3700" - assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" - assert 0 <= z_speed <= 1287, "z_speed must be between 0 and 1287" - assert 0 <= open_gripper_position <= 9999, "open_gripper_position must be between 0 and 9999" - assert 0 <= plate_width <= 9999, "plate_width must be between 0 and 9999" - assert 0 <= grip_strength <= 99, "grip_strength must be between 0 and 99" - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" - ) - assert 0 <= minimum_z_position_at_the_command_end <= 3600, ( - "minimum_z_position_at_the_command_end must be between 0 and 3600" - ) - - command_output = await self.send_command( - module="C0", - command="ZP", - xs=f"{x_position:05}", - xd=x_direction, - yj=f"{y_position:04}", - yv=f"{y_gripping_speed:04}", - zj=f"{z_position:04}", - zy=f"{z_speed:04}", - yo=f"{open_gripper_position:04}", - yg=f"{plate_width:04}", - yw=f"{grip_strength:02}", - th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", - te=f"{minimum_z_position_at_the_command_end:04}", - ) - - return command_output - - @need_iswap_parked - async def core_put_plate( - self, - x_position: int = 0, - x_direction: int = 0, - y_position: int = 0, - z_position: int = 0, - z_press_on_distance: int = 0, - z_speed: int = 500, - open_gripper_position: int = 0, - minimum_traverse_height_at_beginning_of_a_command: int = 2750, - z_position_at_the_command_end: int = 2750, - return_tool: bool = True, - ): - """Put plate with CoRe gripper tool and return to wasteblock mount.""" - - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" - assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" - assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" - assert 0 <= z_press_on_distance <= 50, "z_press_on_distance must be between 0 and 999" - assert 0 <= z_speed <= 1600, "z_speed must be between 0 and 1600" - assert 0 <= open_gripper_position <= 9999, "open_gripper_position must be between 0 and 9999" - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" - ) - assert 0 <= z_position_at_the_command_end <= 3600, ( - "z_position_at_the_command_end must be between 0 and 3600" - ) - - command_output = await self.send_command( - module="C0", - command="ZR", - xs=f"{x_position:05}", - xd=x_direction, - yj=f"{y_position:04}", - zj=f"{z_position:04}", - zi=f"{z_press_on_distance:03}", - zy=f"{z_speed:04}", - yo=f"{open_gripper_position:04}", - th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", - te=f"{z_position_at_the_command_end:04}", - ) - - if return_tool: - await self.return_core_gripper_tools() - - return command_output - - @need_iswap_parked - async def core_move_plate_to_position( - self, - x_position: int = 0, - x_direction: int = 0, - x_acceleration_index: int = 4, - y_position: int = 0, - z_position: int = 0, - z_speed: int = 500, - minimum_traverse_height_at_beginning_of_a_command: int = 3600, - ): - """Move a plate with CoRe gripper tool.""" - - command_output = await self.send_command( - module="C0", - command="ZM", - xs=f"{x_position:05}", - xd=x_direction, - xg=x_acceleration_index, - yj=f"{y_position:04}", - zj=f"{z_position:04}", - zy=f"{z_speed:04}", - th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", - ) - - return command_output - - async def core_read_barcode_of_picked_up_resource( - self, - rails: int, - reading_direction: Literal["vertical", "horizontal", "free"] = "horizontal", - minimal_z_position: float = 220.0, - traverse_height_at_beginning_of_a_command: float = 275.0, - z_speed: float = 128.7, - allow_manual_input: bool = False, - labware_description: Optional[str] = None, - ): - """Read a 1D barcode using the CoRe gripper scanner. - - Args: - rails: Rail/slot number where the barcode to be read is located (1-54). - reading_direction: Direction of barcode reading: 'vertical', 'horizontal', or 'free'. Default is 'horizontal'. - minimal_z_position: Minimal Z position [mm] during barcode reading (220.0-360.0). Default is 220.0. - traverse_height_at_beginning_of_a_command: Traverse height at beginning of command [mm] (0.0-360.0). Default is 275.0. - z_speed: Z speed [mm/s] during barcode reading (0.0-128.7). Default is 128.7. - allow_manual_input: If True, allows the user to manually input a barcode if scanning fails. Default is False. - labware_description: Optional description of the labware being scanned, used in the manual input - prompt to provide context to the user. - - Returns: - A Barcode if one is successfully read, either by the scanner or via manual user input. - - Raises: - STARFirmwareError: if the firmware reports an error in the response. - ValueError: if the response format is unexpected or if no barcode is present and - ``allow_manual_input`` is False, or if manual input is enabled but the user does not - provide a barcode. - """ - - assert 1 <= rails <= 54, "rails must be between 1 and 54" - assert 0 <= minimal_z_position <= 3600, "minimal_z_position must be between 0 and 3600" - assert 0 <= traverse_height_at_beginning_of_a_command <= 3600, ( - "traverse_height_at_beginning_of_a_command must be between 0 and 3600" - ) - assert 0 <= z_speed <= 1287, "z_speed must be between 0 and 1287" - - try: - reading_direction_int = { - "vertical": 0, - "horizontal": 1, - "free": 2, - }[reading_direction] - except KeyError as e: - raise ValueError( - "reading_direction must be one of 'vertical', 'horizontal', or 'free'" - ) from e - - command_output = cast( - str, - await self.send_command( - module="C0", - command="ZB", - cp=f"{rails:02}", - zb=f"{round(minimal_z_position * 10):04}", - th=f"{round(traverse_height_at_beginning_of_a_command * 10):04}", - zy=f"{round(z_speed * 10):04}", - bd=reading_direction_int, - ma="0250 2100 0860 0200", - mr=0, - mo="000 000 000 000 000 000 000", - ), - ) - - if command_output is None: - raise RuntimeError("No response received from CoRe barcode read command.") - - resp = command_output.strip() - er_index = resp.find("er") - if er_index == -1: - # Unexpected format: no error section present. - raise ValueError(f"Unexpected CoRe barcode response (no error section): {resp}") - - self.check_fw_string_error(resp) - - # Parse barcode section: firmware returns `bb/LL` where LL is length (00..99). - bb_index = resp.find("bb/", er_index + 7) - if bb_index == -1: - # Unexpected layout of barcode section. - raise ValueError(f"Unexpected CoRe barcode response format: {resp}") - - if len(resp) < bb_index + 5: - # Need at least 'bb/LL'. - raise ValueError(f"Unexpected CoRe barcode response format: {resp}") - - bb_len_str = resp[bb_index + 3 : bb_index + 5] - try: - bb_len = int(bb_len_str) - except ValueError as e: - raise ValueError(f"Invalid CoRe barcode length field 'bb': {bb_len_str}") from e - - barcode_str = resp[bb_index + 5 :].strip() - - # No barcode present. - if bb_len == 0: - if allow_manual_input: - # Provide context and allow the user to recover by entering a barcode manually. - # Use ANSI color codes to make the prompt stand out in typical terminals. - YELLOW = "\033[93m" - BOLD = "\033[1m" - RESET = "\033[0m" - - lines = [ - f"{YELLOW}{BOLD}=== CoRe barcode scan failed ==={RESET}", - f"{YELLOW}No barcode read by CoRe scanner.{RESET}", - ] - if labware_description is not None: - lines.append(f"{YELLOW}Labware: {labware_description}{RESET}") - lines.append(f"{YELLOW}Enter barcode manually (leave blank to abort): {RESET}") - prompt = "\n".join(lines) - - # Blocking input is acceptable here because this helper is only intended for CLI usage. - user_barcode = input(prompt).strip() - if not user_barcode: - raise ValueError("No barcode read by CoRe scanner and no manual barcode provided.") - - return Barcode( - data=user_barcode, - symbology="code128", - position_on_resource="front", - ) - - raise ValueError("No barcode read by CoRe scanner.") - - if not barcode_str: - # Length > 0 but no data present. - raise ValueError(f"Unexpected CoRe barcode response format: {resp}") - - # If the firmware returns more characters than declared, truncate to the declared length. - if len(barcode_str) > bb_len: - barcode_str = barcode_str[:bb_len] - - return Barcode( - data=barcode_str, - symbology="code128", - position_on_resource="front", - ) - - # -------------- 3.5.6 Adjustment & movement commands -------------- - - async def position_single_pipetting_channel_in_y_direction( - self, pipetting_channel_index: int, y_position: int - ): - """Position single pipetting channel in Y-direction. - - Args: - pipetting_channel_index: Index of pipetting channel. Must be between 1 and 16. - y_position: y position [0.1mm]. Must be between 0 and 6500. - """ - - assert 1 <= pipetting_channel_index <= self.num_channels, ( - "pipetting_channel_index must be between 1 and self" - ) - assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" - - return await self.send_command( - module="C0", - command="KY", - pn=f"{pipetting_channel_index:02}", - yj=f"{y_position:04}", - ) - - async def position_single_pipetting_channel_in_z_direction( - self, pipetting_channel_index: int, z_position: int - ): - """Position single pipetting channel in Z-direction. - - Note that this refers to the point of the tip if a tip is mounted! - - Args: - pipetting_channel_index: Index of pipetting channel. Must be between 1 and 16. - z_position: y position [0.1mm]. Must be between 0 and 3347. The docs say 3600,but empirically 3347 is the max. - """ - - assert 1 <= pipetting_channel_index <= self.num_channels, ( - "pipetting_channel_index must be between 1 and self.num_channels" - ) - # docs say 3600, but empirically 3347 is the max - assert 0 <= z_position <= 3347, "z_position must be between 0 and 3347" - - return await self.send_command( - module="C0", - command="KZ", - pn=f"{pipetting_channel_index:02}", - zj=f"{z_position:04}", - ) - - async def search_for_teach_in_signal_using_pipetting_channel_n_in_x_direction( - self, pipetting_channel_index: int, x_position: int - ): - """Search for Teach in signal using pipetting channel n in X-direction. - - Args: - pipetting_channel_index: Index of pipetting channel. Must be between 1 and self.num_channels. - x_position: x position [0.1mm]. Must be between 0 and 30000. - """ - - assert 1 <= pipetting_channel_index <= self.num_channels, ( - "pipetting_channel_index must be between 1 and self.num_channels" - ) - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - - return await self.send_command( - module="C0", - command="XL", - pn=f"{pipetting_channel_index:02}", - xs=f"{x_position:05}", - ) - - async def spread_pip_channels(self): - """Spread PIP channels""" - - return await self.send_command(module="C0", command="JE") - - @need_iswap_parked - async def move_all_pipetting_channels_to_defined_position( - self, - tip_pattern: bool = True, - x_positions: int = 0, - y_positions: int = 0, - minimum_traverse_height_at_beginning_of_command: int = 3600, - z_endpos: int = 0, - ): - """Move all pipetting channels to defined position - - Args: - tip_pattern: Tip pattern (channels involved). Default True. - x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. - y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. - minimum_traverse_height_at_beginning_of_command: Minimum traverse height at beginning of a - command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be - between 0 and 3600. Default 3600. - z_endpos: Z-Position at end of a command [0.1 mm] (refers to all channels independent of tip - pattern parameter 'tm'). Must be between 0 and 3600. Default 0. - """ - - if self.left_side_panel_installed: - min_x = round(self.PIP_X_MIN_WITH_LEFT_SIDE_PANEL * 10) - if x_positions < min_x: - raise ValueError( - f"PIP channel x={x_positions / 10}mm is below the minimum " - f"{self.PIP_X_MIN_WITH_LEFT_SIDE_PANEL}mm (left side panel is installed)" - ) - assert 0 <= x_positions <= 25000, "x_positions must be between 0 and 25000" - assert 0 <= y_positions <= 6500, "y_positions must be between 0 and 6500" - assert 0 <= minimum_traverse_height_at_beginning_of_command <= 3600, ( - "minimum_traverse_height_at_beginning_of_command must be between 0 and 3600" - ) - assert 0 <= z_endpos <= 3600, "z_endpos must be between 0 and 3600" - - return await self.send_command( - module="C0", - command="JM", - tm=tip_pattern, - xp=x_positions, - yp=y_positions, - th=minimum_traverse_height_at_beginning_of_command, - zp=z_endpos, - ) - - # TODO:(command:JR): teach rack using pipetting channel n - - @need_iswap_parked - async def position_max_free_y_for_n(self, pipetting_channel_index: int): - """Position all pipetting channels so that there is maximum free Y range for channel n - - Args: - pipetting_channel_index: Index of pipetting channel. Must be between 0 and self.num_channels. - """ - - assert 0 <= pipetting_channel_index < self.num_channels, ( - "pipetting_channel_index must be between 1 and self.num_channels" - ) - # convert Python's 0-based indexing to Hamilton firmware's 1-based indexing - pipetting_channel_index = pipetting_channel_index + 1 - - return await self.send_command( - module="C0", - command="JP", - pn=f"{pipetting_channel_index:02}", - ) - - async def move_all_channels_in_z_safety(self): - """Move all pipetting channels in Z-safety position""" - - return await self.send_command(module="C0", command="ZA") - - # -------------- 3.5.7 PIP query -------------- - - async def request_x_pos_channel_n(self, pipetting_channel_index: int = 0) -> float: - """Request X-Position of Pipetting channel n (in mm)""" - - resp = await self.request_left_x_arm_position() - # TODO: check validity for 2 X-arm system - - return round(resp, 1) - - async def request_y_pos_channel_n(self, pipetting_channel_index: int) -> float: - """Request Y-Position of Pipetting channel n - - Args: - pipetting_channel_index: Index of pipetting channel. Must be between 0 and 15. - 0 is the backmost channel. - """ - - assert 0 <= pipetting_channel_index < self.num_channels, ( - "pipetting_channel_index must be between 0 and self.num_channels" - ) - # convert Python's 0-based indexing to Hamilton firmware's 1-based indexing - pipetting_channel_index = pipetting_channel_index + 1 - - y_pos_query = await self.send_command( - module="C0", - command="RB", - fmt="rb####", - pn=f"{pipetting_channel_index:02}", - ) - # Extract y-coordinate and convert to mm - return float(y_pos_query["rb"] / 10) - - async def channels_request_y_positions(self) -> List[float]: - """Request the Y-positions of all pipetting channels in one command. - - Returns: - Y-position (mm) per channel, ordered by channel index (0 = backmost). - """ - resp = await self.send_command(module="C0", command="RY", fmt="ry#### (n)") - return [v / 10 for v in resp["ry"]] - - async def channels_request_stop_disk_z_positions(self) -> List[float]: - """Request the stop-disk Z-position of every channel, excluding any mounted tip. - - Returns: - Stop-disk Z (mm) per channel, ordered by channel index (0 = backmost). - """ - return [await self.request_probe_z_position(i) for i in range(self.num_channels)] - - async def request_z_pos_channel_n(self, pipetting_channel_index: int) -> float: - warnings.warn( - "Deprecated. Use either request_tip_bottom_z_position or request_probe_z_position. " - "Returning request_tip_bottom_z_position for now." - ) - return await self.request_tip_bottom_z_position(channel_idx=pipetting_channel_index) - - async def request_tip_bottom_z_position(self, channel_idx: int) -> float: - """Request Z-Position of the tip bottom of the tip mounted at on channel `channel_idx`. - - Requires a tip to be mounted and will raise if no tip is mounted. - - To get the z-position of the probe (irrespective of tip), use `request_probe_z_position`. - - Args: - channel_idx: Index of pipetting channel. Must be between 0 and 15. 0 is the backmost channel. - """ - - if not (await self.request_tip_presence())[channel_idx]: - raise RuntimeError(f"No tip mounted on channel {channel_idx}") - - if not 0 <= channel_idx <= self.num_channels - 1: - raise ValueError("channel_idx must be in [0, num_channels - 1]") - - z_pos_query = await self.send_command( - module="C0", - command="RD", - fmt="rd####", - # convert Python's 0-based indexing to Hamilton firmware's 1-based indexing - pn=f"{channel_idx + 1:02}", - ) - # Extract z-coordinate and convert to mm - return float(z_pos_query["rd"] / 10) - - async def request_tip_presence(self) -> List[Optional[bool]]: - """Measure tip presence on all single channels using their sleeve sensors. - - Returns: - A list of length `num_channels` where each element is `True` if a tip is mounted, - `False` if not, or `None` if unknown. - """ - resp = await self.send_command(module="C0", command="RT", fmt="rt# (n)") - return [bool(v) for v in cast(List[int], resp.get("rt"))] - - async def channels_sense_tip_presence(self) -> List[int]: - """Deprecated - use `request_tip_presence` instead.""" - warnings.warn( - "`channels_sense_tip_presence` is deprecated and will be " - "removed in a future version. Use `request_tip_presence` instead.", - DeprecationWarning, - stacklevel=2, - ) - return [int(v) for v in await self.request_tip_presence() if v is not None] - - async def request_pip_height_last_lld(self) -> List[float]: - """ - Return the absolute liquid heights measured during the most recent - liquid-level detection (LLD) event for all channels. - - This value is maintained internally by the STAR/STARlet firmware and is - updated **whenever a liquid level is detected**, regardless of whether the - detection method used was: - - capacitive LLD (cLLD == 'STAR.LLDMode(1)'), or - - pressure-based LLD (pLLD == 'STAR.LLDMode(2)'). - - Heights are returned in millimeters, one value per channel, ordered by - channel index. - - Returns: - Absolute liquid heights (mm) from the last LLD event for each channel. - - Raises: - AssertionError: If the instrument response does not contain a valid ``"lh"`` list. - """ - resp = await self.send_command(module="C0", command="RL", fmt="lh#### (n)") - - liquid_levels = resp.get("lh") - - assert len(liquid_levels) == self.num_channels, ( - f"Expected {self.num_channels} liquid level values, got {len(liquid_levels)} instead" - ) - - current_absolute_liquid_heights = [float(lld_channel / 10) for lld_channel in liquid_levels] - - return current_absolute_liquid_heights - - async def request_tadm_status(self): - """Request PIP height of last LLD - - Returns: - TADM channel status 0 = off, 1 = on - """ - - return await self.send_command(module="C0", command="QS", fmt="qs# (n)") - - # TODO:(command:FS) Request PIP channel dispense on fly status - # TODO:(command:VE) Request PIP channel 2nd section aspiration data - - # -------------- 3.6 XL channel commands -------------- - - # TODO: all XL channel commands - - # -------------- 3.6.1 Initialization XL -------------- - - # TODO:(command:LI) - - # -------------- 3.6.2 Tip handling commands using XL -------------- - - # TODO:(command:LP) - # TODO:(command:LR) - - # -------------- 3.6.3 Liquid handling commands using XL -------------- - - # TODO:(command:LA) - # TODO:(command:LD) - # TODO:(command:LB) - # TODO:(command:LC) - - # -------------- 3.6.4 Wash commands using XL channel -------------- - - # TODO:(command:LE) - # TODO:(command:LF) - - # -------------- 3.6.5 XL CoRe gripper commands -------------- - - # TODO:(command:LT) - # TODO:(command:LS) - # TODO:(command:LU) - # TODO:(command:LV) - # TODO:(command:LM) - # TODO:(command:LO) - # TODO:(command:LG) - - # -------------- 3.6.6 Adjustment & movement commands CP -------------- - - # TODO:(command:LY) - # TODO:(command:LZ) - # TODO:(command:LH) - # TODO:(command:LJ) - # TODO:(command:XM) - # TODO:(command:LL) - # TODO:(command:LQ) - # TODO:(command:LK) - # TODO:(command:UE) - - # -------------- 3.6.7 XL channel query -------------- - - # TODO:(command:UY) - # TODO:(command:UB) - # TODO:(command:UZ) - # TODO:(command:UD) - # TODO:(command:UT) - # TODO:(command:UL) - # TODO:(command:US) - # TODO:(command:UF) - - # -------------- 3.7 Tube gripper commands -------------- - - # TODO: all tube gripper commands - - # -------------- 3.7.1 Movements -------------- - - # TODO:(command:FC) - # TODO:(command:FD) - # TODO:(command:FO) - # TODO:(command:FT) - # TODO:(command:FU) - # TODO:(command:FJ) - # TODO:(command:FM) - # TODO:(command:FW) - - # -------------- 3.7.2 Tube gripper query -------------- - - # TODO:(command:FQ) - # TODO:(command:FN) - - # -------------- 3.8 Imaging channel commands -------------- - - # TODO: all imaging commands - - # -------------- 3.8.1 Movements -------------- - - # TODO:(command:IC) - # TODO:(command:ID) - # TODO:(command:IM) - # TODO:(command:IJ) - - # -------------- 3.8.2 Imaging channel query -------------- - - # TODO:(command:IN) - - # -------------- 3.9 Robotic channel commands -------------- - - # -------------- 3.9.1 Initialization -------------- - - # TODO:(command:OI) - - # -------------- 3.9.2 Cap handling commands -------------- - - # TODO:(command:OP) - # TODO:(command:OQ) - - # -------------- 3.9.3 Adjustment & movement commands -------------- - - # TODO:(command:OY) - # TODO:(command:OZ) - # TODO:(command:OH) - # TODO:(command:OJ) - # TODO:(command:OX) - # TODO:(command:OM) - # TODO:(command:OF) - # TODO:(command:OG) - - # -------------- 3.9.4 Robotic channel query -------------- - - # TODO:(command:OA) - # TODO:(command:OB) - # TODO:(command:OC) - # TODO:(command:OD) - # TODO:(command:OT) - - # -------------- 3.10 96-Head commands -------------- - - async def head96_request_firmware_version(self) -> datetime.date: - """Request 96 Head firmware version (MEM-READ command).""" - resp: str = await self.send_command(module="H0", command="RF") - return self._parse_firmware_version_datetime(resp) - - async def _head96_request_configuration(self) -> List[str]: - """Request the 96-head configuration (raw) using the QU command. - - The instrument returns ten blank-separated decimal values. This method returns - them as a list of strings, undecoded; the list indices currently understood are: - - - index 0: clot_monitoring_with_clld - - index 1: stop_disc_type (codes: 0=core_i, 1=core_ii) - - index 2: instrument_type (codes: 0=legacy, 1=FM-STAR) - - indices 3..9: reserve - - Index 1 (stop_disc_type) is populated on firmware at least back to 2021 (a 2021-10-22 - build reports core_ii). Whether index 2 (instrument_type) is reliably populated on - every build, or on some falls back to reserve (read back as 0 -> legacy), is unverified; - confirm on an FM-STAR head before relying on it to unlock the FM-STAR z-range extension. - - Returns: - Raw positional tokens extracted from the QU response (the portion after the last ``"au"`` marker). - """ - resp: str = await self.send_command(module="H0", command="QU") - return resp.split("au")[-1].split() - - async def head96_request_type(self) -> Head96Information.HeadType: - """Send QG and return the 96-head type as a human-readable string.""" - type_map: Dict[int, Head96Information.HeadType] = { - 0: "Low volume head", - 1: "High volume head", - 2: "96 head II", - 3: "96 head TADM", - } - resp = await self.send_command(module="H0", command="QG", fmt="qg#") - return type_map.get(resp["qg"], "unknown") - - def _head96_resolve_z_range(self, instrument_type: str) -> Tuple[float, float]: - """Z-drive position window (mm); FM-STAR extends it (za/zb/zh all share this range).""" - min_inc, max_inc = (24200, 76200) if instrument_type == "FM-STAR" else (36100, 68500) - return ( - self._head96_z_drive_increment_to_mm(min_inc), - self._head96_z_drive_increment_to_mm(max_inc), - ) - - # -------------- 3.10.1 Initialization -------------- - - async def initialize_core_96_head( - self, trash96: Trash, z_position_at_the_command_end: float = 245.0 - ): - """Initialize CoRe 96 Head - - Args: - trash96: Trash object where tips should be disposed. The 96 head will be positioned in the - center of the trash. - z_position_at_the_command_end: Z position at the end of the command [mm]. - """ - - # The firmware command expects location of tip A1 of the head. - loc = self._position_96_head_in_resource(trash96) - self._check_96_position_legal(loc, skip_z=True) - - return await self.send_command( - module="C0", - command="EI", - read_timeout=60, - xs=f"{abs(round(loc.x * 10)):05}", - xd=0 if loc.x >= 0 else 1, - yh=f"{abs(round(loc.y * 10)):04}", - za=f"{round(loc.z * 10):04}", - ze=f"{round(z_position_at_the_command_end * 10):04}", - ) - - async def request_core_96_head_initialization_status(self) -> bool: - # not available in the C0 docs, so get from module H0 itself instead - response = await self.send_command(module="H0", command="QW", fmt="qw#") - return bool(response.get("qw", 0) == 1) # type? - - async def head96_dispensing_drive_and_squeezer_driver_initialize( - self, - squeezer_speed: float = 15.0, # mm/sec - squeezer_acceleration: float = 62.0, # mm/sec**2, - squeezer_current_limit: int = 15, - dispensing_drive_current_limit: int = 7, - ): - """Initialize 96-head's dispensing drive AND squeezer drive - - This command... - - drops any tips that might be on the channel (in place, without moving to trash!) - - moves the dispense drive to volume position 215.92 uL - (after tip pickup it will be at 218.19 uL) - - Args: - squeezer_speed: Speed of the movement (mm/sec). Default is 15.0 mm/sec. - squeezer_acceleration: Acceleration of the movement (mm/sec**2). Default is 62.0 mm/sec**2. - squeezer_current_limit: Current limit for the squeezer drive (1-15). Default is 15. - dispensing_drive_current_limit: Current limit for the dispensing drive (1-15). Default is 7. - """ - - if not (0.01 <= squeezer_speed <= 16.69): - raise ValueError( - f"96-head squeezer drive speed must be between 0.01 and 16.69 mm/sec, is {squeezer_speed}" - ) - if not (1.04 <= squeezer_acceleration <= 62.6): - raise ValueError( - "96-head squeezer drive acceleration must be between 1.04 and " - f"62.6 mm/sec**2, is {squeezer_acceleration}" - ) - if not (1 <= squeezer_current_limit <= 15): - raise ValueError( - "96-head squeezer drive current limit must be between 1 and 15, " - f"is {squeezer_current_limit}" - ) - if not (1 <= dispensing_drive_current_limit <= 15): - raise ValueError( - "96-head dispensing drive current limit must be between 1 and 15, " - f"is {dispensing_drive_current_limit}" - ) - - squeezer_speed_increment = self._head96_squeezer_drive_mm_to_increment(squeezer_speed) - squeezer_acceleration_increment = self._head96_squeezer_drive_mm_to_increment( - squeezer_acceleration - ) - - resp = await self.send_command( - module="H0", - command="PI", - sv=f"{squeezer_speed_increment:05}", - sr=f"{squeezer_acceleration_increment:06}", - sw=f"{squeezer_current_limit:02}", - dw=f"{dispensing_drive_current_limit:02}", - ) - - return resp - - # -------------- 3.10.2 96-Head Movements -------------- - - # Conversion factors for 96-Head: owned by Head96Information now (encoder resolutions); aliased - # here for backwards compatibility. - _head96_z_drive_mm_per_increment = Head96Information.z_drive_mm_per_increment - _head96_y_drive_mm_per_increment = Head96Information.y_drive_mm_per_increment - _head96_dispensing_drive_mm_per_increment = Head96Information.dispensing_drive_mm_per_increment - _head96_dispensing_drive_uL_per_increment = Head96Information.dispensing_drive_uL_per_increment - _head96_squeezer_drive_mm_per_increment = Head96Information.squeezer_drive_mm_per_increment - - # Y-axis conversions - - def _head96_y_drive_mm_to_increment(self, value_mm: float) -> int: - """Convert mm to Y-axis hardware increments for 96-head.""" - return round(value_mm / self._head96_y_drive_mm_per_increment) - - def _head96_y_drive_increment_to_mm(self, value_increments: int) -> float: - """Convert Y-axis hardware increments to mm for 96-head.""" - return round(value_increments * self._head96_y_drive_mm_per_increment, 2) - - # Z-axis conversions - - def _head96_z_drive_mm_to_increment(self, value_mm: float) -> int: - """Convert mm to Z-axis hardware increments for 96-head.""" - return round(value_mm / self._head96_z_drive_mm_per_increment) - - def _head96_z_drive_increment_to_mm(self, value_increments: int) -> float: - """Convert Z-axis hardware increments to mm for 96-head.""" - return round(value_increments * self._head96_z_drive_mm_per_increment, 2) - - # Dispensing drive conversions (mm and uL) - - def _head96_dispensing_drive_mm_to_increment(self, value_mm: float) -> int: - """Convert mm to dispensing drive hardware increments for 96-head.""" - return round(value_mm / self._head96_dispensing_drive_mm_per_increment) - - def _head96_dispensing_drive_increment_to_mm(self, value_increments: int) -> float: - """Convert dispensing drive hardware increments to mm for 96-head.""" - return round(value_increments * self._head96_dispensing_drive_mm_per_increment, 2) - - def _head96_dispensing_drive_uL_to_increment(self, value_uL: float) -> int: - """Convert uL to dispensing drive hardware increments for 96-head.""" - return round(value_uL / self._head96_dispensing_drive_uL_per_increment) - - def _head96_dispensing_drive_increment_to_uL(self, value_increments: int) -> float: - """Convert dispensing drive hardware increments to uL for 96-head.""" - return round(value_increments * self._head96_dispensing_drive_uL_per_increment, 2) - - def _head96_dispensing_drive_mm_to_uL(self, value_mm: float) -> float: - """Convert dispensing drive mm to uL for 96-head.""" - # Convert mm -> increment -> uL - increment = self._head96_dispensing_drive_mm_to_increment(value_mm) - return self._head96_dispensing_drive_increment_to_uL(increment) - - def _head96_dispensing_drive_uL_to_mm(self, value_uL: float) -> float: - """Convert dispensing drive uL to mm for 96-head.""" - # Convert uL -> increment -> mm - increment = self._head96_dispensing_drive_uL_to_increment(value_uL) - return self._head96_dispensing_drive_increment_to_mm(increment) - - # Squeezer drive conversions - - def _head96_squeezer_drive_mm_to_increment(self, value_mm: float) -> int: - """Convert mm to squeezer drive hardware increments for 96-head.""" - return round(value_mm / self._head96_squeezer_drive_mm_per_increment) - - def _head96_squeezer_drive_increment_to_mm(self, value_increments: int) -> float: - """Convert squeezer drive hardware increments to mm for 96-head.""" - return round(value_increments * self._head96_squeezer_drive_mm_per_increment, 2) - - # Default drive speed/acceleration a move uses when the caller passes none. Each getter returns the - # user override if one was set, otherwise the firmware-resolved Head96Information factory default; - # the setter range-checks against Head96Information so a user can set their own default safely. A - # move does not persist these to the drive - it snapshots the live register and restores it after. - - @property - def head96_y_drive_speed_default(self) -> float: - """Default 96-head Y-drive speed (mm/s) used when a Y move is called without an explicit speed. - - Seeded from the machine at setup; assign your own and it is validated against `y_speed_range` - before taking effect. A move does not persist this to the drive: it snapshots the live register - and restores it afterwards. - """ - assert self._head96_y_drive_speed_default is not None, ( - "96-head defaults not loaded; run setup()" - ) - return self._head96_y_drive_speed_default - - @head96_y_drive_speed_default.setter - def head96_y_drive_speed_default(self, value: float): - assert self._head96_information is not None, "96-head information not loaded; run setup()" - lo, hi = self._head96_information.y_speed_range - if not lo <= value <= hi: - raise ValueError(f"speed must be between {lo} and {hi} mm/sec") - self._head96_y_drive_speed_default = value - - @property - def head96_y_drive_acceleration_default(self) -> float: - """Default 96-head Y-drive acceleration (mm/s2) used when a Y move is called without one. - - Seeded from the machine at setup; assign your own and it is validated against - `y_acceleration_range` before taking effect. A move does not persist this to the drive: it - snapshots the live register and restores it afterwards. - """ - assert self._head96_y_drive_acceleration_default is not None, ( - "96-head defaults not loaded; run setup()" - ) - return self._head96_y_drive_acceleration_default - - @head96_y_drive_acceleration_default.setter - def head96_y_drive_acceleration_default(self, value: float): - assert self._head96_information is not None, "96-head information not loaded; run setup()" - lo, hi = self._head96_information.y_acceleration_range - if not lo <= value <= hi: - raise ValueError(f"acceleration must be between {lo} and {hi} mm/sec**2") - self._head96_y_drive_acceleration_default = value - - @property - def head96_z_drive_speed_default(self) -> float: - """Default 96-head Z-drive speed (mm/s) used when a Z move is called without an explicit speed. - - Seeded from the machine at setup; assign your own and it is validated against `z_speed_range` - before taking effect. A move does not persist this to the drive: it snapshots the live register - and restores it afterwards. - """ - assert self._head96_z_drive_speed_default is not None, ( - "96-head defaults not loaded; run setup()" - ) - return self._head96_z_drive_speed_default - - @head96_z_drive_speed_default.setter - def head96_z_drive_speed_default(self, value: float): - assert self._head96_information is not None, "96-head information not loaded; run setup()" - lo, hi = self._head96_information.z_speed_range - if not lo <= value <= hi: - raise ValueError(f"speed must be between {lo} and {hi} mm/sec") - self._head96_z_drive_speed_default = value - - @property - def head96_z_drive_acceleration_default(self) -> float: - """Default 96-head Z-drive acceleration (mm/s2) used when a Z move is called without one. - - Seeded from the machine at setup; assign your own and it is validated against - `z_acceleration_range` before taking effect. A move does not persist this to the drive: it - snapshots the live register and restores it afterwards. - """ - assert self._head96_z_drive_acceleration_default is not None, ( - "96-head defaults not loaded; run setup()" - ) - return self._head96_z_drive_acceleration_default - - @head96_z_drive_acceleration_default.setter - def head96_z_drive_acceleration_default(self, value: float): - assert self._head96_information is not None, "96-head information not loaded; run setup()" - lo, hi = self._head96_information.z_acceleration_range - if not lo <= value <= hi: - raise ValueError(f"acceleration must be between {lo} and {hi} mm/sec**2") - self._head96_z_drive_acceleration_default = value - - async def head96_request_y_speed(self) -> float: - """Request the persistent 96-head Y-drive speed (mm/s), via H0 RA (read parameter yv). - - The read counterpart of `_head96_set_y_speed`. - """ - resp = await self.send_command(module="H0", command="RA", ra="yv", fmt="yv#####") - return self._head96_y_drive_increment_to_mm(resp["yv"]) - - async def head96_request_y_acceleration(self) -> float: - """Request the persistent 96-head Y-drive acceleration (mm/s^2), via H0 RA (read parameter yr). - - The read counterpart of `_head96_set_y_acceleration`. - """ - resp = await self.send_command(module="H0", command="RA", ra="yr", fmt="yr#####") - return self._head96_y_drive_increment_to_mm(resp["yr"]) - - async def head96_request_z_speed(self) -> float: - """Request the persistent 96-head Z-drive speed (mm/s), via H0 RA (read parameter zv). - - The read counterpart of `_head96_set_z_speed`. - """ - resp = await self.send_command(module="H0", command="RA", ra="zv", fmt="zv#####") - return self._head96_z_drive_increment_to_mm(resp["zv"]) - - async def head96_request_z_acceleration(self) -> float: - """Request the persistent 96-head Z-drive acceleration (mm/s^2), via H0 RA (read parameter zr). - - The read counterpart of `_head96_set_z_acceleration`; undoes the firmware-version acceleration - scaling that the setter (and `head96_move_stop_disk_z`) applies. - """ - assert self._head96_information is not None, ( - "requires 96-head firmware version information for safe operation" - ) - resp = await self.send_command(module="H0", command="RA", ra="zr", fmt="zr######") - acceleration_multiplier = 1 if self._head96_information.fw_version.year >= 2010 else 0.001 - return self._head96_z_drive_increment_to_mm(round(resp["zr"] / acceleration_multiplier)) - - @_requires_head96 - async def _head96_set_y_speed(self, speed: float): - """Set the persistent 96-head Y-drive speed (mm/s) on the device without moving. - - On-device write for troubleshooting or specialized use, not day-to-day - set - `head96_y_drive_speed_default` for routine control. Subsequent Y moves that don't pass their own - speed - including the C0-level 96-head commands - inherit this until it is changed or the drive - re-initialises. - """ - assert self._head96_information is not None, ( - "requires 96-head firmware version information for safe operation" - ) - y_speed_min, y_speed_max = self._head96_information.y_speed_range - assert y_speed_min <= speed <= y_speed_max, ( - f"speed must be between {y_speed_min} and {y_speed_max} mm/sec" - ) - return await self.send_command( - module="H0", command="AA", yv=f"{self._head96_y_drive_mm_to_increment(speed):05}" - ) - - @_requires_head96 - async def _head96_set_y_acceleration(self, acceleration: float): - """Set the persistent 96-head Y-drive acceleration (mm/s^2) on the device without moving. - - On-device write for troubleshooting or specialized use, not day-to-day - set - `head96_y_drive_acceleration_default` for routine control. - """ - assert self._head96_information is not None, ( - "requires 96-head firmware version information for safe operation" - ) - y_accel_min, y_accel_max = self._head96_information.y_acceleration_range - assert y_accel_min <= acceleration <= y_accel_max, ( - f"acceleration must be between {y_accel_min} and {y_accel_max} mm/sec**2" - ) - return await self.send_command( - module="H0", command="AA", yr=f"{self._head96_y_drive_mm_to_increment(acceleration):05}" - ) - - @_requires_head96 - async def _head96_set_z_speed(self, speed: float): - """Set the persistent 96-head Z-drive speed (mm/s) on the device without moving. - - On-device write for troubleshooting or specialized use, not day-to-day - set - `head96_z_drive_speed_default` for routine control. - """ - assert self._head96_information is not None, ( - "requires 96-head firmware version information for safe operation" - ) - z_speed_min, z_speed_max = self._head96_information.z_speed_range - assert z_speed_min <= speed <= z_speed_max, ( - f"speed must be between {z_speed_min} and {z_speed_max} mm/sec" - ) - return await self.send_command( - module="H0", command="AA", zv=f"{self._head96_z_drive_mm_to_increment(speed):05}" - ) - - @_requires_head96 - async def _head96_set_z_acceleration(self, acceleration: float): - """Set the persistent 96-head Z-drive acceleration (mm/s^2) on the device without moving. - - On-device write for troubleshooting or specialized use, not day-to-day - set - `head96_z_drive_acceleration_default` for routine control. Applies the same firmware-version - acceleration scaling as `head96_move_stop_disk_z` (pre-2010 x0.001). - """ - assert self._head96_information is not None, ( - "requires 96-head firmware version information for safe operation" - ) - z_accel_min, z_accel_max = self._head96_information.z_acceleration_range - assert z_accel_min <= acceleration <= z_accel_max, ( - f"acceleration must be between {z_accel_min} and {z_accel_max} mm/sec**2" - ) - acceleration_multiplier = 1 if self._head96_information.fw_version.year >= 2010 else 0.001 - acceleration_increment = round( - self._head96_z_drive_mm_to_increment(acceleration) * acceleration_multiplier - ) - return await self.send_command(module="H0", command="AA", zr=f"{acceleration_increment:06}") - - # Movement commands - - async def move_core_96_to_safe_position(self): - """Move CoRe 96 Head to Z safe position.""" - warnings.warn( - "move_core_96_to_safe_position is deprecated. Use head96_move_to_z_safety instead. " - "This method will be removed in 2026-04", # TODO: remove 2026-04 - DeprecationWarning, - stacklevel=2, - ) - return await self.head96_move_to_z_safety() - - @_requires_head96 - async def head96_move_to_z_safety( - self, speed: Optional[float] = None, acceleration: Optional[float] = None - ): - """Move the 96-head up to its Z-safety height: the top of the firmware/variant Z window - (the max of Head96Information.z_range), not a hardcoded value. speed and acceleration forward - to the underlying stop-disk move (None uses the head defaults).""" - assert self._head96_information is not None, ( - "requires 96-head firmware version information for safe operation" - ) - z_max = self._head96_information.z_range[1] - return await self.head96_move_stop_disk_z( - z_max, speed=speed, acceleration=acceleration, retract_on_crash=False - ) - - @_requires_head96 - async def head96_park( - self, - ): - """Park the 96-head. - - Uses firmware default speeds and accelerations. - """ - - return await self.send_command(module="H0", command="MO") - - @_requires_head96 - async def head96_move_x( - self, - x: float, - acceleration_level: int = 3, - current_protection_limiter: int = 7, - ): - """Move the 96-head to a target channel-A1 X coordinate via the direct X-arm drive. - - Drives the X-arm carriage to ``x + head96_information.x_offset`` so channel A1 lands at - ``x``: A1 sits left of (below) the carriage center, so deck-A1 = carriage - offset and the - carriage target is therefore ``x + offset`` (inverse of the iSWAP rotation-drive derivation, - ``iswap_rotation_drive_request_x``). - Unlike the legacy EM coordinate move (all axes together, no per-axis motion control), this - is the single-axis X-arm drive command and exposes acceleration and current control, like - ``head96_move_y`` / ``head96_move_stop_disk_z``. - - Args: - x: Target A1 X coordinate in mm. Valid range [x_min, 974.0]; x_min is 0.0 with a left - side panel installed, else -271.0. - acceleration_level: X-arm acceleration index (1-5). Default 3. - current_protection_limiter: X-arm motor current limit (0-7). Default 7. - - Raises: - RuntimeError: If the 96-head is not installed. - ValueError: If the target A1 X is outside the legal 96-head X range. - """ - x_min = self.HEAD96_X_MIN_WITH_LEFT_SIDE_PANEL if self.left_side_panel_installed else -271.0 - if not (x_min <= x <= 974.0): - raise ValueError(f"96-head A1 x={x} out of range [{x_min}, 974.0]") - assert self._head96_information is not None, "96-head information not loaded; run setup()" - carriage_x = x + self._head96_information.x_offset - return await self.experimental_x_arm_move( - carriage_x, - acceleration_level=acceleration_level, - current_protection_limiter=current_protection_limiter, - ) - - @_requires_head96 - async def head96_move_y( - self, - y: float, - speed: Optional[float] = None, - acceleration: Optional[float] = None, - current_protection_limiter: int = 15, - ): - """Move the 96-head to a specified Y-axis coordinate. - - A YA move writes its speed/acceleration into the drive's volatile register, where later moves - would inherit them. This command snapshots whatever speed/acceleration are on the robot before - it runs and restores them afterwards, so it leaves the persistent machine state untouched (the - restore is skipped when the move's value already matches what was there). - - Args: - y: Target Y coordinate in mm. Valid range: [93.75, 562.5] - speed: Movement speed in mm/sec; None uses `head96_y_drive_speed_default`. The valid range is - firmware-dependent (resolved into `Head96Information.y_speed_range`): [0.78125, 390.625] - pre-2021, [0.78125, 625.0] on 2021+ firmware. - acceleration: Movement acceleration in mm/sec**2; None uses - `head96_y_drive_acceleration_default`. The valid range is firmware-dependent (resolved into - `Head96Information.y_acceleration_range`): [78.125, 500.0] pre-2010, [78.125, 781.25] on - 2013+ firmware. - current_protection_limiter: Motor current limit (0-15, hardware units). Default: 15 - - Returns: - Response from the hardware command. - - Raises: - RuntimeError: If 96-head is not installed. - AssertionError: If firmware info missing or parameters out of range. - - Note: - The maxima rose across firmware generations, and the speed and acceleration cutoffs differ: - - - Speed: 390.625 mm/sec pre-2021 (25,000 increments), 625.0 mm/sec on 2021+ (40,000 - increments). The exact firmware version introducing this change is undocumented. - - Acceleration: 500.0 mm/sec**2 pre-2010 (32,000 increments), 781.25 mm/sec**2 on 2013+ - (50,000 increments). - """ - assert self._head96_information is not None, ( - "requires 96-head firmware version information for safe operation" - ) - - if speed is None: - speed = self.head96_y_drive_speed_default - if acceleration is None: - acceleration = self.head96_y_drive_acceleration_default - - fw_version = self._head96_information.fw_version - y_min, y_max = self._head96_information.y_range - y_speed_min, y_speed_max = self._head96_information.y_speed_range - y_accel_min, y_accel_max = self._head96_information.y_acceleration_range - - # Validate parameters before hardware communication - assert y_min <= y <= y_max, f"y must be between {y_min} and {y_max} mm" - assert y_speed_min <= speed <= y_speed_max, ( - f"speed must be between {y_speed_min} and {y_speed_max} mm/sec for firmware version {fw_version}. " - f"Your firmware version: {fw_version}. " - "If this limit seems incorrect, please test cautiously with an empty deck and report " - "accurate limits + firmware to PyLabRobot: https://github.com/PyLabRobot/pylabrobot/issues" - ) - assert y_accel_min <= acceleration <= y_accel_max, ( - f"acceleration must be between {y_accel_min} and {y_accel_max} mm/sec**2" - ) - assert isinstance(current_protection_limiter, int) and ( - 0 <= current_protection_limiter <= 15 - ), "current_protection_limiter must be an integer between 0 and 15" - - # Convert mm-based parameters to hardware increments using conversion methods - y_increment = self._head96_y_drive_mm_to_increment(y) - speed_increment = self._head96_y_drive_mm_to_increment(speed) - acceleration_increment = self._head96_y_drive_mm_to_increment(acceleration) - - # Snapshot what is on the robot now (read from the device, not a tracked default, so an external - # AA edit is preserved) so the move can restore it afterwards and leave the register untouched. - prev_speed = await self.head96_request_y_speed() - prev_acceleration = await self.head96_request_y_acceleration() - prev_speed_increment = self._head96_y_drive_mm_to_increment(prev_speed) - prev_acceleration_increment = self._head96_y_drive_mm_to_increment(prev_acceleration) - - try: - return await self.send_command( - module="H0", - command="YA", - ya=f"{y_increment:05}", - yv=f"{speed_increment:05}", - yr=f"{acceleration_increment:05}", - yw=f"{current_protection_limiter:02}", - ) - finally: - # Restore the pre-command register values, skipping the AA write where the move's value - # already matched what was there (compared in increments, the unit actually stored). - if speed_increment != prev_speed_increment: - await self._head96_set_y_speed(prev_speed) - if acceleration_increment != prev_acceleration_increment: - await self._head96_set_y_acceleration(prev_acceleration) - - @_requires_head96 - async def head96_move_z( - self, - z: float, - speed: Optional[float] = None, - acceleration: Optional[float] = None, - current_protection_limiter: int = 15, - ): - """Move the 96-head Z drive (stop disk) to an absolute Z position in mm. - - .. deprecated:: - Use `head96_move_stop_disk_z` for moves without a tip attached (stop disk) or - `head96_move_tool_z` when a tip is attached (tip end). - """ - warnings.warn( - "head96_move_z is deprecated and will be removed in v1. Use head96_move_stop_disk_z() for " - "moves without a tip attached or head96_move_tool_z() when a tip is attached.", - DeprecationWarning, - stacklevel=2, - ) - return await self.head96_move_stop_disk_z( - z, - speed=speed, - acceleration=acceleration, - current_protection_limiter=current_protection_limiter, - ) - - @_requires_head96 - async def head96_move_stop_disk_z( - self, - z: float, - speed: Optional[float] = None, - acceleration: Optional[float] = None, - current_protection_limiter: int = 15, - retract_on_crash: bool = True, - ): - """Move the 96-head z-drive (stop disk) to an absolute Z position in mm. - - Stop-disk reference, mirroring the single-channel `move_channel_stop_disk_z`: use this for moves - without a tip; for the tip end with a tip on, use `head96_move_tool_z`. - - A ZA move writes its speed/acceleration into the drive's volatile register, where later - moves (and C0-level commands) would inherit them. This command snapshots whatever - speed/acceleration are on the robot before it runs and restores them afterwards, so it - leaves the persistent machine state untouched (the restore is skipped when the move's value - already matches what was there). On any firmware error during the move (e.g. the head crashing - into something) the head retracts to Z-safety before the error is re-raised. - - Args: - z: Target stop-disk Z in mm. Valid range: Head96Information.z_range (180.5-342.5 mm; FM-STAR - extends it). - speed: Movement speed in mm/sec, [0.25, 100.0]; None uses `head96_z_drive_speed_default` - (seeded to 85 mm/s; constant for the Z drive, not version-resolved like the Y-drive default). - acceleration: Movement acceleration in mm/sec^2, [25.0, 500.0]; None uses - `head96_z_drive_acceleration_default` (seeded to 400 mm/s^2; likewise constant for the Z drive). - current_protection_limiter: Motor current limit (0-15, hardware units). Default: 15 - retract_on_crash: If True (default), retract to Z-safety on any firmware error (e.g. a crash) - before re-raising. head96_move_to_z_safety passes False so its own retract cannot recurse. - - Returns: - Response from the hardware command. - - Raises: - RuntimeError: If 96-head is not installed. - AssertionError: If firmware info missing or parameters out of range. - - Note: - Firmware versions from 2021+ use 1:1 acceleration scaling, while pre-2021 versions use 100x - scaling. Both maintain a 100,000 increment upper limit. - """ - assert self._head96_information is not None, ( - "requires 96-head firmware version information for safe operation" - ) - if speed is None: - speed = self.head96_z_drive_speed_default - if acceleration is None: - acceleration = self.head96_z_drive_acceleration_default - - fw_version = self._head96_information.fw_version - - # Validate parameters before hardware communication. The Z window is firmware/variant-adaptive - # (FM-STAR extends it), so read it from Head96Information rather than hardcoding the legacy range. - z_min, z_max = self._head96_information.z_range - z_speed_min, z_speed_max = self._head96_information.z_speed_range - z_accel_min, z_accel_max = self._head96_information.z_acceleration_range - assert z_min <= z <= z_max, f"z must be between {z_min} and {z_max} mm" - assert z_speed_min <= speed <= z_speed_max, ( - f"speed must be between {z_speed_min} and {z_speed_max} mm/sec" - ) - assert z_accel_min <= acceleration <= z_accel_max, ( - f"acceleration must be between {z_accel_min} and {z_accel_max} mm/sec**2" - ) - assert isinstance(current_protection_limiter, int) and ( - 0 <= current_protection_limiter <= 15 - ), "current_protection_limiter must be an integer between 0 and 15" - - # Determine acceleration scaling based on firmware version - # Pre-2010 firmware: acceleration parameter is multiplied by 1000 - # 2010+ firmware: acceleration parameter is 1:1 with increment/sec**2 - # TODO: identify exact firmware version that introduced this change - acceleration_multiplier = 1 if fw_version.year >= 2010 else 0.001 - - # Convert mm-based parameters to hardware increments - z_increment = self._head96_z_drive_mm_to_increment(z) - speed_increment = self._head96_z_drive_mm_to_increment(speed) - acceleration_increment = round( - self._head96_z_drive_mm_to_increment(acceleration) * acceleration_multiplier - ) - - # Snapshot what is on the robot now (read from the device, not a tracked default, so an external - # AA edit is preserved) so the move can restore it afterwards and leave the register untouched. - prev_speed = await self.head96_request_z_speed() - prev_acceleration = await self.head96_request_z_acceleration() - prev_speed_increment = self._head96_z_drive_mm_to_increment(prev_speed) - prev_acceleration_increment = round( - self._head96_z_drive_mm_to_increment(prev_acceleration) * acceleration_multiplier - ) - - try: - return await self.send_command( - module="H0", - command="ZA", - za=f"{z_increment:05}", - zv=f"{speed_increment:05}", - zr=f"{acceleration_increment:06}", - zw=f"{current_protection_limiter:02}", - ) - except STARFirmwareError: - # Any firmware error here (most importantly a Z-drive crash) can leave the head against an - # obstacle, so retract to Z-safety before re-raising. head96_move_to_z_safety calls back into - # this method with retract_on_crash=False, so the retract cannot recurse into recovery. - if retract_on_crash: - try: - # retract slowly (quarter of max speed) - the head may be in liquid after a crash - await self.head96_move_to_z_safety(speed=self._head96_information.z_speed_range[1] * 0.25) - except STARFirmwareError: - pass # retract failed too; surface the original error below - raise - finally: - # Restore the pre-command register values, skipping the AA write where the move's value - # already matched what was there (compared in increments, the unit actually stored). - if speed_increment != prev_speed_increment: - await self._head96_set_z_speed(prev_speed) - if acceleration_increment != prev_acceleration_increment: - await self._head96_set_z_acceleration(prev_acceleration) - - async def head96_request_tip_length(self) -> float: - """Measures the length of the tips on the 96-head; the head's counterpart of - `request_tip_len_on_channel`. Raises if no tips are present. - - Returns: - The measured tip length in millimeters. - - Raises: - RuntimeError: If the 96-head holds no tips. - """ - if not await self.head96_request_tip_presence(): - raise RuntimeError("96-head has no tips (firmware reports none)") - stop_disk = await self.head96_request_stop_disk_z() - tip_bottom = (await self.head96_request_position()).z - return round(stop_disk - (tip_bottom - STARBackend.DEFAULT_TIP_FITTING_DEPTH), 1) - - @_requires_head96 - async def head96_move_tool_z(self, z: float, speed: Optional[float] = None): - """Move the 96-head tip bottom to an absolute Z position in mm. - - Requires tips. `head96_move_stop_disk_z` references the stop disk, so this reads the tip overhang - (stop disk minus tip bottom, measured move-free from `head96_request_stop_disk_z` vs - `head96_request_position`) and offsets the move so the tip end lands on `z`. Mirrors the - single-channel `move_channel_tool_z`: a tip-presence guard plus a tip-space range check. - - Args: - z: Target tip-bottom Z in mm. - speed: Movement speed in mm/sec; None uses the head default. - - Raises: - ValueError: if the 96-head holds no tips, or `z` is outside the reachable tip-bottom window. - """ - assert self._head96_information is not None, ( - "requires 96-head firmware version information for safe operation" - ) - - if not await self.head96_request_tip_presence(): - raise ValueError( - "96-head has no tips (firmware reports none); use head96_move_stop_disk_z for Z moves " - "without a tip attached." - ) - - tip_overhang = await self.head96_request_tip_length() - STARBackend.DEFAULT_TIP_FITTING_DEPTH - - # The move is in stop-disk space over z_range, so the reachable tip-bottom window is z_range - # shifted down by the overhang, floored at the deck surface. Validate in tip-bottom terms. - z_min, z_max = self._head96_information.z_range - deck = STARBackend.MINIMUM_CHANNEL_Z_POSITION - if not (max(z_min - tip_overhang, deck) <= z <= z_max - tip_overhang): - raise ValueError( - f"tip-bottom z={z} mm out of reach " - f"[{round(max(z_min - tip_overhang, deck), 1)}, {round(z_max - tip_overhang, 1)}] mm " - f"for overhang {round(tip_overhang, 1)} mm" - ) - - return await self.head96_move_stop_disk_z(z + tip_overhang, speed=speed) - - @need_iswap_parked - @_requires_head96 - async def head96_probe_z_using_clld( - self, - start_pos_search: Optional[float] = None, - tip_len: Optional[float] = None, - lowest_immers_pos: Optional[float] = None, - approach_speed: Optional[float] = None, - speed: float = 10.0, - acceleration: float = 300.0, - lld_sensor: Literal["A1 or B2", "G11 or H12", "any", "all"] = "any", - detection_edge: int = 10, - detection_drop: int = 2, - post_detection_dist: float = 2.0, - current_protection_limiter: int = 15, - move_to_z_safety_after: bool = False, - ) -> float: - """Probe the liquid-surface Z-height with the 96-head's capacitive LLD (cLLD). - - Runs a downward cLLD search on the 96-head stop-disk Z drive (H0 ZL), stopping at the detected - surface, and returns the tip-bottom (= liquid-surface) Z-height. The 96-head counterpart of the - single-channel cLLD probe; `lld_sensor` is head-specific (the head has two cLLD sensors, a - channel has one). - - Positions are tip-bottom referenced like `head96_move_tool_z`: the tip overhang (stop disk minus - tip bottom, a rigid constant for the mounted tip) maps them to the firmware's stop-disk zh / zc, - and the deck floor caps the deepest immersion. The head should be at Z-safety before calling - - `start_pos_search` defaults to the top and the firmware brings the head there before searching. - cLLD needs a conductive path, so tips must be loaded. - - The ZL wire format changed between the 2008 and 2013 firmware command sets, so the parameters are - formatted per the head's reported firmware date. `lld_sensor` other than "any" requires 2013+ - firmware, since the 2008 ZL has no sensor-selection field. - - Args: - tip_len: mounted tip length in mm, used to map tip-bottom positions to the stop disk. None - (default) measures it via `head96_request_tip_length`. - lowest_immers_pos: lowest tip-bottom the search may reach in mm; None is the deepest safe value. - start_pos_search: tip-bottom position the search starts from in mm; None is the highest safe. - speed: cLLD search speed in mm/sec. - acceleration: search acceleration in mm/sec**2. - approach_speed: fast descent speed in mm/sec for the upper section, before the slow search. - None uses `head96_z_drive_speed_default`. - current_protection_limiter: motor current limit (hardware units; 0-15 on 2013+, 0-7 on 2008). - lld_sensor: which head cLLD sensor(s) trigger detection. - detection_edge: edge steepness threshold for cLLD detection (0-1023). - detection_drop: offset applied after cLLD edge detection (0-1023). - post_detection_dist: signed distance to move after detection in mm; positive moves up / out of - liquid, negative moves down / deeper. - move_to_z_safety_after: if True, retract the head to Z-safety after reading the height. - - Returns: - The detected liquid-surface Z-height as a tip-bottom position in mm. - - Raises: - ValueError: if the head holds no tips, the chosen sensor's corner channel(s) hold no tip (when - tip tracking is on), a parameter is out of range, or `lld_sensor` other than "any" is - requested on pre-2013 firmware. - """ - assert self._head96_information is not None, ( - "requires 96-head firmware version information for safe operation" - ) - - lld_sensor_map = {"G11 or H12": 0, "A1 or B2": 1, "any": 2, "all": 3} - if lld_sensor not in lld_sensor_map: - raise ValueError(f"lld_sensor must be one of {list(lld_sensor_map)}, is {lld_sensor!r}") - - z_speed_min, z_speed_max = self._head96_information.z_speed_range - z_accel_min, z_accel_max = self._head96_information.z_acceleration_range - - if approach_speed is None: - approach_speed = self.head96_z_drive_speed_default - if not z_speed_min <= approach_speed <= z_speed_max: - raise ValueError( - f"approach_speed must be between {z_speed_min} - {z_speed_max} mm/sec, is {approach_speed}" - ) - if not z_speed_min <= speed <= z_speed_max: - raise ValueError(f"speed must be between {z_speed_min} - {z_speed_max} mm/sec, is {speed}") - if not z_accel_min <= acceleration <= z_accel_max: - raise ValueError( - f"acceleration must be between {z_accel_min} - {z_accel_max} mm/sec**2, is {acceleration}" - ) - if not 0 <= detection_edge <= 1023: - raise ValueError(f"detection_edge must be between 0 - 1023, is {detection_edge}") - if not 0 <= detection_drop <= 1023: - raise ValueError(f"detection_drop must be between 0 - 1023, is {detection_drop}") - - # First guard (firmware, always verifiable): some channel must hold a tip for the conductive path. - if not await self.head96_request_tip_presence(): - raise ValueError("96-head cLLD requires tips loaded (conductive path); none detected") - - # When tip tracking is on, also require a tip on the corner channel(s) that feed the chosen - # sensor - the firmware guard above only confirms *some* channel does. cLLD sensor 0 reads - # G11(86)/H12(95), sensor 1 reads A1(0)/B2(9) (column-major head96 indices). - if does_tip_tracking() and self.head96 is not None: - sensor_0 = self.head96[86].has_tip or self.head96[95].has_tip - sensor_1 = self.head96[0].has_tip or self.head96[9].has_tip - sensor_ready = { - "G11 or H12": sensor_0, - "A1 or B2": sensor_1, - "any": sensor_0 or sensor_1, - "all": sensor_0 and sensor_1, - }[lld_sensor] - if not sensor_ready: - raise ValueError( - f"lld_sensor={lld_sensor!r}: the tip tracker reports no tip on the corner channel(s) " - "that feed it" - ) - - # Tip length: measure unless the caller supplied it. - if tip_len is None: - tip_len = await self.head96_request_tip_length() - tip_overhang = tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH - - # Reachable tip-bottom window: z_range shifted down by the overhang, floored at the deck. - z_min, z_max = self._head96_information.z_range - deck = STARBackend.MINIMUM_CHANNEL_Z_POSITION - height_min = max(z_min - tip_overhang, deck) - height_max = z_max - tip_overhang - - if lowest_immers_pos is None: - lowest_immers_pos = height_min - if start_pos_search is None: - start_pos_search = height_max - if not (height_min <= lowest_immers_pos <= height_max): - raise ValueError( - f"lowest_immers_pos={lowest_immers_pos} mm out of reach " - f"[{round(height_min, 1)}, {round(height_max, 1)}] mm (tip-bottom)" - ) - if not (height_min <= start_pos_search <= height_max): - raise ValueError( - f"start_pos_search={start_pos_search} mm out of reach " - f"[{round(height_min, 1)}, {round(height_max, 1)}] mm (tip-bottom)" - ) - - # lm and the raw 6-digit zr arrived with the 2013 firmware; pre-2013 has no lm and scales zr. - uses_2013_structure = self._head96_information.fw_version >= datetime.date(2013, 1, 1) - if not uses_2013_structure and lld_sensor != "any": - raise ValueError( - f"lld_sensor={lld_sensor!r} requires 2013+ firmware; the 2008 command set has no " - "sensor-selection field" - ) - - # zw (current protection limiter) range narrows on pre-2013 firmware. - zw_max = 15 if uses_2013_structure else 7 - if not 0 <= current_protection_limiter <= zw_max: - raise ValueError( - f"current_protection_limiter must be between 0 - {zw_max}, is {current_protection_limiter}" - ) - - # Back to stop-disk space (zh / zc) via the overhang. - lowest_immers_pos_increments = self._head96_z_drive_mm_to_increment( - lowest_immers_pos + tip_overhang - ) - start_pos_search_increments = self._head96_z_drive_mm_to_increment( - start_pos_search + tip_overhang - ) - approach_speed_increments = self._head96_z_drive_mm_to_increment(approach_speed) - speed_increments = self._head96_z_drive_mm_to_increment(speed) - acceleration_increments = self._head96_z_drive_mm_to_increment(acceleration) - - # Signed post-detection move -> direction (zj) and magnitude (zi). - post_detection_direction = 1 if post_detection_dist >= 0 else 0 - post_detection_dist_increments = self._head96_z_drive_mm_to_increment(abs(post_detection_dist)) - if not 0 <= post_detection_dist_increments <= 9999: - raise ValueError( - f"abs(post_detection_dist) must be <= " - f"{self._head96_z_drive_increment_to_mm(9999)} mm, is {abs(post_detection_dist)}" - ) - - lm_field = {"lm": str(lld_sensor_map[lld_sensor])} if uses_2013_structure else {} - if uses_2013_structure: - zr_field = f"{acceleration_increments:06}" # raw [increment/second**2] - zw_field = f"{current_protection_limiter:02}" - else: - zr_field = f"{acceleration_increments // 1000:03}" # [1000 increment/second**2] - zw_field = f"{current_protection_limiter:01}" - zl_params: Dict[str, Any] = { - "zh": f"{lowest_immers_pos_increments:05}", # lowest immersion position [increment] - "zc": f"{start_pos_search_increments:05}", # start position of LLD search [increment] - "zi": f"{post_detection_dist_increments:04}", # immersion depth after LLD [increment] - "zj": f"{post_detection_direction}", # direction of immersion depth (0 down, 1 up) - **lm_field, # which cLLD sensor(s) trigger detection (2013+ only) - "gt": f"{detection_edge:04}", # edge steepness at cLLD detection - "gl": f"{detection_drop:04}", # offset after cLLD edge detection - "zv": f"{approach_speed_increments:05}", # upper-section (fast approach) speed - "zl": f"{speed_increments:05}", # cLLD search speed - "zr": zr_field, # acceleration - "zw": zw_field, # current protection limiter - } - try: - await self.send_command(module="H0", command="ZL", **zl_params) - except STARFirmwareError: - await self.head96_move_to_z_safety() - raise - - # RH returns the latched detected surface (stop-disk frame), unaffected by the post-detection - # move; map it to tip-bottom. TODO(hardware): confirm the RH response format against a capture. - detected_tip_bottom = round(await self.head96_request_last_lld_height() - tip_overhang, 2) - if move_to_z_safety_after: - await self.head96_move_to_z_safety() - return detected_tip_bottom - - # -------------- 3.10.2 Tip handling using CoRe 96 Head -------------- - - @need_iswap_parked - @_requires_head96 - async def pick_up_tips_core96( - self, - x_position: int, - x_direction: int, - y_position: int, - tip_type_idx: int, - tip_pickup_method: int = 2, - z_deposit_position: int = 3425, - minimum_traverse_height_at_beginning_of_a_command: int = 3425, - minimum_height_command_end: int = 3425, - ): - """Pick up tips with CoRe 96 head - - Args: - x_position: x position [0.1mm]. Must be between 0 and 30000. Default 0. - x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - y_position: y position [0.1mm]. Must be between 1080 and 5600. Default 5600. - tip_size: Tip type. - tip_pickup_method: Tip pick up method. 0 = pick up from rack. 1 = pick up from C0Re 96 tip - wash station. 2 = pick up with " full volume blow out" - z_deposit_position: Z- deposit position [0.1mm] (collar bearing position) Must bet between - 0 and 3425. Default 3425. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning - of a command [0.1mm]. Must be between 0 and 3425. - minimum_height_command_end: Minimal height at command end [0.1 mm] Must be between 0 and 3425. - """ - - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" - assert 1080 <= y_position <= 5600, "y_position must be between 1080 and 5600" - assert 0 <= z_deposit_position <= 3425, "z_deposit_position must be between 0 and 3425" - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3425, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3425" - ) - assert 0 <= minimum_height_command_end <= 3425, ( - "minimum_height_command_end must be between 0 and 3425" - ) - - return await self.send_command( - module="C0", - command="EP", - xs=f"{x_position:05}", - xd=x_direction, - yh=f"{y_position:04}", - tt=f"{tip_type_idx:02}", - wu=tip_pickup_method, - za=f"{z_deposit_position:04}", - zh=f"{minimum_traverse_height_at_beginning_of_a_command:04}", - ze=f"{minimum_height_command_end:04}", - ) - - @need_iswap_parked - @_requires_head96 - async def discard_tips_core96( - self, - x_position: int, - x_direction: int, - y_position: int, - z_deposit_position: int = 3425, - minimum_traverse_height_at_beginning_of_a_command: int = 3425, - minimum_height_command_end: int = 3425, - ): - """Drop tips with CoRe 96 head - - Args: - x_position: x position [0.1mm]. Must be between 0 and 30000. Default 0. - x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - y_position: y position [0.1mm]. Must be between 1080 and 5600. Default 5600. - tip_type: Tip type. - tip_pickup_method: Tip pick up method. 0 = pick up from rack. 1 = pick up from C0Re 96 - tip wash station. 2 = pick up with " full volume blow out" - z_deposit_position: Z- deposit position [0.1mm] (collar bearing position) Must bet between - 0 and 3425. Default 3425. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning - of a command [0.1mm]. Must be between 0 and 3425. - minimum_height_command_end: Minimal height at command end [0.1 mm] Must be between 0 and 3425 - """ - - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" - assert 1080 <= y_position <= 5600, "y_position must be between 1080 and 5600" - assert 0 <= z_deposit_position <= 3425, "z_deposit_position must be between 0 and 3425" - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3425, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3425" - ) - assert 0 <= minimum_height_command_end <= 3425, ( - "minimum_height_command_end must be between 0 and 3425" - ) - - return await self.send_command( - module="C0", - command="ER", - xs=f"{x_position:05}", - xd=x_direction, - yh=f"{y_position:04}", - za=f"{z_deposit_position:04}", - zh=f"{minimum_traverse_height_at_beginning_of_a_command:04}", - ze=f"{minimum_height_command_end:04}", - ) - - # -------------- 3.10.3 Liquid handling using CoRe 96 Head -------------- - - @_requires_head96 - async def head96_experimental_aspirate( - self, - volume: float, - flow_rate: Optional[float] = None, - minimum_height: Optional[float] = None, - surface_following_distance: float = 0.0, - requires_tip: bool = True, - ): - """Aspirate on the 96-head with surface following (the firmware drives Z and the dispensing drive - in parallel). - - The direct, full-control counterpart to `aspirate96`: it takes the height / surface-following / - flow directly rather than a resource and liquid class, and computes no positions. Acts on the whole - head (rigid - no per-channel selection). Values are given in human units and converted to firmware - increments. This is a basic, thin command: it does not settle after aspirating (settling is a - separate basic command). - - Args: - volume: The piston (dispensing-drive) volume to aspirate per channel, uL; raw, not liquid-class - corrected. - flow_rate: The dispensing-drive speed, uL/s; None uses the head's default speed. - minimum_height: The lowest the tip end (tip-bottom) descends to, mm - the end of the stroke. - None defaults to the deepest safe target: the deck floor with a tip on, or the firmware Z - floor with none on (no tip overhang, so this is the stop-disk position directly). - surface_following_distance: The Z travel during aspiration, mm; 0 keeps the head in place so it - cannot drive into the container bottom. - requires_tip: If True, raise if the head holds no tips; if False, allow aspirating air. - - Raises: - RuntimeError: If 96-head is not installed, or requires_tip and the head holds no tips. - AssertionError: If firmware info missing or a parameter is out of range. - """ - assert self._head96_information is not None, "96-head information not loaded; run setup()" - info = self._head96_information - vol_min, vol_max = info.dispensing_drive_range - flow_min, flow_max = info.dispensing_drive_speed_range - z_min, z_max = info.z_range - surface_following_max = self._head96_z_drive_increment_to_mm(9999) - if flow_rate is None: - flow_rate = info.dispensing_drive_speed_default - - assert vol_min <= volume <= vol_max, f"volume must be between {vol_min} and {vol_max} uL" - assert flow_min <= flow_rate <= flow_max, ( - f"flow_rate must be between {flow_min} and {flow_max} uL/s" - ) - assert 0 <= surface_following_distance <= surface_following_max, ( - f"surface_following_distance must be between 0 and {surface_following_max} mm" - ) - - has_tips = bool(await self.head96_request_tip_presence()) - if requires_tip and not has_tips: - raise RuntimeError( - "96-head has no tips (firmware reports none); pick up tips before aspirating" - ) - - # minimum_height is a tip-bottom height: the tip overhang (stop disk - tip bottom) converts it to - # the firmware stop-disk zh, and the deck floor caps how deep it may go. With no tip there is no - # overhang, so minimum_height is the stop-disk position directly and is guarded against z_min. - overhang = 0.0 - if has_tips: - overhang = await self.head96_request_tip_length() - STARBackend.DEFAULT_TIP_FITTING_DEPTH - height_min = max(z_min - overhang, STARBackend.MINIMUM_CHANNEL_Z_POSITION) - height_max = z_max - overhang - if minimum_height is None: - minimum_height = height_min - assert height_min <= minimum_height <= height_max, ( - f"minimum_height must be between {height_min} and {height_max} mm" - ) - - volume_increment = self._head96_dispensing_drive_uL_to_increment(volume) - flow_rate_increment = self._head96_dispensing_drive_uL_to_increment(flow_rate) - surface_following_increment = self._head96_z_drive_mm_to_increment(surface_following_distance) - zh_increment = self._head96_z_drive_mm_to_increment(minimum_height + overhang) - return await self.send_command( - module="H0", - command="PA", - pm="F" * 24, # all 96 channels; the rigid head has no per-channel selection - dj="1", # minimum_height enforcement always on; the resolved minimum_height is the floor - da=f"{volume_increment:05}", - dv=f"{flow_rate_increment:05}", - dc="00000", # pre-wetting off; its interaction with surface following is unverified - zd=f"{surface_following_increment:04}", - zh=f"{zh_increment:05}", - to="000", # settling_time not exposed here (firmware allows it); it is its own basic command - ) - - @_requires_head96 - async def head96_experimental_dispense( - self, - volume: float, - flow_rate: Optional[float] = None, - minimum_height: Optional[float] = None, - stop_back_volume: float = 0.0, - surface_following_distance: float = 0.0, - stop_flow_rate: Optional[float] = None, - requires_tip: bool = True, - ): - """Dispense on the 96-head with surface following (the firmware drives Z and the dispensing drive - in parallel). - - The direct, full-control counterpart to `dispense96`. Acts on the whole head (rigid). This is a - basic, thin command: it does not settle after dispensing (settling is a separate basic command). - - Args: - volume: The piston (dispensing-drive) volume to dispense per channel, uL; raw, not liquid-class - corrected. - flow_rate: The dispensing-drive speed, uL/s; None uses the head's default speed. - minimum_height: The lowest the tip end (tip-bottom) descends to, mm - the end of the stroke. - None defaults to the deepest safe target: the deck floor with a tip on, or the firmware Z - floor with none on (no tip overhang, so this is the stop-disk position directly). - stop_back_volume: The volume drawn back at the end to stop dripping, uL. - surface_following_distance: The Z travel during dispense, mm. - stop_flow_rate: The dispensing-drive stop speed, uL/s; None uses the firmware default (0). - requires_tip: If True, raise if the head holds no tips; if False, allow dispensing air. - - Raises: - RuntimeError: If 96-head is not installed, or requires_tip and the head holds no tips. - AssertionError: If firmware info missing or a parameter is out of range. - """ - assert self._head96_information is not None, "96-head information not loaded; run setup()" - info = self._head96_information - vol_min, vol_max = info.dispensing_drive_range - flow_min, flow_max = info.dispensing_drive_speed_range - z_min, z_max = info.z_range - surface_following_max = self._head96_z_drive_increment_to_mm(9999) - stop_back_max = self._head96_dispensing_drive_increment_to_uL(9999) - if flow_rate is None: - flow_rate = info.dispensing_drive_speed_default - if stop_flow_rate is None: - stop_flow_rate = 0.0 # firmware stop-speed default - - assert vol_min <= volume <= vol_max, f"volume must be between {vol_min} and {vol_max} uL" - assert flow_min <= flow_rate <= flow_max, ( - f"flow_rate must be between {flow_min} and {flow_max} uL/s" - ) - assert 0 <= stop_back_volume <= stop_back_max, ( - f"stop_back_volume must be between 0 and {stop_back_max} uL" - ) - assert 0 <= surface_following_distance <= surface_following_max, ( - f"surface_following_distance must be between 0 and {surface_following_max} mm" - ) - assert 0 <= stop_flow_rate <= flow_max, f"stop_flow_rate must be between 0 and {flow_max} uL/s" - - has_tips = bool(await self.head96_request_tip_presence()) - if requires_tip and not has_tips: - raise RuntimeError( - "96-head has no tips (firmware reports none); pick up tips before dispensing" - ) - - # minimum_height is a tip-bottom height: the tip overhang (stop disk - tip bottom) converts it to - # the firmware stop-disk zh, and the deck floor caps how deep it may go. With no tip there is no - # overhang, so minimum_height is the stop-disk position directly and is guarded against z_min. - overhang = 0.0 - if has_tips: - overhang = await self.head96_request_tip_length() - STARBackend.DEFAULT_TIP_FITTING_DEPTH - height_min = max(z_min - overhang, STARBackend.MINIMUM_CHANNEL_Z_POSITION) - height_max = z_max - overhang - if minimum_height is None: - minimum_height = height_min - assert height_min <= minimum_height <= height_max, ( - f"minimum_height must be between {height_min} and {height_max} mm" - ) - - volume_increment = self._head96_dispensing_drive_uL_to_increment(volume) - flow_rate_increment = self._head96_dispensing_drive_uL_to_increment(flow_rate) - stop_back_increment = self._head96_dispensing_drive_uL_to_increment(stop_back_volume) - surface_following_increment = self._head96_z_drive_mm_to_increment(surface_following_distance) - zh_increment = self._head96_z_drive_mm_to_increment(minimum_height + overhang) - stop_flow_rate_increment = self._head96_dispensing_drive_uL_to_increment(stop_flow_rate) - return await self.send_command( - module="H0", - command="PB", - pm="F" * 24, # all 96 channels; the rigid head has no per-channel selection - db=f"{volume_increment:05}", - dv=f"{flow_rate_increment:05}", - dd=f"{stop_back_increment:04}", - ze=f"{surface_following_increment:04}", - zh=f"{zh_increment:05}", - du=f"{stop_flow_rate_increment:05}", - ) - - @_requires_head96 - @need_iswap_parked - async def mix96( - self, - mix: Mix, - resource: Union[Plate, Container, List[Well]], - offset: Coordinate = Coordinate.zero(), - minimum_traverse_height_start: Optional[float] = None, - blowout_air_volume: float = 5.0, - lld_mode: Optional[LLDMode] = None, - descent_speed: float = 80.0, - swap_speed: float = 5.0, - settling_time: float = 0.0, - minimum_traverse_height_end: Optional[float] = None, - ): - """Mix in place with the 96-head over a resource (aspirate96-style target). - - Thin convenience wrapper over :meth:`mix96_at_coordinate`: resolves the channel-A1 deck - target (and the container top, used for the swap-start clearance) from ``resource`` like - ``aspirate96`` does, applies ``offset``, and delegates. See that method for the mixing - behaviour and the meaning of the remaining arguments. - - Args: - mix: volume, repetitions, flow_rate and optional surface_following_distance. - resource: aspirate96-style target - a Plate (head A1 over well A1), a Container, or a list - of Wells. - offset: added to the resolved channel-A1 target position. - minimum_traverse_height_start: absolute tip-bottom Z before the X/Y move; None uses full Z - safety. - blowout_air_volume: air gap taken above the well before descent and expelled above it on - exit, to clear the tips of residual on the way out; 0 skips both. - lld_mode: liquid-level-detection mode; only ``LLDMode.OFF`` is supported (the default). - descent_speed: speed for the fast descent down to just above the well. - swap_speed: speed from there into the well to the target Z. - settling_time: seconds to wait after the last cycle, before retracting out of the well. - minimum_traverse_height_end: absolute tip-bottom Z after mixing; None uses full Z safety. - """ - anchor: Container - if isinstance(resource, Plate): - anchor = resource.get_item(0) # head A1 over well A1 (as aspirate96 resolves it) - elif isinstance(resource, list): - anchor = resource[0] - else: - anchor = resource - a1 = anchor.get_absolute_location(x="c", y="c", z="cavity_bottom") + offset - z_top = anchor.get_absolute_location(x="c", y="c", z="top").z - - return await self.mix96_at_coordinate( - mix, - a1_coordinate=a1, - z_top=z_top, - minimum_traverse_height_start=minimum_traverse_height_start, - blowout_air_volume=blowout_air_volume, - lld_mode=lld_mode, - descent_speed=descent_speed, - swap_speed=swap_speed, - settling_time=settling_time, - minimum_traverse_height_end=minimum_traverse_height_end, - ) - - async def mix96_at_coordinate( - self, - mix: Mix, - a1_coordinate: Coordinate, - z_top: Optional[float] = None, - minimum_traverse_height_start: Optional[float] = None, - blowout_air_volume: float = 5.0, - lld_mode: Optional[LLDMode] = None, - descent_speed: float = 80.0, - swap_speed: float = 5.0, - settling_time: float = 0.0, - minimum_traverse_height_end: Optional[float] = None, - ): - """Position the 96-head over an explicit channel-A1 deck coordinate and mix in place. - - Raises the single channels to safe Z, then moves X/Y over the target and descends into the - well, then runs ``mix.repetitions`` symmetric aspirate / dispense cycles: each aspirate - follows the surface down by ``surface_following_distance`` and each dispense follows it - back up, so the tip oscillates without drifting. Returns to a traverse height when done. - - Z targets are in tip-bottom space (the target Z is where the tip end goes, not the stop - disk). For a resource-relative target, use :meth:`mix96`. - - Args: - mix: volume, repetitions, flow_rate and optional surface_following_distance. - a1_coordinate: explicit channel-A1 deck target (tip-bottom space). - z_top: container top Z used for the swap-start clearance above the well; None descends - straight to just above the mix start. - minimum_traverse_height_start: absolute tip-bottom Z before the X/Y move; None uses full Z - safety. - blowout_air_volume: air gap taken above the well before descent and expelled above it on - exit, to clear the tips of residual on the way out; 0 skips both. - lld_mode: liquid-level-detection mode; only ``LLDMode.OFF`` is supported (the default). - descent_speed: speed for the fast descent down to just above the well. - swap_speed: speed from there into the well to the target Z. - settling_time: seconds to wait after the last cycle, before retracting out of the well. - minimum_traverse_height_end: absolute tip-bottom Z after mixing; None uses full Z safety. - - Raises: - ValueError: if ``lld_mode`` is not ``LLDMode.OFF`` or ``settling_time`` < 0. - RuntimeError: if the 96-head holds no tips. - """ - lld_mode = lld_mode if lld_mode is not None else self.LLDMode.OFF - if lld_mode is not self.LLDMode.OFF: - raise ValueError("mix96 currently supports only LLDMode.OFF") - - if settling_time < 0: - raise ValueError("settling_time must be >= 0") - - if await self.head96_request_tip_presence() == 0: - raise RuntimeError("96-head has no tips (firmware reports none); pick up tips first") - - # H0 direct-drive moves don't raise the single channels (the C0 core-96 commands do so at - # firmware level), so do it explicitly before the X/Y traverse. - await self.move_all_channels_in_z_safety() - - a1 = a1_coordinate - - # traverse to start height; None retracts to full (stop-disk) Z safety, a value is the - # tip-bottom height the rest of the method works in - if minimum_traverse_height_start is None: - await self.head96_move_to_z_safety() - else: - await self.head96_move_tool_z(minimum_traverse_height_start, speed=descent_speed) - - # move X, Y; X acceleration_level=1 below y=200 mm, the low-Y zone where the head is - # cantilevered forward off the X-drive and wobbles most - # TODO: replace head96_move_y with a primitive Y move to enable parallelised addressing of - # the X and Y drives. Its speed/acceleration request+set round-trip currently blocks - # parallelisation of the Y drive: the second read is trapped behind the in-flight X move on - # the single connection, so the Y move only starts once X has finished. - await asyncio.gather( - self.head96_move_x(a1.x, acceleration_level=1 if a1.y <= 200.0 else 3), - self.head96_move_y(a1.y), - ) - - # the tip oscillates between the floor (a1.z) and mix_start (floor + sf), starting at - # mix_start so the first aspirate can follow the surface down without hitting the bottom. - sf = 0.0 if mix.surface_following_distance is None else mix.surface_following_distance - mix_floor = a1.z - mix_start = a1.z + sf - - # 2-stage Z descent in tip-bottom space: descent_speed to the swap-start height just above - # the well, aspirate blowout_air_volume, then swap_speed down to mix_start; move_tool_z lands - # the tip end each move. - z_clearance = 5.0 - swap_start_z = (z_top if z_top is not None else mix_start) + z_clearance - await self.head96_move_tool_z(swap_start_z, speed=descent_speed) - if blowout_air_volume: - await self.head96_experimental_aspirate( - blowout_air_volume, - flow_rate=mix.flow_rate, - minimum_height=mix_floor, - surface_following_distance=0, - requires_tip=False, - ) - await self.head96_move_tool_z(mix_start, speed=swap_speed) - - # symmetric mix cycles (no per-cycle drift): each aspirate follows the surface down by sf - # to the floor, each dispense back up to mix_start. minimum_height is the tip-bottom floor; - # the experimental commands convert it to the stop-disk reference. - for _ in range(mix.repetitions): - await self.head96_experimental_aspirate( - mix.volume, - flow_rate=mix.flow_rate, - minimum_height=mix_floor, - surface_following_distance=sf, - requires_tip=False, - ) - await self.head96_experimental_dispense( - mix.volume, - flow_rate=mix.flow_rate, - minimum_height=mix_floor, - surface_following_distance=sf, - requires_tip=False, - ) - - # settle in place (tip still in the liquid) after the last cycle - if settling_time: - await asyncio.sleep(settling_time) - - # careful exit at swap_speed back up to the swap-start height (mirrors the descent), before - # the fast traverse out - await self.head96_move_tool_z(swap_start_z, speed=swap_speed) - - if blowout_air_volume: - await self.head96_experimental_dispense( - blowout_air_volume, - flow_rate=mix.flow_rate, - requires_tip=False, - ) - - # traverse to end height; None retracts to full (stop-disk) Z safety, a value is the - # tip-bottom height the rest of the method works in - if minimum_traverse_height_end is None: - await self.head96_move_to_z_safety() - else: - await self.head96_move_tool_z(minimum_traverse_height_end, speed=descent_speed) - - # # # Granular commands # # # - - async def head96_dispensing_drive_move_to_home_volume( - self, - ): - """Move the 96-head dispensing drive into its home position (vol=0.0 uL). - - .. warning:: - This firmware command is known to be broken: the 96-head dispensing drive cannot reach - vol=0.0 uL, which typically raises - ``STARFirmwareError: {'CoRe 96 Head': UnknownHamiltonError('Position out of permitted - area')}``. - """ - - logger.warning( - "head96_dispensing_drive_move_to_home_volume is a known broken firmware command: " - "the 96-head dispensing drive cannot reach vol=0.0 uL and will likely raise " - "STARFirmwareError: {'CoRe 96 Head': UnknownHamiltonError('Position out of permitted " - "area')}. Attempting to send the command anyway." - ) - - return await self.send_command( - module="H0", - command="DL", - ) - - # # # "Atomic" liquid handling commands # # # - - @need_iswap_parked - @_requires_head96 - async def aspirate_core_96( - self, - aspiration_type: int = 0, - x_position: int = 0, - x_direction: int = 0, - y_positions: int = 0, - minimum_traverse_height_at_beginning_of_a_command: int = 3425, - min_z_endpos: int = 3425, - lld_search_height: int = 3425, - liquid_surface_no_lld: int = 3425, - pull_out_distance_transport_air: int = 3425, - minimum_height: int = 3425, - second_section_height: int = 0, - second_section_ratio: int = 3425, - immersion_depth: int = 0, - immersion_depth_direction: int = 0, - surface_following_distance: float = 0, - aspiration_volumes: int = 0, - aspiration_speed: int = 1000, - transport_air_volume: int = 0, - blow_out_air_volume: int = 200, - pre_wetting_volume: int = 0, - lld_mode: int = 1, - gamma_lld_sensitivity: int = 1, - swap_speed: int = 100, - settling_time: int = 5, - mix_volume: int = 0, - mix_cycles: int = 0, - mix_position_from_liquid_surface: int = 250, - mix_surface_following_distance: int = 0, - speed_of_mix: int = 1000, - channel_pattern: List[bool] = [True] * 96, - limit_curve_index: int = 0, - tadm_algorithm: bool = False, - recording_mode: int = 0, - # Deprecated parameters, to be removed in future versions - # rm: >2026-01: - liquid_surface_sink_distance_at_the_end_of_aspiration: float = 0, - minimal_end_height: int = 3425, - liquid_surface_at_function_without_lld: int = 3425, - pull_out_distance_to_take_transport_air_in_function_without_lld: int = 50, - maximum_immersion_depth: int = 3425, - surface_following_distance_during_mix: int = 0, - tube_2nd_section_ratio: int = 3425, - tube_2nd_section_height_measured_from_zm: int = 0, - ): - """aspirate CoRe 96 - - Aspiration of liquid using CoRe 96 - - Args: - aspiration_type: Type of aspiration (0 = simple; 1 = sequence; 2 = cup emptied). Must be - between 0 and 2. Default 0. - x_position: X-Position [0.1mm] of well A1. Must be between 0 and 30000. Default 0. - x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - y_positions: Y-Position [0.1mm] of well A1. Must be between 1080 and 5600. Default 0. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of - a command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). - Must be between 0 and 3425. Default 3425. - min_z_endpos: Minimal height at command end [0.1mm]. Must be between 0 and 3425. Default 3425. - lld_search_height: LLD search height [0.1mm]. Must be between 0 and 3425. Default 3425. - liquid_surface_no_lld: Liquid surface at function without LLD [0.1mm]. Must be between 0 and 3425. Default 3425. - pull_out_distance_transport_air: pull out distance to take transport air in function without LLD [0.1mm]. Must be between 0 and 3425. Default 50. - minimum_height: Minimum height (maximum immersion depth) [0.1mm]. Must be between 0 and 3425. Default 3425. - second_section_height: second ratio height. Must be between 0 and 3425. Default 0. - second_section_ratio: Tube 2nd section ratio (See Fig 2.). Must be between 0 and 10000. Default 3425. - immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0. - immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out of - liquid). Must be between 0 and 1. Default 0. - surface_following_distance_at_the_end_of_aspiration: Surface following distance during - aspiration [0.1mm]. Must be between 0 and 990. Default 0. (renamed for clarity from - 'liquid_surface_sink_distance_at_the_end_of_aspiration' in firmware docs) - aspiration_volumes: Aspiration volume [0.1ul]. Must be between 0 and 11500. Default 0. - aspiration_speed: Aspiration speed [0.1ul/s]. Must be between 3 and 5000. Default 1000. - transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0. - blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 11500. Default 200. - pre_wetting_volume: Pre-wetting volume. Must be between 0 and 11500. Default 0. - lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be between - 0 and 4. Default 1. - gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and 4. - Default 1. - swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1000. Default 100. - settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5. - mix_volume: mix volume [0.1ul]. Must be between 0 and 11500. Default 0. - mix_cycles: Number of mix cycles. Must be between 0 and 99. Default 0. - mix_position_from_liquid_surface: mix position in Z- direction from - liquid surface (LLD or absolute terms) [0.1mm]. Must be between 0 and 990. Default 250. - mix_surface_following_distance: surface following distance during - mix [0.1mm]. Must be between 0 and 990. Default 0. - speed_of_mix: Speed of mix [0.1ul/s]. Must be between 3 and 5000. - Default 1000. - todo: TODO: 24 hex chars. Must be between 4 and 5000. - limit_curve_index: limit curve index. Must be between 0 and 999. Default 0. - tadm_algorithm: TADM algorithm. Default False. - recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. - Must be between 0 and 2. Default 0. - """ - - # # # TODO: delete > 2026-01 # # # - # deprecated liquid_surface_sink_distance_at_the_end_of_aspiration: - if liquid_surface_sink_distance_at_the_end_of_aspiration != 0.0: - surface_following_distance = liquid_surface_sink_distance_at_the_end_of_aspiration - warnings.warn( - "The liquid_surface_sink_distance_at_the_end_of_aspiration parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard surface_following_distance parameter instead.\n" - "liquid_surface_sink_distance_at_the_end_of_aspiration currently superseding " - "surface_following_distance.", - DeprecationWarning, - ) - - if minimal_end_height != 3425: - min_z_endpos = minimal_end_height - warnings.warn( - "The minimal_end_height parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard min_z_endpos parameter instead.\n" - "minimal_end_height currently superseding min_z_endpos.", - DeprecationWarning, - ) - - if liquid_surface_at_function_without_lld != 3425: - liquid_surface_no_lld = liquid_surface_at_function_without_lld - warnings.warn( - "The liquid_surface_at_function_without_lld parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard liquid_surface_no_lld parameter instead.\n" - "liquid_surface_at_function_without_lld currently superseding liquid_surface_no_lld.", - DeprecationWarning, - ) - - if pull_out_distance_to_take_transport_air_in_function_without_lld != 50: - pull_out_distance_transport_air = ( - pull_out_distance_to_take_transport_air_in_function_without_lld - ) - warnings.warn( - "The pull_out_distance_to_take_transport_air_in_function_without_lld parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard pull_out_distance_transport_air parameter instead.\n" - "pull_out_distance_to_take_transport_air_in_function_without_lld currently superseding pull_out_distance_transport_air.", - DeprecationWarning, - ) - - if maximum_immersion_depth != 3425: - minimum_height = maximum_immersion_depth - warnings.warn( - "The maximum_immersion_depth parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard minimum_height parameter instead.\n" - "minimum_height currently superseding maximum_immersion_depth.", - DeprecationWarning, - ) - - if surface_following_distance_during_mix != 0: - mix_surface_following_distance = surface_following_distance_during_mix - warnings.warn( - "The surface_following_distance_during_mix parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard mix_surface_following_distance parameter instead.\n" - "surface_following_distance_during_mix currently superseding mix_surface_following_distance.", - DeprecationWarning, - ) - - if tube_2nd_section_ratio != 3425: - second_section_ratio = tube_2nd_section_ratio - warnings.warn( - "The tube_2nd_section_ratio parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard second_section_ratio parameter instead.\n" - "tube_2nd_section_ratio currently superseding second_section_ratio.", - DeprecationWarning, - ) - - if tube_2nd_section_height_measured_from_zm != 0: - second_section_height = tube_2nd_section_height_measured_from_zm - warnings.warn( - "The tube_2nd_section_height_measured_from_zm parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard tube_2nd_section_height_measured_from_zm parameter instead.\n" - "tube_2nd_section_height_measured_from_zm currently superseding tube_2nd_section_height_measured_from_zm.", - DeprecationWarning, - ) - # # # delete # # # - - assert 0 <= aspiration_type <= 2, "aspiration_type must be between 0 and 2" - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" - assert 1080 <= y_positions <= 5600, "y_positions must be between 1080 and 5600" - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3425, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3425" - ) - assert 0 <= min_z_endpos <= 3425, "min_z_endpos must be between 0 and 3425" - assert 0 <= lld_search_height <= 3425, "lld_search_height must be between 0 and 3425" - assert 0 <= liquid_surface_no_lld <= 3425, "liquid_surface_no_lld must be between 0 and 3425" - assert 0 <= pull_out_distance_transport_air <= 3425, ( - "pull_out_distance_transport_air must be between 0 and 3425" - ) - assert 0 <= minimum_height <= 3425, "minimum_height must be between 0 and 3425" - assert 0 <= second_section_height <= 3425, "second_section_height must be between 0 and 3425" - assert 0 <= second_section_ratio <= 10000, "second_section_ratio must be between 0 and 10000" - assert 0 <= immersion_depth <= 3600, "immersion_depth must be between 0 and 3600" - assert 0 <= immersion_depth_direction <= 1, "immersion_depth_direction must be between 0 and 1" - assert 0 <= surface_following_distance <= 990, ( - "surface_following_distance must be between 0 and 990" - ) - assert 0 <= aspiration_volumes <= 11500, "aspiration_volumes must be between 0 and 11500" - assert 3 <= aspiration_speed <= 5000, "aspiration_speed must be between 3 and 5000" - assert 0 <= transport_air_volume <= 500, "transport_air_volume must be between 0 and 500" - assert 0 <= blow_out_air_volume <= 11500, "blow_out_air_volume must be between 0 and 11500" - assert 0 <= pre_wetting_volume <= 11500, "pre_wetting_volume must be between 0 and 11500" - assert 0 <= lld_mode <= 4, "lld_mode must be between 0 and 4" - assert 1 <= gamma_lld_sensitivity <= 4, "gamma_lld_sensitivity must be between 1 and 4" - assert 3 <= swap_speed <= 1000, "swap_speed must be between 3 and 1000" - assert 0 <= settling_time <= 99, "settling_time must be between 0 and 99" - assert 0 <= mix_volume <= 11500, "mix_volume must be between 0 and 11500" - assert 0 <= mix_cycles <= 99, "mix_cycles must be between 0 and 99" - assert 0 <= mix_position_from_liquid_surface <= 990, ( - "mix_position_from_liquid_surface must be between 0 and 990" - ) - assert 0 <= mix_surface_following_distance <= 990, ( - "mix_surface_following_distance must be between 0 and 990" - ) - assert 3 <= speed_of_mix <= 5000, "speed_of_mix must be between 3 and 5000" - assert 0 <= limit_curve_index <= 999, "limit_curve_index must be between 0 and 999" - - assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" - - # Convert bool list to hex string - assert len(channel_pattern) == 96, "channel_pattern must be a list of 96 boolean values" - channel_pattern_bin_str = reversed(["1" if x else "0" for x in channel_pattern]) - channel_pattern_hex = hex(int("".join(channel_pattern_bin_str), 2)).upper()[2:] - - return await self.send_command( - module="C0", - command="EA", - read_timeout=max(300, self.read_timeout), - aa=aspiration_type, - xs=f"{x_position:05}", - xd=x_direction, - yh=f"{y_positions:04}", - zh=f"{minimum_traverse_height_at_beginning_of_a_command:04}", - ze=f"{min_z_endpos:04}", - lz=f"{lld_search_height:04}", - zt=f"{liquid_surface_no_lld:04}", - pp=f"{pull_out_distance_transport_air:04}", - zm=f"{minimum_height:04}", - zv=f"{second_section_height:04}", - zq=f"{second_section_ratio:05}", - iw=f"{immersion_depth:03}", - ix=immersion_depth_direction, - fh=f"{surface_following_distance:03}", - af=f"{aspiration_volumes:05}", - ag=f"{aspiration_speed:04}", - vt=f"{transport_air_volume:03}", - bv=f"{blow_out_air_volume:05}", - wv=f"{pre_wetting_volume:05}", - cm=lld_mode, - cs=gamma_lld_sensitivity, - bs=f"{swap_speed:04}", - wh=f"{settling_time:02}", - hv=f"{mix_volume:05}", - hc=f"{mix_cycles:02}", - hp=f"{mix_position_from_liquid_surface:03}", - mj=f"{mix_surface_following_distance:03}", - hs=f"{speed_of_mix:04}", - cw=channel_pattern_hex, - cr=f"{limit_curve_index:03}", - cj=tadm_algorithm, - cx=recording_mode, - ) - - @need_iswap_parked - @_requires_head96 - async def dispense_core_96( - self, - dispensing_mode: int = 0, - x_position: int = 0, - x_direction: int = 0, - y_position: int = 0, - second_section_height: int = 0, - second_section_ratio: int = 3425, - lld_search_height: int = 3425, - liquid_surface_no_lld: int = 3425, - pull_out_distance_transport_air: int = 50, - minimum_height: int = 3425, - immersion_depth: int = 0, - immersion_depth_direction: int = 0, - surface_following_distance: float = 0, - minimum_traverse_height_at_beginning_of_a_command: int = 3425, - min_z_endpos: int = 3425, - dispense_volume: int = 0, - dispense_speed: int = 5000, - cut_off_speed: int = 250, - stop_back_volume: int = 0, - transport_air_volume: int = 0, - blow_out_air_volume: int = 200, - lld_mode: int = 1, - gamma_lld_sensitivity: int = 1, - side_touch_off_distance: int = 0, - swap_speed: int = 100, - settling_time: int = 5, - mixing_volume: int = 0, - mixing_cycles: int = 0, - mix_position_from_liquid_surface: int = 250, - mix_surface_following_distance: int = 0, - speed_of_mixing: int = 1000, - channel_pattern: List[bool] = [True] * 12 * 8, - limit_curve_index: int = 0, - tadm_algorithm: bool = False, - recording_mode: int = 0, - # Deprecated parameters, to be removed in future versions - # rm: >2026-01: - liquid_surface_sink_distance_at_the_end_of_dispense: float = 0, # surface_following_distance! - tube_2nd_section_ratio: int = 3425, - liquid_surface_at_function_without_lld: int = 3425, - maximum_immersion_depth: int = 3425, - minimal_end_height: int = 3425, - mixing_position_from_liquid_surface: int = 250, - surface_following_distance_during_mixing: int = 0, - pull_out_distance_to_take_transport_air_in_function_without_lld: int = 50, - tube_2nd_section_height_measured_from_zm: int = 0, - ): - """Dispensing of liquid using CoRe 96 - - Args: - dispensing_mode: Type of dispensing mode 0 = Partial volume in jet mode 1 = Blow out - in jet mode 2 = Partial volume at surface 3 = Blow out at surface 4 = Empty tip at fix - position. Must be between 0 and 4. Default 0. - x_position: X-Position [0.1mm] of well A1. Must be between 0 and 30000. Default 0. - x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - y_position: Y-Position [0.1mm] of well A1. Must be between 1080 and 5600. Default 0. - minimum_height: Minimum height (maximum immersion depth) [0.1mm]. Must be between 0 and 3425. Default 3425. - second_section_height: Second ratio height. [0.1mm]. Must be between 0 and 3425. Default 0. - second_section_ratio: Tube 2nd section ratio (See Fig 2.). Must be between 0 and 10000. Default 3425. - lld_search_height: LLD search height [0.1mm]. Must be between 0 and 3425. Default 3425. - liquid_surface_no_lld: Liquid surface at function without LLD [0.1mm]. Must be between 0 and 3425. Default 3425. - pull_out_distance_transport_air: pull out distance to take transport air in function without LLD [0.1mm]. Must be between 0 and 3425. Default 50. - immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0. - immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out of - liquid). Must be between 0 and 1. Default 0. - surface_following_distance: Liquid surface following distance during dispense [0.1mm]. - Must be between 0 and 990. Default 0. (renamed for clarity from - 'liquid_surface_sink_distance_at_the_end_of_dispense' in firmware docs) - minimum_traverse_height_at_beginning_of_a_command: Minimal traverse height at begin of - command [0.1mm]. Must be between 0 and 3425. Default 3425. - min_z_endpos: Minimal height at command end [0.1mm]. Must be between 0 and 3425. Default 3425. - dispense_volume: Dispense volume [0.1ul]. Must be between 0 and 11500. Default 0. - dispense_speed: Dispense speed [0.1ul/s]. Must be between 3 and 5000. Default 5000. - cut_off_speed: Cut-off speed [0.1ul/s]. Must be between 3 and 5000. Default 250. - stop_back_volume: Stop back volume [0.1ul/s]. Must be between 0 and 999. Default 0. - transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0. - blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 11500. Default 200. - lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be - between 0 and 4. Default 1. - gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and 4. - Default 1. - side_touch_off_distance: side touch off distance [0.1 mm] 0 = OFF ( > 0 = ON & turns LLD off) - Must be between 0 and 45. Default 1. - swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1000. Default 100. - settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5. - mixing_volume: mix volume [0.1ul]. Must be between 0 and 11500. Default 0. - mixing_cycles: Number of mixing cycles. Must be between 0 and 99. Default 0. - mix_position_from_liquid_surface: mix position in Z- direction from liquid surface (LLD or absolute terms) [0.1mm]. Must be between 0 and 990. Default 250. - mix_surface_following_distance: surface following distance during mixing [0.1mm]. Must be between 0 and 990. Default 0. - speed_of_mixing: Speed of mixing [0.1ul/s]. Must be between 3 and 5000. Default 1000. - channel_pattern: list of 96 boolean values - limit_curve_index: limit curve index. Must be between 0 and 999. Default 0. - tadm_algorithm: TADM algorithm. Default False. - recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. Must - be between 0 and 2. Default 0. - """ - - # # # TODO: delete > 2026-01 # # # - # deprecated liquid_surface_sink_distance_at_the_end_of_aspiration: - if liquid_surface_sink_distance_at_the_end_of_dispense != 0.0: - surface_following_distance = liquid_surface_sink_distance_at_the_end_of_dispense - warnings.warn( - "The liquid_surface_sink_distance_at_the_end_of_dispense parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard surface_following_distance parameter instead.\n" - "liquid_surface_sink_distance_at_the_end_of_dispense currently superseding surface_following_distance.", - DeprecationWarning, - ) - - if tube_2nd_section_ratio != 3425: - second_section_ratio = tube_2nd_section_ratio - warnings.warn( - "The tube_2nd_section_ratio parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard second_section_ratio parameter instead.\n" - "second_section_ratio currently superseding tube_2nd_section_ratio.", - DeprecationWarning, - ) - - if maximum_immersion_depth != 3425: - minimum_height = maximum_immersion_depth - warnings.warn( - "The maximum_immersion_depth parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard minimum_height parameter instead.\n" - "minimum_height currently superseding maximum_immersion_depth.", - DeprecationWarning, - ) - - if liquid_surface_at_function_without_lld != 3425: - liquid_surface_no_lld = liquid_surface_at_function_without_lld - warnings.warn( - "The liquid_surface_at_function_without_lld parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard liquid_surface_no_lld parameter instead.\n" - "liquid_surface_at_function_without_lld currently superseding liquid_surface_no_lld.", - DeprecationWarning, - ) - - if minimal_end_height != 3425: - min_z_endpos = minimal_end_height - warnings.warn( - "The minimal_end_height parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard min_z_endpos parameter instead.\n" - "minimal_end_height currently superseding min_z_endpos.", - DeprecationWarning, - ) - - if mixing_position_from_liquid_surface != 250: - mix_position_from_liquid_surface = mixing_position_from_liquid_surface - warnings.warn( - "The mixing_position_from_liquid_surface parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard mix_position_from_liquid_surface parameter instead.\n" - "mixing_position_from_liquid_surface currently superseding mix_position_from_liquid_surface.", - DeprecationWarning, - ) - - if surface_following_distance_during_mixing != 0: - mix_surface_following_distance = surface_following_distance_during_mixing - warnings.warn( - "The surface_following_distance_during_mixing parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard mix_surface_following_distance parameter instead.\n" - "mix_surface_following_distance currently superseding surface_following_distance_during_mixing.", - DeprecationWarning, - ) - - if pull_out_distance_to_take_transport_air_in_function_without_lld != 50: - pull_out_distance_transport_air = ( - pull_out_distance_to_take_transport_air_in_function_without_lld - ) - warnings.warn( - "The pull_out_distance_to_take_transport_air_in_function_without_lld parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard pull_out_distance_transport_air parameter instead.\n" - "pull_out_distance_to_take_transport_air_in_function_without_lld currently superseding pull_out_distance_transport_air.", - DeprecationWarning, - ) - - if tube_2nd_section_height_measured_from_zm != 0: - second_section_height = tube_2nd_section_height_measured_from_zm - warnings.warn( - "The tube_2nd_section_height_measured_from_zm parameter is deprecated and will be removed in the future. " - "Use the Hamilton-standard second_section_height parameter instead.\n" - "tube_2nd_section_height_measured_from_zm currently superseding second_section_height.", - DeprecationWarning, - ) - # # # delete # # # - - assert 0 <= dispensing_mode <= 4, "dispensing_mode must be between 0 and 4" - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" - assert 1080 <= y_position <= 5600, "y_position must be between 1080 and 5600" - assert 0 <= minimum_height <= 3425, "minimum_height must be between 0 and 3425" - assert 0 <= second_section_height <= 3425, "second_section_height must be between 0 and 3425" - assert 0 <= second_section_ratio <= 10000, "second_section_ratio must be between 0 and 10000" - assert 0 <= lld_search_height <= 3425, "lld_search_height must be between 0 and 3425" - assert 0 <= liquid_surface_no_lld <= 3425, "liquid_surface_no_lld must be between 0 and 3425" - assert 0 <= pull_out_distance_transport_air <= 3425, ( - "pull_out_distance_transport_air must be between 0 and 3425" - ) - assert 0 <= immersion_depth <= 3600, "immersion_depth must be between 0 and 3600" - assert 0 <= immersion_depth_direction <= 1, "immersion_depth_direction must be between 0 and 1" - assert 0 <= surface_following_distance <= 990, ( - "surface_following_distance must be between 0 and 990" - ) - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3425, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3425" - ) - assert 0 <= min_z_endpos <= 3425, "min_z_endpos must be between 0 and 3425" - assert 0 <= dispense_volume <= 11500, "dispense_volume must be between 0 and 11500" - assert 3 <= dispense_speed <= 5000, "dispense_speed must be between 3 and 5000" - assert 3 <= cut_off_speed <= 5000, "cut_off_speed must be between 3 and 5000" - assert 0 <= stop_back_volume <= 999, "stop_back_volume must be between 0 and 999" - assert 0 <= transport_air_volume <= 500, "transport_air_volume must be between 0 and 500" - assert 0 <= blow_out_air_volume <= 11500, "blow_out_air_volume must be between 0 and 11500" - assert 0 <= lld_mode <= 4, "lld_mode must be between 0 and 4" - assert 1 <= gamma_lld_sensitivity <= 4, "gamma_lld_sensitivity must be between 1 and 4" - assert 0 <= side_touch_off_distance <= 45, "side_touch_off_distance must be between 0 and 45" - assert 3 <= swap_speed <= 1000, "swap_speed must be between 3 and 1000" - assert 0 <= settling_time <= 99, "settling_time must be between 0 and 99" - assert 0 <= mixing_volume <= 11500, "mixing_volume must be between 0 and 11500" - assert 0 <= mixing_cycles <= 99, "mixing_cycles must be between 0 and 99" - assert 0 <= mix_position_from_liquid_surface <= 990, ( - "mix_position_from_liquid_surface must be between 0 and 990" - ) - assert 0 <= mix_surface_following_distance <= 990, ( - "mix_surface_following_distance must be between 0 and 990" - ) - assert 3 <= speed_of_mixing <= 5000, "speed_of_mixing must be between 3 and 5000" - assert 0 <= limit_curve_index <= 999, "limit_curve_index must be between 0 and 999" - assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" - - # Convert bool list to hex string - assert len(channel_pattern) == 96, "channel_pattern must be a list of 96 boolean values" - channel_pattern_bin_str = reversed(["1" if x else "0" for x in channel_pattern]) - channel_pattern_hex = hex(int("".join(channel_pattern_bin_str), 2)).upper()[2:] - - return await self.send_command( - module="C0", - command="ED", - read_timeout=max(300, self.read_timeout), - da=dispensing_mode, - xs=f"{x_position:05}", - xd=x_direction, - yh=f"{y_position:04}", - zm=f"{minimum_height:04}", - zv=f"{second_section_height:04}", - zq=f"{second_section_ratio:05}", - lz=f"{lld_search_height:04}", - zt=f"{liquid_surface_no_lld:04}", - pp=f"{pull_out_distance_transport_air:04}", - iw=f"{immersion_depth:03}", - ix=immersion_depth_direction, - fh=f"{surface_following_distance:03}", - zh=f"{minimum_traverse_height_at_beginning_of_a_command:04}", - ze=f"{min_z_endpos:04}", - df=f"{dispense_volume:05}", - dg=f"{dispense_speed:04}", - es=f"{cut_off_speed:04}", - ev=f"{stop_back_volume:03}", - vt=f"{transport_air_volume:03}", - bv=f"{blow_out_air_volume:05}", - cm=lld_mode, - cs=gamma_lld_sensitivity, - ej=f"{side_touch_off_distance:02}", - bs=f"{swap_speed:04}", - wh=f"{settling_time:02}", - hv=f"{mixing_volume:05}", - hc=f"{mixing_cycles:02}", - hp=f"{mix_position_from_liquid_surface:03}", - mj=f"{mix_surface_following_distance:03}", - hs=f"{speed_of_mixing:04}", - cw=channel_pattern_hex, - cr=f"{limit_curve_index:03}", - cj=tadm_algorithm, - cx=recording_mode, - ) - - # -------------- 3.10.4 Adjustment & movement commands -------------- - - @_requires_head96 - async def move_core_96_head_to_defined_position( - self, - x: float, - y: float, - z: float = 342.5, - minimum_height_at_beginning_of_a_command: float = 342.5, - ): - """Move CoRe 96 Head to defined position - - Args: - x: X-Position [1mm] of well A1. Must be between -300.0 and 300.0. Default 0. - y: Y-Position [1mm]. Must be between 108.0 and 560.0. Default 0. - z: Z-Position [1mm]. Must be between 0 and 560.0. Default 0. - minimum_height_at_beginning_of_a_command: Minimum height at beginning of a command [1mm] - (refers to all channels independent of tip pattern parameter 'tm'). Must be between 0 and - 342.5. Default 342.5. - """ - - warnings.warn( # TODO: remove 2025-02 - "`move_core_96_head_to_defined_position` is deprecated and will be " - "removed in 2025-02. Use `head96_move_to_coordinate` instead.", - DeprecationWarning, - stacklevel=2, - ) - - # TODO: these are values for a STARBackend. Find them for a STARlet. - self._check_96_position_legal(Coordinate(x, y, z)) - assert 0 <= minimum_height_at_beginning_of_a_command <= 342.5, ( - "minimum_height_at_beginning_of_a_command must be between 0 and 342.5" - ) - - return await self.send_command( - module="C0", - command="EM", - xs=f"{abs(round(x * 10)):05}", - xd=0 if x >= 0 else 1, - yh=f"{round(y * 10):04}", - za=f"{round(z * 10):04}", - zh=f"{round(minimum_height_at_beginning_of_a_command * 10):04}", - ) - - @_requires_head96 - async def head96_move_to_coordinate( - self, - coordinate: Coordinate, - minimum_height_at_beginning_of_a_command: float = 342.5, - ): - """Move STAR(let) 96-Head to defined Coordinate - - Args: - coordinate: Coordinate of A1 in mm - - if tip present refers to tip bottom, - - if not present refers to channel bottom - minimum_height_at_beginning_of_a_command: Minimum height at beginning of a command [1mm] - (refers to all channels independent of tip pattern parameter 'tm'). Must be between ? and - 342.5. Default 342.5. - """ - - self._check_96_position_legal(coordinate) - - assert 0 <= minimum_height_at_beginning_of_a_command <= 342.5, ( - "minimum_height_at_beginning_of_a_command must be between 0 and 342.5" - ) - - return await self.send_command( - module="C0", - command="EM", - xs=f"{abs(round(coordinate.x * 10)):05}", - xd="0" if coordinate.x >= 0 else "1", - yh=f"{round(coordinate.y * 10):04}", - za=f"{round(coordinate.z * 10):04}", - zh=f"{round(minimum_height_at_beginning_of_a_command * 10):04}", - ) - - HEAD96_DISPENSING_DRIVE_VOL_LIMIT_BOTTOM = 0 - HEAD96_DISPENSING_DRIVE_VOL_LIMIT_TOP = 1244.59 - - @_requires_head96 - async def head96_dispensing_drive_move_to_position( - self, - position, - speed: float = 261.1, - stop_speed: float = 0, - acceleration: float = 17406.84, - current_protection_limiter: int = 15, - ): - """Move dispensing drive to absolute position in uL - - Args: - position: Position in uL. Between 0, 1244.59. - speed: Speed in uL/s. Between 0.1, 1063.75. - stop_speed: Stop speed in uL/s. Between 0, 1063.75. - acceleration: Acceleration in uL/s^2. Between 96.7, 17406.84. - current_protection_limiter: Current protection limiter (0-15), default 15 - """ - - if not ( - self.HEAD96_DISPENSING_DRIVE_VOL_LIMIT_BOTTOM - <= position - <= self.HEAD96_DISPENSING_DRIVE_VOL_LIMIT_TOP - ): - raise ValueError("position must be between 0 and 1244.59") - if not (0.1 <= speed <= 1063.75): - raise ValueError("speed must be between 0.1 and 1063.75") - if not (0 <= stop_speed <= 1063.75): - raise ValueError("stop_speed must be between 0 and 1063.75") - if not (96.7 <= acceleration <= 17406.84): - raise ValueError("acceleration must be between 96.7 and 17406.84") - if not (0 <= current_protection_limiter <= 15): - raise ValueError("current_protection_limiter must be between 0 and 15") - - position_increments = self._head96_dispensing_drive_uL_to_increment(position) - speed_increments = self._head96_dispensing_drive_uL_to_increment(speed) - stop_speed_increments = self._head96_dispensing_drive_uL_to_increment(stop_speed) - acceleration_increments = self._head96_dispensing_drive_uL_to_increment(acceleration) - - await self.send_command( - module="H0", - command="DQ", - dq=f"{position_increments:05}", - dv=f"{speed_increments:05}", - du=f"{stop_speed_increments:05}", - dr=f"{acceleration_increments:06}", - dw=f"{current_protection_limiter:02}", - ) - - async def move_core_96_head_x(self, x_position: float): - """Move CoRe 96 Head X to absolute position - - .. deprecated:: - Use :meth:`head96_move_x` instead. Will be removed in 2026-06. - """ - warnings.warn( - "`move_core_96_head_x` is deprecated. Use `head96_move_x` instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.head96_move_x(x_position) - - async def move_core_96_head_y(self, y_position: float): - """Move CoRe 96 Head Y to absolute position - - .. deprecated:: - Use :meth:`head96_move_y` instead. Will be removed in 2026-06. - """ - warnings.warn( - "`move_core_96_head_y` is deprecated. Use `head96_move_y` instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.head96_move_y(y_position) - - async def move_core_96_head_z(self, z_position: float): - """Move CoRe 96 Head Z to absolute position - - .. deprecated:: - Use :meth:`head96_move_z` instead. Will be removed in 2026-06. - """ - warnings.warn( - "`move_core_96_head_z` is deprecated. Use `head96_move_z` instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.head96_move_stop_disk_z(z_position) - - async def move_96head_to_coordinate( - self, - coordinate: Coordinate, - minimum_height_at_beginning_of_a_command: float = 342.5, - ): - """Move STAR(let) 96-Head to defined Coordinate - - .. deprecated:: - Use :meth:`head96_move_to_coordinate` instead. Will be removed in 2026-06. - """ - warnings.warn( - "`move_96head_to_coordinate` is deprecated. Use `head96_move_to_coordinate` instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.head96_move_to_coordinate( - coordinate=coordinate, - minimum_height_at_beginning_of_a_command=minimum_height_at_beginning_of_a_command, - ) - - # -------------- 3.10.5 Wash procedure commands using CoRe 96 Head -------------- - - # TODO:(command:EG) Washing tips using CoRe 96 Head - # TODO:(command:EU) Empty washed tips (end of wash procedure only) - - # -------------- 3.10.6 Query CoRe 96 Head -------------- - - async def request_tip_presence_in_core_96_head(self): - """Deprecated - use `head96_request_tip_presence` instead. - - Returns: - dictionary with key qh: - qh: 0 = no tips, 1 = tips are picked up - """ - warnings.warn( # TODO: remove 2026-06 - "`request_tip_presence_in_core_96_head` is deprecated and will be " - "removed in 2026-06 use `head96_request_tip_presence` instead.", - DeprecationWarning, - stacklevel=2, - ) - - return await self.send_command(module="C0", command="QH", fmt="qh#") - - async def head96_request_tip_presence(self) -> int: - """Request Tip presence on the 96-Head - - Note: this command requests this information from the STAR(let)'s - internal memory. - It does not directly sense whether tips are present. - - Returns: - 0 = no tips - 1 = firmware believes tips are on the 96-head - """ - resp = await self.send_command(module="C0", command="QH", fmt="qh#") - - return int(resp["qh"]) - - async def request_position_of_core_96_head(self): - """Deprecated - use `head96_request_position` instead.""" - - warnings.warn( # TODO: remove 2026-02 - "`request_position_of_core_96_head` is deprecated and will be " - "removed in 2026-02 use `head96_request_position` instead.", - DeprecationWarning, - stacklevel=2, - ) - - return await self.head96_request_position() - - async def head96_request_position(self) -> Coordinate: - """Request position of CoRe 96 Head (A1 considered to tip length) - - Returns: - Coordinate: x, y, z in mm - """ - - resp = await self.send_command(module="C0", command="QI", fmt="xs#####xd#yh####za####") - - x_coordinate = resp["xs"] / 10 - y_coordinate = resp["yh"] / 10 - z_coordinate = resp["za"] / 10 - - x_coordinate = x_coordinate if resp["xd"] == 0 else -x_coordinate - - return Coordinate(x=x_coordinate, y=y_coordinate, z=z_coordinate) - - @_requires_head96 - async def head96_request_stop_disk_z(self) -> float: - """Request the 96-head z-drive (stop-disk) position in mm. - - Unlike `head96_request_position` (whose z is the tip bottom when a tip is mounted), this is the - raw z-drive position - the stop disk - regardless of tip state. - - Returns: - Stop-disk Z position in mm. - """ - resp = await self.send_command(module="H0", command="RZ", fmt="rz##### (n)") - return self._head96_z_drive_increment_to_mm(resp["rz"][1]) # [0] = FW counter, [1] = HW counter - - async def head96_request_last_lld_height(self) -> float: - """Request the liquid-surface position the last 96-head cLLD search found, in mm (H0 RH). - - Unlike `head96_request_stop_disk_z` (the head's current position), this is the latched surface - the last `ZL` search detected, so it is unaffected by any post-detection move - the head - counterpart of the channel `request_pip_height_last_lld`. - - Returns: - Detected liquid-surface Z position (stop-disk frame) in mm. - """ - resp = await self.send_command(module="H0", command="RH", fmt="rh#####") - return self._head96_z_drive_increment_to_mm(resp["rh"]) - - async def _head96_probe_z_max(self) -> float: - """Probe the reachable Z top (mm) for this unit: drive to the firmware Z-safety height (C0 EV) - and read the stop disk there. The generic command-range max can exceed what this unit actually - reaches, so the top is read off the hardware. - - Doubles as the firmware Z-safety retract (the EV), so it can stand in for an explicit safe-z - move. Needs no Head96Information, so it is usable before that record exists. - """ - await self.send_command(module="C0", command="EV", read_timeout=20) - return await self.head96_request_stop_disk_z() - - async def request_core_96_head_channel_tadm_status(self): - """Request CoRe 96 Head channel TADM Status - - Returns: - qx: TADM channel status 0 = off 1 = on - """ - - return await self.send_command(module="C0", command="VC", fmt="qx#") - - async def request_core_96_head_channel_tadm_error_status(self): - """Request CoRe 96 Head channel TADM error status - - Returns: - vb: error pattern 0 = no error - """ - - return await self.send_command(module="C0", command="VB", fmt="vb" + "&" * 24) - - async def head96_dispensing_drive_request_position_mm(self) -> float: - """Request 96 Head dispensing drive position in mm""" - resp = await self.send_command(module="H0", command="RD", fmt="rd######") - return self._head96_dispensing_drive_increment_to_mm(resp["rd"]) - - async def head96_dispensing_drive_request_position_uL(self) -> float: - """Request 96 Head dispensing drive position in uL""" - position_mm = await self.head96_dispensing_drive_request_position_mm() - return self._head96_dispensing_drive_mm_to_uL(position_mm) - - # -------------- 3.11 384 Head commands -------------- - - # -------------- 3.11.1 Initialization -------------- - - # -------------- 3.11.2 Tip handling using 384 Head -------------- - - # -------------- 3.11.3 Liquid handling using 384 Head -------------- - - # -------------- 3.11.4 Adjustment & movement commands -------------- - - # -------------- 3.11.5 Wash procedure commands using 384 Head -------------- - - # -------------- 3.11.6 Query 384 Head -------------- - - # -------------- 3.12 Nano pipettor commands -------------- - - # TODO: all nano pipettor commands - - # -------------- 3.12.1 Initialization -------------- - - # TODO:(command:NI) - # TODO:(command:NV) - # TODO:(command:NP) - - # -------------- 3.12.2 Nano pipettor liquid handling commands -------------- - - # TODO:(command:NA) - # TODO:(command:ND) - # TODO:(command:NF) - - # -------------- 3.12.3 Nano pipettor wash & clean commands -------------- - - # TODO:(command:NW) - # TODO:(command:NU) - - # -------------- 3.12.4 Nano pipettor adjustment & movements -------------- - - # TODO:(command:NM) - # TODO:(command:NT) - - # -------------- 3.12.5 Nano pipettor query -------------- - - # TODO:(command:QL) - # TODO:(command:QN) - # TODO:(command:RN) - # TODO:(command:QQ) - # TODO:(command:QR) - # TODO:(command:QO) - # TODO:(command:RR) - # TODO:(command:QU) - - # -------------- 3.13 Autoload commands -------------- - - # -------------- 3.13.1 Initialization -------------- - - async def initialize_auto_load(self): - """Deprecated - use `initialize_autoload` instead.""" - warnings.warn( # TODO: remove 2025-02 - "`initialize_auto_load` is deprecated and will be removed " - "in 2025-02 use `initialize_autoload` instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.initialize_autoload() - - async def initialize_autoload(self): - """Initialize Auto load module""" - - return await self.send_command(module="C0", command="II") - - async def move_auto_load_to_z_save_position(self): - """Deprecated - use `move_autoload_to_safe_z_position` instead.""" - - warnings.warn( # TODO: remove 2025-02 - "`move_auto_load_to_z_save_position` is deprecated and will be " - "removed in 2025-02 use `move_autoload_to_safe_z_position` instead.", - DeprecationWarning, - stacklevel=2, - ) - - return await self.move_autoload_to_safe_z_position() - - async def move_autoload_to_save_z_position(self): - """Deprecated - use `move_autoload_to_safe_z_position` instead.""" - warnings.warn( # TODO: remove 2025-02 - "`move_autoload_to_saVe_z_position` is deprecated and will be " - "removed in 2025-02 use `move_autoload_to_safe_z_position` instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.move_autoload_to_safe_z_position() - - async def move_autoload_to_safe_z_position(self): - """Move autoload carrier handling wheel to safe Z position""" - - return await self.send_command(module="C0", command="IV") - - async def request_auto_load_slot_position(self): - """Deprecated - use `request_autoload_track` instead.""" - warnings.warn( # TODO: remove 2025-02 - "`request_auto_load_slot_position` is deprecated and will be " - "removed in 2025-02 use `request_autoload_track` instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.request_autoload_track() - - async def request_autoload_track(self) -> int: - """Request current track of the autoload 'carrier handler'. - - Returns: - track (0..54) - """ - resp = await self.send_command(module="C0", command="QA", fmt="qa##") - return int(resp["qa"]) - - async def request_autoload_type(self) -> str: - """ - Query the autoload module type. - - This sends the `C0:QA` command, which returns a CQ-format response containing - the autoload identification fields, error/trace information, and the module - type code. The `cq` field specifies the autoload hardware type: - - 0 = ML-STAR with 1D Barcode Scanner - 1 = XRP Lite - 2 = ML-STAR with 2D Barcode Scanner - 3-9 = Reserved / other module variants - - Returns: - int: The autoload module type code (0-9). - """ - - autoload_type_dict = { - 0: "ML-STAR with 1D Barcode Scanner", - 1: "XRP Lite", - 2: "ML-STAR with 2D Barcode Scanner", - } - - resp = await self.send_command(module="C0", command="CQ", fmt="cq#") - resp = autoload_type_dict[resp["cq"]] if resp["cq"] in autoload_type_dict else resp["cq"] - - return str(resp) - - # -------------- 3.13.2 Carrier sensing -------------- - - def _decode_hex_bitmask_to_track_list(self, mask_hex: str) -> list[int]: - """ - Decode a hex occupancy bitmask of arbitrary length. - Each hex nibble = 4 slots. - Slot numbering starts at 1 from the rightmost nibble (LSB). - """ - mask_hex = mask_hex.strip() - - if not all(c in "0123456789abcdefABCDEF" for c in mask_hex): - raise ValueError(f"Invalid hex in mask: {mask_hex!r}") - - slots = [] - bit_index = 1 - - # Rightmost hex digit = slot 1 (LSB) - for nibble in reversed(mask_hex): - val = int(nibble, 16) - for bit in range(4): - if val & (1 << bit): - slots.append(bit_index) - bit_index += 1 - - return sorted(slots) - - async def request_presence_of_carriers_on_deck(self) -> list[int]: - """ - Read the deck carrier presence sensors and return the positions where carriers - are currently detected. - - This sends the `C0:RC` command to query the rear deck sensors. No autoload - movement is performed. The returned hex bitmask is decoded into a list of - track numbers (1-54), where each number corresponds to a deck rail position - that is occupied by a carrier. - - Returns: - list[int]: Sorted list of deck rail positions where carriers are present. - """ - resp = await self.send_command(module="C0", command="RC") - - ce_resp = resp.split("ce")[-1] - - return self._decode_hex_bitmask_to_track_list(ce_resp) - - async def request_presence_of_carriers_on_loading_tray(self) -> list[int]: - """ - Moves autoload sled across loading tray and reads its front-facing proximity sensors - to determine which tray positions contain carriers. - - This sends the `C0:CS` command, which provides a hex-encoded presence bitmask - for the loading tray. The bitmask is decoded into a list of track numbers (1-54) - representing tray positions that currently contain a carrier. - - Returns: - list[int]: Sorted list of loading-tray positions where carriers are present. - - Raises: - ValueError: If the response is missing the expected 'cd' field. - """ - resp = await self.send_command(module="C0", command="CS") - - if "cd" not in resp: - raise ValueError(f"CD field missing: {resp!r}") - - mask_hex = resp.split("cd", 1)[1].strip() - - return self._decode_hex_bitmask_to_track_list(mask_hex) - - async def request_presence_of_single_carrier_on_loading_tray(self, track: int) -> bool: - """ - Check whether a specific loading-tray track contains a carrier. - - This sends the `C0:CT` command, which instructs the autoload sled to move to - the specified tray track and read its front-facing proximity sensor. Unlike - `request_presence_of_carriers_on_loading_tray`, which scans all tray - positions and returns a bitmask, this method queries only a single track and - returns a boolean result. - - Args: - track (int): The loading-tray track number to query (1-54). - - Returns: - bool: True if a carrier is detected at the given track; False otherwise. - - Raises: - AssertionError: If `track` is outside the valid range (1-54). - """ - - assert 1 <= track <= 54, "track must be between 1 and 54" - - track_str = str(track).zfill(2) - - resp = await self.send_command( - module="C0", - command="CT", - fmt="ct#", - cp=track_str, - ) - assert resp is not None - - return int(resp["ct"]) == 1 - - async def request_single_carrier_presence(self, carrier_position: int): - """Request single carrier presence on the loading tray (not on deck)""" - warnings.warn( # TODO: remove 2025-02 - "`request_single_carrier_presence` is deprecated and will be " - "removed in 2025-02 use `is_carrier_present_on_loading_tray` instead.", - DeprecationWarning, - stacklevel=2, - ) - await self.request_presence_of_single_carrier_on_loading_tray(carrier_position) - - # -------------- 3.13.3 Autoload movement commands -------------- - - def _compute_end_rail_of_carrier(self, carrier: Carrier, track_width: float = 22.5) -> int: - """Compute end rail of carrier based on its location on the deck.""" - - carrier_width = carrier.get_location_wrt(self.deck).x - 100 + carrier.get_absolute_size_x() - carrier_end_rail = int(carrier_width / track_width) - - assert 1 <= carrier_end_rail <= 54, "carrier loading rail must be between 1 and 54" - - return carrier_end_rail - - async def move_autoload_to_slot(self, slot_number: int): - """deprecated - use `move_autoload_to_track` instead.""" - - warnings.warn( # TODO: remove 2025-02 - "`move_autoload_to_slot` is deprecated and will be " - "removed in 2025-02 use `move_autoload_to_track` instead.", - DeprecationWarning, - stacklevel=2, - ) - - return await self.move_autoload_to_track(track=slot_number) - - async def move_autoload_to_track(self, track: int): - """Move autoload to specific slot/track position""" - - assert 1 <= track <= 54, "track must be between 1 and 54" - - await self.move_autoload_to_safe_z_position() - - track_no_as_safe_str = str(track).zfill(2) - return await self.send_command(module="I0", command="XP", xp=track_no_as_safe_str) - - async def park_autoload(self): - """Park autoload""" - - # Identify max number of x positions for your liquid handler - max_x_pos = str(self.extended_conf.instrument_size_slots).zfill(2) - - await self.move_autoload_to_safe_z_position() - - # Park autoload to max x position available - return await self.send_command(module="I0", command="XP", xp=max_x_pos) - - async def take_carrier_out_to_autoload_belt(self, carrier: Carrier): - """Take carrier out to identification position for barcode reading. - Start: carrier is already on the deck - """ - - # Identify carrier end rail - carrier_end_rail = self._compute_end_rail_of_carrier(carrier) - - carrier_on_loading_tray = await self.request_single_carrier_presence(carrier_end_rail) - - if not carrier_on_loading_tray: - try: - await self.send_command( - module="C0", - command="CN", - cp=str(carrier_end_rail).zfill(2), - ) - except Exception as e: - await self.move_autoload_to_safe_z_position() - raise RuntimeError( - f"Failed to take carrier at rail {carrier_end_rail} out to autoload belt: {e}" - ) - else: - raise ValueError(f"Carrier is already on the loading tray at position {carrier_end_rail}.") - - # -------------- 3.13.4 Autoload barcode reading commands -------------- - - # 1D barcode symbology bitmask - # Each symbology corresponds to exactly one bit in the 8-bit barcode type field. - # Bit definitions from spec: - # Bit 0 = ISBT Standard - # Bit 1 = Code 128 (Subset B and C) - # Bit 2 = Code 39 - # Bit 3 = Codabar - # Bit 4 = Code 2of5 Interleaved - # Bit 5 = UPC A/E - # Bit 6 = YESN/EAN 8 - # Bit 7 = (unused / undocumented) - - barcode_1d_symbology_dict: dict[Barcode1DSymbology, str] = { - "ISBT Standard": "01", # bit 0 → 0b00000001 → 0x01 → 1 - "Code 128 (Subset B and C)": "02", # bit 1 → 0b00000010 → 0x02 → 2 - "Code 39": "04", # bit 2 → 0b00000100 → 0x04 → 4 - "Codebar": "08", # bit 3 → 0b00001000 → 0x08 → 8 - "Code 2of5 Interleaved": "10", # bit 4 → 0b00010000 → 0x10 → 16 - "UPC A/E": "20", # bit 5 → 0b00100000 → 0x20 → 32 - "YESN/EAN 8": "40", # bit 6 → 0b01000000 → 0x40 → 64 - # Bit 7 → 0b10000000 → 0x80 → 128 (not documented, so omitted) - "ANY 1D": "7F", # bits 0-6 → 0b01111111 → 0x7F → 127 - } - - async def set_1d_barcode_type( - self, - barcode_symbology: Optional[Barcode1DSymbology], - ) -> None: - """Set 1D barcode type for autoload barcode reading.""" - - # If none given, use the default - if barcode_symbology is None: - barcode_symbology = self._default_1d_symbology - - # Prove to mypy that barcode_symbology is no longer Optional - assert barcode_symbology is not None - - await self.send_command( - module="C0", - command="CB", - bt=self.barcode_1d_symbology_dict[barcode_symbology], - ) - - self._default_1d_symbology = barcode_symbology - - async def set_barcode_type( - self, - ISBT_Standard: bool = True, - code128: bool = True, - code39: bool = True, - codebar: bool = True, - code2_5: bool = True, - UPC_AE: bool = True, - EAN8: bool = True, - ): - """deprecated - use set_1d_barcode_type instead""" - - warnings.warn( # TODO: remove 2025-02 - "`set_barcode_type` is deprecated and will be " - "removed in 2025-02 use `set_1d_barcode_type` instead.", - DeprecationWarning, - stacklevel=2, - ) - - # Encode values into bit pattern. Last bit is always one. - bt = "" - for t in [ - ISBT_Standard, - code128, - code39, - codebar, - code2_5, - UPC_AE, - EAN8, - True, - ]: - bt += "1" if t else "0" - # Convert bit pattern to hex. - bt_hex = hex(int(bt, base=2)) - return await self.send_command(module="C0", command="CB", bt=bt_hex) - - # TODO:(command:CW) Unload carrier finally - - async def load_carrier_from_tray_and_scan_carrier_barcode( - self, - carrier: Carrier, - carrier_barcode_reading: bool = True, - barcode_symbology: Optional[Barcode1DSymbology] = None, - barcode_position: float = 4.3, # mm - barcode_reading_window_width: float = 38.0, # mm - reading_speed: float = 128.1, # mm/sec - ) -> Optional[Barcode]: - """Load carrier from loading tray and - optionally - scan 1D carrier barcode""" - - if barcode_symbology is None: - barcode_symbology = self._default_1d_symbology - - assert barcode_symbology is not None - - carrier_end_rail = self._compute_end_rail_of_carrier(carrier) - carrier_end_rail_str = str(carrier_end_rail).zfill(2) - - assert 1 <= int(carrier_end_rail_str) <= 54 - assert 0 <= barcode_position <= 470 - assert 0.1 <= barcode_reading_window_width <= 99.9 - assert 1.5 <= reading_speed <= 160.0 - - try: - resp = await self.send_command( - module="C0", - command="CI", - cp=carrier_end_rail_str, - bi=f"{round(barcode_position * 10):04}", - bw=f"{round(barcode_reading_window_width * 10):03}", - co="0960", # Distance between containers (pattern) [0.1 mm] - cv=f"{round(reading_speed * 10):04}", - ) - except Exception as e: - if carrier_barcode_reading: - await self.move_autoload_to_safe_z_position() - raise RuntimeError( - f"Failed to load carrier at rail {carrier_end_rail} and scan barcode: {e}" - ) - else: - pass - - if not carrier_barcode_reading: - return None - - barcode_str = resp.split("bb/")[-1] - - return Barcode(data=barcode_str, symbology=barcode_symbology, position_on_resource="right") - - async def unload_carrier_after_carrier_barcode_scanning(self): - """After scanning the barcode of the carrier currently engaged with - the autoload sled, unload the carrier back to the loading tray. - """ - try: - resp = await self.send_command( - module="C0", - command="CA", - ) - except Exception as e: - await self.move_autoload_to_safe_z_position() - raise RuntimeError(f"Failed to unload carrier after barcode scanning: {e}") - - return resp - - async def set_carrier_monitoring(self, should_monitor: bool = False): - """Set carrier monitoring - - Args: - should_monitor: whether carrier should be monitored. - - Returns: - True if present, False otherwise - """ - - return await self.send_command(module="C0", command="CU", cu=should_monitor) - - async def load_carrier_from_autoload_belt( - self, - barcode_reading: bool = False, - barcode_reading_direction: Literal["horizontal", "vertical"] = "horizontal", - barcode_symbology: Optional[Barcode1DSymbology] = None, - reading_position_of_first_barcode: float = 63.0, # mm - no_container_per_carrier: int = 5, - distance_between_containers: float = 96.0, # mm - width_of_reading_window: float = 38.0, # mm - reading_speed: float = 128.1, # mm/secs - park_autoload_after: bool = True, - ) -> dict[int, Optional[Barcode]]: - """Finishes loading the carrier that is currently engaged with the autoload sled, - i.e. is currently in the identification position. - """ - - assert barcode_reading_direction in ["horizontal", "vertical"] - assert 0 <= reading_position_of_first_barcode <= 470 - assert 0 <= no_container_per_carrier <= 32 - assert 0 <= distance_between_containers <= 470 - assert 0.1 <= width_of_reading_window <= 99.9 - assert 1.5 <= reading_speed <= 160.0 - - barcode_reading_direction_dict = { - "vertical": "0", - "horizontal": "1", - } - - if barcode_symbology is None: - barcode_symbology = self._default_1d_symbology - assert barcode_symbology is not None - - no_container_per_carrier_str = str(no_container_per_carrier).zfill(2) - reading_position_of_first_barcode_str = str( - round(reading_position_of_first_barcode * 10) - ).zfill(4) - distance_between_containers_str = str(round(distance_between_containers * 10)).zfill(4) - width_of_reading_window_str = str(round(width_of_reading_window * 10)).zfill(3) - reading_speed_str = str(round(reading_speed * 10)).zfill(4) - - if not barcode_reading: - barcode_reading_direction = "vertical" # no movement - no_container_per_carrier_str = "00" # no scanning - - else: - # Choose barcode symbology - await self.set_1d_barcode_type(barcode_symbology=barcode_symbology) - - self._default_1d_symbology = barcode_symbology - - try: - resp = await self.send_command( - module="C0", - command="CL", - bd=barcode_reading_direction_dict[barcode_reading_direction], - bp=reading_position_of_first_barcode_str, # Barcode reading position of first barcode [mm] - cn=no_container_per_carrier_str, - co=distance_between_containers_str, # Distance between containers (pattern) [mm] - cf=width_of_reading_window_str, # Width of reading window [mm] - cv=reading_speed_str, # Carrier reading speed [mm/sec]/ - ) - except Exception as e: - await self.move_autoload_to_safe_z_position() - raise RuntimeError(f"Failed to load carrier from autoload belt: {e}") - - if park_autoload_after: - await self.park_autoload() - - assert isinstance(resp, str), f"Response is not a string: {resp!r}" - - barcode_dict: dict[int, Optional[Barcode]] = {} - - if barcode_reading: - resp_list = resp.split("bb/")[-1].split("/") # remove header - - assert len(resp_list) == no_container_per_carrier, ( - f"Number of barcodes read ({len(resp_list)}) does not match " - f"expected number ({no_container_per_carrier})" - ) - for i in range(0, no_container_per_carrier): - if resp_list[i] == "00": - barcode_dict[i] = None - else: - barcode_dict[i] = Barcode( - data=resp_list[i], symbology=barcode_symbology, position_on_resource="right" - ) - - return barcode_dict - - # -------------- 3.13.5 Autoload carrier loading/unloading commands -------------- - - async def load_carrier( - self, - carrier: Carrier, - carrier_barcode_reading: bool = True, - barcode_reading: bool = False, - barcode_reading_direction: Literal["horizontal", "vertical"] = "horizontal", - barcode_symbology: Optional[Barcode1DSymbology] = None, - no_container_per_carrier: int = 5, - reading_position_of_first_barcode: float = 63.0, # mm - distance_between_containers: float = 96.0, # mm - width_of_reading_window: float = 38.0, # mm - reading_speed: float = 128.1, # mm/secs - park_autoload_after: bool = True, - ) -> dict: - """ - Use autoload to load carrier. - - Args: - carrier: Carrier to load - barcode_reading: Whether to read barcodes. Default False. - barcode_reading_direction: Barcode reading direction. Either "vertical" or "horizontal", - default "horizontal". - barcode_symbology: Barcode symbology. Default "Code 128 (Subset B and C)". - no_container_per_carrier: Number of containers per carrier. Default 5. - park_autoload_after: Whether to park autoload after loading. Default True. - """ - - if barcode_symbology is None: - barcode_symbology = self._default_1d_symbology - - # Identify carrier end rail - carrier_end_rail = self._compute_end_rail_of_carrier(carrier) - assert 1 <= int(carrier_end_rail) <= 54, "carrier loading rail must be between 1 and 54" - - # Determine presence of carrier at defined position - presence_check = await self.request_presence_of_single_carrier_on_loading_tray(carrier_end_rail) - - if presence_check != 1: - raise ValueError( - f"""No carrier found at position {carrier_end_rail}, - have you placed the carrier onto the correct autoload tray position?""" - ) - - # Set carrier type for identification purposes - carrier_barcode = await self.load_carrier_from_tray_and_scan_carrier_barcode( - carrier, carrier_barcode_reading=carrier_barcode_reading - ) - - # Load carrier - # with barcoding - if barcode_reading: - # Choose barcode symbology - await self.set_1d_barcode_type(barcode_symbology=barcode_symbology) - self._default_1d_symbology = barcode_symbology - - # Load and read out barcodes # TODO: swap with load_carrier_from_autoload_belt? - resp = await self.load_carrier_from_autoload_belt( - barcode_reading=barcode_reading, - barcode_reading_direction=barcode_reading_direction, - barcode_symbology=barcode_symbology, - reading_position_of_first_barcode=reading_position_of_first_barcode, - no_container_per_carrier=no_container_per_carrier, - distance_between_containers=distance_between_containers, - width_of_reading_window=width_of_reading_window, - reading_speed=reading_speed, - park_autoload_after=False, - ) - else: # without barcoding - resp = await self.load_carrier_from_autoload_belt( - barcode_reading=False, park_autoload_after=False - ) - - if park_autoload_after: - await self.park_autoload() - - # Parse response and create output dict - output = { - "carrier_barcode": carrier_barcode if carrier_barcode_reading else None, - "container_barcodes": resp if barcode_reading else None, - } - - return output - - async def set_loading_indicators(self, bit_pattern: List[bool], blink_pattern: List[bool]): - """Set loading indicators (LEDs) - - The docs here are a little weird because 2^54 < 7FFFFFFFFFFFFF. - - Args: - bit_pattern: On if True, off otherwise - blink_pattern: Blinking if True, steady otherwise - """ - - assert len(bit_pattern) == 54, "bit pattern must be length 54" - assert len(blink_pattern) == 54, "bit pattern must be length 54" - - def pattern2hex(pattern: List[bool]) -> str: - bit_string = "".join(["1" if x else "0" for x in pattern]) - return hex(int(bit_string, base=2))[2:].upper().zfill(14) - - bit_pattern_hex = pattern2hex(bit_pattern) - blink_pattern_hex = pattern2hex(blink_pattern) - - return await self.send_command( - module="C0", - command="CP", - cl=bit_pattern_hex, - cb=blink_pattern_hex, - ) - - async def verify_and_wait_for_carriers( - self, - check_interval: float = 1.0, - ): - """Verify that carriers have been loaded at expected rail positions. - - This function checks if carriers are physically present on the deck at the specified - rail positions using the deck's presence sensors. If any carriers are missing, it will: - 1. Prompt the user to load the missing carriers - 2. Flash LEDs at the missing positions using set_loading_indicators - 3. Continue checking until all carriers are detected - - Args: - check_interval: Interval in seconds between presence checks (default: 1.0) - - Raises: - ValueError: If no carriers are found on the deck. - """ - # Extract carriers from deck children with start and end rail positions - carrier_rails: List[Tuple[int, int]] = [] # List of (start_rail, end_rail) tuples - - for child in self.deck.children: - if isinstance(child, Carrier): - # Get x coordinate relative to deck - carrier_x = child.get_location_wrt(self.deck).x - carrier_start_rail = rails_for_x_coordinate(carrier_x) - carrier_end_rail = rails_for_x_coordinate(carrier_x - 100.0 + child.get_absolute_size_x()) - - # Verify rails are valid - carrier_start_rail = max(1, min(carrier_start_rail, 54)) - if 1 <= carrier_end_rail <= 54: - carrier_rails.append((carrier_start_rail, carrier_end_rail)) - - if len(carrier_rails) == 0: - raise ValueError("No carriers found on deck. Assign carriers to the deck.") - - # Extract end rails for comparison with detected rails - # The presence detection reports the end rail position - expected_end_rails = [end_rail for _, end_rail in carrier_rails] - - # Check initial presence - detected_rails = set(await self.request_presence_of_carriers_on_deck()) - missing_end_rails = sorted(set(expected_end_rails) - detected_rails) - - if len(missing_end_rails) == 0: - logger.info(f"All carriers detected at end rail positions: {expected_end_rails}") - # Turn off all indicators - await self.set_loading_indicators( - bit_pattern=[False] * 54, - blink_pattern=[False] * 54, - ) - print(f"\n✓ All carriers successfully detected at end rail positions: {expected_end_rails}\n") - return - - # Prompt user about missing carriers - print( - f"\n{'=' * 60}\n" - f"CARRIER LOADING REQUIRED\n" - f"{'=' * 60}\n" - f"Expected carriers at end rail positions: {expected_end_rails}\n" - f"Detected carriers at rail positions: {sorted(detected_rails)}\n" - f"Missing carriers at end rail positions: {missing_end_rails}\n" - f"{'=' * 60}\n" - f"Please load the missing carriers. LEDs will flash at the carrier positions.\n" - f"The system will automatically detect when all carriers are loaded.\n" - f"{'=' * 60}\n" - ) - - # Flash LEDs until all carriers are detected - while missing_end_rails: - # Create bit pattern for missing carriers - # Flash all LEDs from start_rail to end_rail (inclusive) for each missing carrier - bit_pattern = [False] * 54 - blink_pattern = [False] * 54 - - # For each missing carrier (identified by missing end rail), flash all its rails - for missing_end_rail in missing_end_rails: - # Find the carrier with this end rail - for start_rail, end_rail in carrier_rails: - if end_rail == missing_end_rail: - # Flash all LEDs from start_rail to end_rail (inclusive) - for rail in range(start_rail, end_rail + 1): - if 1 <= rail <= 54: - indicator_index = rail - 1 # Convert rail (1-54) to index (0-53) - bit_pattern[indicator_index] = True - blink_pattern[indicator_index] = True - break - - # Set loading indicators - await self.set_loading_indicators(bit_pattern[::-1], blink_pattern[::-1]) - - # Wait before checking again - await asyncio.sleep(check_interval) - - # Check for presence again - detected_rails = set(await self.request_presence_of_carriers_on_deck()) - missing_end_rails = sorted(set(expected_end_rails) - detected_rails) - - # All carriers detected, turn off all indicators - logger.info(f"All carriers successfully detected at end rail positions: {expected_end_rails}") - await self.set_loading_indicators( - bit_pattern=[False] * 54, - blink_pattern=[False] * 54, - ) - print("\n✓ All carriers successfully loaded and detected!\n") - - async def unload_carrier( - self, - carrier: Carrier, - park_autoload_after: bool = True, - ): - """Use autoload to unload carrier.""" - # Identify carrier end rail - track_width = 22.5 - carrier_width = carrier.get_location_wrt(self.deck).x - 100 + carrier.get_absolute_size_x() - carrier_end_rail = int(carrier_width / track_width) - - assert 1 <= carrier_end_rail <= 54, "carrier loading rail must be between 1 and 54" - - carrier_end_rail_str = str(carrier_end_rail).zfill(2) - - # Unload - resp = await self.send_command( - module="C0", - command="CR", - cp=carrier_end_rail_str, - ) - - if park_autoload_after: - await self.park_autoload() - - return resp - - # -------------- 3.14 G1-3/ CR Needle Washer commands -------------- - - # TODO: All needle washer commands - - # TODO:(command:WI) - # TODO:(command:WI) - # TODO:(command:WS) - # TODO:(command:WW) - # TODO:(command:WR) - # TODO:(command:WC) - # TODO:(command:QF) - - # -------------- 3.15 Pump unit commands -------------- - - async def request_pump_settings(self, pump_station: int = 1): - """Set carrier monitoring - - Args: - carrier_position: pump station number (1..3) - - Returns: - 0 = CoRe 96 wash station (single chamber) - 1 = DC wash station (single chamber rev 02 ) 2 = ReReRe (single chamber) - 3 = CoRe 96 wash station (dual chamber) - 4 = DC wash station (dual chamber) - 5 = ReReRe (dual chamber) - """ - - assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" - - return await self.send_command(module="C0", command="ET", fmt="et#", ep=pump_station) - - # -------------- 3.15.1 DC Wash commands (only for revision up to 01) -------------- - - # TODO:(command:FA) Start DC wash procedure - # TODO:(command:FB) Stop DC wash procedure - # TODO:(command:FP) Prime DC wash station - - # -------------- 3.15.2 Single chamber pump unit only -------------- - - # TODO:(command:EW) Start circulation (single chamber only) - # TODO:(command:EC) Check circulation (single chamber only) - # TODO:(command:ES) Stop circulation (single chamber only) - # TODO:(command:EF) Prime (single chamber only) - # TODO:(command:EE) Drain & refill (single chamber only) - # TODO:(command:EB) Fill (single chamber only) - # TODO:(command:QE) Request single chamber pump station prime status - - # -------------- 3.15.3 Dual chamber pump unit only -------------- - - async def initialize_dual_pump_station_valves(self, pump_station: int = 1): - """Initialize pump station valves (dual chamber only) - - Args: - carrier_position: pump station number (1..3) - """ - - assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" - - return await self.send_command(module="C0", command="EJ", ep=pump_station) - - async def fill_selected_dual_chamber( - self, - pump_station: int = 1, - drain_before_refill: bool = False, - wash_fluid: int = 1, - chamber: int = 2, - waste_chamber_suck_time_after_sensor_change: int = 0, - ): - """Initialize pump station valves (dual chamber only) - - Args: - carrier_position: pump station number (1..3) - drain_before_refill: drain chamber before refill. Default False. - wash_fluid: wash fluid (1 or 2) - chamber: chamber (1 or 2) - drain_before_refill: waste chamber suck time after sensor change [s] (for error handling only) - """ - - assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" - assert 1 <= wash_fluid <= 2, "wash_fluid must be between 1 and 2" - assert 1 <= chamber <= 2, "chamber must be between 1 and 2" - - # wash fluid <-> chamber connection - # 0 = wash fluid 1 <-> chamber 2 - # 1 = wash fluid 1 <-> chamber 1 - # 2 = wash fluid 2 <-> chamber 1 - # 3 = wash fluid 2 <-> chamber 2 - connection = {(1, 2): 0, (1, 1): 1, (2, 1): 2, (2, 2): 3}[wash_fluid, chamber] - - return await self.send_command( - module="C0", - command="EH", - ep=pump_station, - ed=drain_before_refill, - ek=connection, - eu=f"{waste_chamber_suck_time_after_sensor_change:02}", - wait=False, - ) - - # TODO:(command:EK) Drain selected chamber - - async def drain_dual_chamber_system(self, pump_station: int = 1): - """Drain system (dual chamber only) - - Args: - carrier_position: pump station number (1..3) - """ - - assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" - - return await self.send_command(module="C0", command="EL", ep=pump_station) - - # TODO:(command:QD) Request dual chamber pump station prime status - - # -------------- 3.16 Incubator commands -------------- - - # TODO: all incubator commands - # TODO:(command:HC) - # TODO:(command:HI) - # TODO:(command:HF) - # TODO:(command:RP) - - # -------------- 3.17 iSWAP commands -------------- - - # -------------- 3.17.1 Pre & Initialization commands -------------- - - async def initialize_iswap(self): - """Initialize iSWAP (for standalone configuration only)""" - - return await self.send_command(module="C0", command="FI") - - async def position_components_for_free_iswap_y_range(self): - """Position all components so that there is maximum free Y range for iSWAP""" - - return await self.send_command(module="C0", command="FY") - - async def move_iswap_x_relative(self, step_size: float, allow_splitting: bool = False): - """ - Args: - step_size: X Step size [1mm] Between -99.9 and 99.9 if allow_splitting is False. - allow_splitting: Allow splitting of the movement into multiple steps. Default False. - """ - - direction = 0 if step_size >= 0 else 1 - max_step_size = 99.9 - if abs(step_size) > max_step_size: - if not allow_splitting: - raise ValueError("step_size must be less than 99.9") - await self.move_iswap_x_relative( - step_size=max_step_size if step_size > 0 else -max_step_size, allow_splitting=True - ) - remaining_steps = step_size - max_step_size if step_size > 0 else step_size + max_step_size - return await self.move_iswap_x_relative(remaining_steps, allow_splitting) - - return await self.send_command( - module="C0", command="GX", gx=str(round(abs(step_size) * 10)).zfill(3), xd=direction - ) - - async def move_iswap_y_relative(self, step_size: float, allow_splitting: bool = False): - """ - Args: - step_size: Y Step size [1mm] Between -99.9 and 99.9 if allow_splitting is False. - allow_splitting: Allow splitting of the movement into multiple steps. Default False. - """ - - # check if iswap will hit the first (backmost) channel - # we only need to check for positive step sizes because the iswap is always behind the first channel - if step_size < 0: - y_pos_channel_0 = await self.request_y_pos_channel_n(0) - current_y_pos_iswap = await self.iswap_rotation_drive_request_y() - if current_y_pos_iswap + step_size < y_pos_channel_0: - raise ValueError( - f"iSWAP will hit the first (backmost) channel. Current iSWAP Y position: {current_y_pos_iswap} mm, " - f"first channel Y position: {y_pos_channel_0} mm, requested step size: {step_size} mm" - ) - - direction = 0 if step_size >= 0 else 1 - max_step_size = 99.9 - if abs(step_size) > max_step_size: - if not allow_splitting: - raise ValueError("step_size must be less than 99.9") - await self.move_iswap_y_relative( - step_size=max_step_size if step_size > 0 else -max_step_size, allow_splitting=True - ) - remaining_steps = step_size - max_step_size if step_size > 0 else step_size + max_step_size - return await self.move_iswap_y_relative(remaining_steps, allow_splitting) - - return await self.send_command( - module="C0", command="GY", gy=str(round(abs(step_size) * 10)).zfill(3), yd=direction - ) - - async def move_iswap_z_relative(self, step_size: float, allow_splitting: bool = False): - """ - Args: - step_size: Z Step size [1mm] Between -99.9 and 99.9 if allow_splitting is False. - allow_splitting: Allow splitting of the movement into multiple steps. Default False. - """ - - direction = 0 if step_size >= 0 else 1 - max_step_size = 99.9 - if abs(step_size) > max_step_size: - if not allow_splitting: - raise ValueError("step_size must be less than 99.9") - await self.move_iswap_z_relative( - step_size=max_step_size if step_size > 0 else -max_step_size, allow_splitting=True - ) - remaining_steps = step_size - max_step_size if step_size > 0 else step_size + max_step_size - return await self.move_iswap_z_relative(remaining_steps, allow_splitting) - - return await self.send_command( - module="C0", command="GZ", gz=str(round(abs(step_size) * 10)).zfill(3), zd=direction - ) - - async def move_iswap_x( - self, - x_position: float, # TODO: by convention should be just 'x' in v1 - acceleration_level: int = 3, - current_protection_limiter: int = 7, - ): - """Move the iSWAP gripper center to absolute X position (deck coordinates). - - The X-arm carriage and the gripper translate rigidly together in X, so - the gripper-X delta equals the rotation-drive-X delta. Read the current - gripper X from FK, translate the X-arm by the delta, and the gripper - lands at `x_position` via a single smooth motion plan. - - Args: - x_position [mm]: target gripper X in deck coordinates. - acceleration_level: X-arm acceleration index, 1..5. - current_protection_limiter: motor current limit, 0..7. - - Raises: - RuntimeError: if iSWAP is not installed, or if `setup()` has not - populated `iswap_information`. - ValueError: if the resolved rotation drive X is outside the X-arm - hardware range, or if any arg is outside its valid range. - """ - current_gripper_x = (await self.iswap_request_pose()).location.x - current_rotation_drive_x = await self.iswap_rotation_drive_request_x() - target_rotation_drive_x = current_rotation_drive_x + (x_position - current_gripper_x) - try: - await self.experimental_iswap_rotation_drive_move_x( - x=target_rotation_drive_x, - acceleration_level=acceleration_level, - current_protection_limiter=current_protection_limiter, - ) - except ValueError as e: - raise ValueError( - f"move_iswap_x(x_position={x_position}) resolved to rotation drive " - f"target x={target_rotation_drive_x:.2f}: {e}" - ) from e - - async def move_iswap_y( - self, - y_position: float, # TODO: by convention should be just 'y' in v1 - speed: float = 220.0, - acceleration_level: int = 2, - current_protection_limiter: int = 7, - make_space: bool = False, - ): - """Move the iSWAP gripper center to absolute Y position (deck coordinates). - - The rotation drive Y carriage and the gripper translate rigidly together - in Y, so the gripper-Y delta equals the rotation-drive-Y delta. Read the - current gripper Y from FK, translate the rotation drive by the delta, - and the gripper lands at `y_position` via a single smooth `R0 YA` move. - - Args: - y_position [mm]: target gripper Y in deck coordinates. The achievable - range depends on channel configuration and current arm pose; in - unobstructed conditions the absolute envelope is approximately - -270..+648 mm at factory link lengths. - speed [mm/sec]: max linear velocity, 2.4..370. - acceleration_level: acceleration index, 1 or 2. - current_protection_limiter: motor current limit, 0..7. - make_space: if True, reposition pipetting channels when channel 0 is - in the way and can be cleared. If False, raise so the caller decides. - - Raises: - RuntimeError: if iSWAP is not installed, or if `setup()` has not - populated `iswap_information`. - ValueError: if `y_position` is outside the Y hardware range; if the - target requires channel 0 to be moved and `make_space=False`; if - the move is unreachable even with channel repositioning; or if - `speed`, `acceleration_level`, or `current_protection_limiter` is - outside its valid range. - """ - current_gripper_y = (await self.iswap_request_pose()).location.y - current_rotation_drive_y = await self.iswap_rotation_drive_request_y() - target_rotation_drive_y = current_rotation_drive_y + (y_position - current_gripper_y) - try: - await self.iswap_rotation_drive_move_y( - y=target_rotation_drive_y, - speed=speed, - acceleration_level=acceleration_level, - current_protection_limiter=current_protection_limiter, - make_space=make_space, - ) - except ValueError as e: - raise ValueError( - f"move_iswap_y(y_position={y_position}) resolved to rotation drive " - f"target y={target_rotation_drive_y:.2f}: {e}" - ) from e - - async def move_iswap_z( - self, - z_position: float, # TODO: by convention should be just 'z' in v1 - speed: float = 118.0, - acceleration: float = 643.66, - current_protection_limiter: int = 6, - ): - """Move the iSWAP gripper finger plane to absolute Z position (deck coordinates). - - The Z carriage and the gripper translate rigidly together in Z (with a - fixed 13 mm offset between the rotation-drive-bottom Z and the - finger-plane Z), so the gripper-Z delta equals the rotation-drive-Z - delta. Read the current gripper finger-plane Z from FK, translate the - rotation drive by the delta, and the gripper lands at `z_position` via - a single smooth `R0 ZA` move. - - Args: - z_position [mm]: target gripper finger-plane Z in deck coordinates. - speed [mm/sec]: max linear velocity. - acceleration [mm/sec^2]: max linear acceleration. - current_protection_limiter: motor current limit, 0..7. - - Raises: - RuntimeError: if iSWAP is not installed, or if `setup()` has not - populated `iswap_information`. - ValueError: if the resolved rotation drive Z is outside the Z - hardware range, or if any arg is outside its valid range. - """ - current_gripper_z = (await self.iswap_request_pose()).location.z - current_rotation_drive_z = await self.iswap_rotation_drive_request_z() - target_rotation_drive_z = current_rotation_drive_z + (z_position - current_gripper_z) - try: - await self.iswap_rotation_drive_move_z( - z=target_rotation_drive_z, - speed=speed, - acceleration=acceleration, - current_protection_limiter=current_protection_limiter, - ) - except ValueError as e: - raise ValueError( - f"move_iswap_z(z_position={z_position}) resolved to rotation drive " - f"target z={target_rotation_drive_z:.2f}: {e}" - ) from e - - # ----------------------------------------------------------------------- - # iSWAP: SCARA Geometry - # ----------------------------------------------------------------------- - - async def iswap_request_link_1_length(self) -> float: - """Read iSWAP link 1 length (rotation joint -> wrist joint) in mm. - - Default factory value 138.0 mm. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="R0", command="RA", ra="pw", fmt="pw##### (n)") - pw = cast(List[int], resp["pw"]) - return round(pw[9] / 10, 1) - - async def iswap_request_link_2_length(self) -> float: - """Read iSWAP link 2 length (wrist joint -> gripper finger center) in mm. - - Default factory value 138.0 mm. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="R0", command="RA", ra="pt", fmt="pt##### (n)") - pt = cast(List[int], resp["pt"]) - return round(pt[9] / 10, 1) - - # ======================================================================= - # TODO: remove in v1 - iSWAP drive constants superseded by `iSWAPInformation`. - # - # Every value below now lives canonically in `iSWAPInformation` (per-drive - # area-of-operation ranges + encoder resolutions); all internal usage reads - # from there. Kept only for backward compatibility with external code and as - # an audit anchor for the prior literals. - # Ordered to match iSWAPInformation: Y, Z, rotation drive, wrist drive, - # gripper (no X-axis constant is vestigial). - # ======================================================================= - # Y carriage (commanded via the rotation drive) - iswap_y_drive_mm_per_increment = 0.046302083 - iswap_rotation_drive_y_speed_increment_range = (50, 8_000) - - # Z carriage (increments in finger-plane coords, the R0 ZA reference) - iswap_rotation_drive_z_min_increment = -187 - iswap_rotation_drive_z_max_increment = 26_661 - iswap_z_drive_mm_per_increment = 0.01072765 - iswap_rotation_drive_z_speed_increment_range = (50, 15_000) - iswap_rotation_drive_z_acceleration_increment_range = (5, 999) - - # rotation drive (W) - iswap_rotation_drive_min_increment = -30032 # ~ -93 deg - iswap_rotation_drive_max_increment = 30032 # ~ +93 deg - iswap_rotation_drive_deg_per_increment = 0.00309619077 - - # wrist drive (T) - iswap_wrist_drive_min_increment = -30000 # ~ -152 deg - iswap_wrist_drive_max_increment = 30000 # ~ +152 deg - iswap_wrist_drive_deg_per_increment = 0.00507968798 - - # gripper (G) - iswap_gripper_drive_min_increment = 12780 # ~ 71 mm - iswap_gripper_drive_max_increment = 24120 # ~ 134 mm - iswap_gripper_drive_mm_per_increment = 0.00554337 - # ======================================================================= - - # ----------------------------------------------------------------------- - # iSWAP: "Rotation Drive" (Joint 1) - # ----------------------------------------------------------------------- - - iswap_rotation_drive_diameter = 30.5 - iswap_rotation_drive_safety_radius = 90.0 - - @staticmethod - def iswap_y_drive_mm_to_increment( - value_mm: float, mm_per_increment: float = iSWAPInformation.y_mm_per_increment - ) -> int: - return round(value_mm / mm_per_increment) - - @staticmethod - def iswap_y_drive_increment_to_mm( - value_increments: int, mm_per_increment: float = iSWAPInformation.y_mm_per_increment - ) -> float: - return round(value_increments * mm_per_increment, 2) - - @staticmethod - def iswap_z_drive_mm_to_increment( - value_mm: float, mm_per_increment: float = iSWAPInformation.z_mm_per_increment - ) -> int: - return round(value_mm / mm_per_increment) - - @staticmethod - def iswap_z_drive_increment_to_mm( - value_increments: int, mm_per_increment: float = iSWAPInformation.z_mm_per_increment - ) -> float: - return round(value_increments * mm_per_increment, 2) - - class RotationDriveOrientation(enum.Enum): - LEFT = 1 - FRONT = 2 - RIGHT = 3 - PARKED_RIGHT = None - - async def _iswap_rotation_drive_request_x_offset(self) -> float: - """Read the X-offset i.e. X-axis center <-> iSWAP rotation drive, in mm. - - Stored in the master EEPROM as parameter `kg`. - Default: 34.0 mm, but typically tuned per machine during service calibration. - Required for deriving the iSWAP rotation drive's deck X coordinate from - the X-arm carriage center. - Cached on the backend as `iswap_information.rotation_drive_x_offset` during setup. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="C0", command="RA", ra="kg", fmt="kg###") - return cast(int, resp["kg"]) / 10.0 - - async def _iswap_rotation_drive_request_y_max(self) -> float: - """Read the iSWAP Y-axis upper bound (parking pose) from EEPROM, in mm. - - Parking sits at the back of the usable Y travel; anything past it is in - the mechanical-stop region. - """ - py = await self.iswap_rotation_drive_request_predefined_y_positions() - return py["parking"] - - async def iswap_rotation_drive_request_x(self) -> float: - """Request iSWAP rotation drive X position (deck coordinates), in mm. - - Computed as `request_left_x_arm_position() - kg` (cached at setup). - """ - x_arm_center = await self.request_left_x_arm_position() - return x_arm_center - self.iswap_information.rotation_drive_x_offset - - async def iswap_rotation_drive_request_y(self) -> float: - """Request iSWAP rotation drive Y position (deck coordinates), in mm. - - Reads the linear Y carriage that the rotation joint is mounted on. This is - NOT the gripper finger's Y - the finger position depends on the rotation - drive (W) and wrist (T) angles. Use `iswap_rotation_drive_request_position` - for the rotation drive's full XYZ. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="R0", command="RY", fmt="ry##### (n)") - iswap_y_pos = resp["ry"][1] # 0 = FW counter, 1 = HW counter - return round( - STARBackend.iswap_y_drive_increment_to_mm( - iswap_y_pos, self.iswap_information.y_mm_per_increment - ), - 1, - ) - - # Vertical offset between the rotation drive's bottom (its lowest physical - # point) and the gripper finger plane. R0 RZ is calibrated to the finger - # plane; the rotation drive's bottom sits 13 mm above it. - iswap_rotation_drive_z_offset_above_finger_mm = 13.0 - - async def iswap_rotation_drive_request_z(self) -> float: - """Request iSWAP rotation-drive-bottom Z (deck coordinates), in mm. - - Returns the Z of the rotation drive's lowest physical point, which sits - `iswap_rotation_drive_z_offset_above_finger_mm` above the gripper finger - plane that R0 RZ reports. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="R0", command="RZ", fmt="rz##### (n)") - iswap_z_pos_increments = resp["rz"][1] # 0 = FW counter, 1 = HW counter - finger_plane_z = STARBackend.iswap_z_drive_increment_to_mm( - iswap_z_pos_increments, self.iswap_information.z_mm_per_increment - ) - return round(finger_plane_z + STARBackend.iswap_rotation_drive_z_offset_above_finger_mm, 1) - - async def iswap_rotation_drive_request_position(self) -> Coordinate: - """Position of the iSWAP rotation drive (joint 1) in deck coordinates, mm.""" - return Coordinate( - x=await self.iswap_rotation_drive_request_x(), - y=await self.iswap_rotation_drive_request_y(), - z=await self.iswap_rotation_drive_request_z(), - ) - - async def experimental_iswap_rotation_drive_move_x( - self, - x: float, - acceleration_level: int = 3, - current_protection_limiter: int = 7, - ): - """Move the iSWAP rotation drive to an absolute X position (deck coordinates). - - Thin wrapper around `x_arm_move` that translates rotation-drive X into - X-arm carriage X using the cached `kg` offset. - - Args: - x: Target rotation-drive X coordinate in mm. - acceleration_level: Acceleration index (hardware units), 1-5. Default 3. - current_protection_limiter: Motor current limit (hardware units), 0-7. Default 7. - """ - # TODO: remove "experimental_" prefix once x_arm_move has been optimised - - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - kg = self.iswap_information.rotation_drive_x_offset - - x_min = 90.0 - kg - x_max = 1350.0 - kg - if not (x_min <= x <= x_max): - raise ValueError(f"x must be between {x_min} and {x_max} mm, is {x}") - - return await self.experimental_x_arm_move( - x=x + kg, - acceleration_level=acceleration_level, - current_protection_limiter=current_protection_limiter, - ) - - async def iswap_rotation_drive_move_y( - self, - y: float, - speed: float = 220.0, - acceleration_level: int = 2, - current_protection_limiter: int = 7, - make_space: bool = False, - ): - """Move the iSWAP rotation drive to an absolute Y position. - - To stay clear of channel 0 regardless of the current W-axis angle, the - iSWAP envelope is treated as a circle of radius - `iswap_rotation_drive_diameter / 2 + iswap_rotation_drive_safety_radius`. - The safety radius bounds the link-1 and protrusion sweep across all - rotation poses. - - Args: - y: Target Y coordinate in mm. - speed: Max velocity in mm/sec. Default 220.0. - acceleration_level: Acceleration index, 1 or 2. Default 2. - current_protection_limiter: Motor current limit, 0-7. Default 7. - make_space: If True, reposition pipetting channels in a single - synchronous JY move when channel 0 is in the way and can be cleared. - If False, raise so the caller decides. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - - iswap_radius = ( - STARBackend.iswap_rotation_drive_diameter / 2 + STARBackend.iswap_rotation_drive_safety_radius - ) - channel_0_radius = self._channels_minimum_y_spacing[0] / 2 - channel_0_y = await self.request_y_pos_channel_n(0) - - compressed_channel_0_y = self.extended_conf.left_arm_min_y_position + sum( - self._channels_minimum_y_spacing[1:] - ) - - max_y = self.iswap_information.rotation_drive_y_max - absolute_min_y = self.extended_conf.left_arm_min_y_position - if not (absolute_min_y <= y <= max_y): - raise ValueError(f"y must be between {absolute_min_y} and {max_y} mm, is {y}") - - target_channel_0_y = y - channel_0_radius - iswap_radius - if channel_0_y > target_channel_0_y: - if target_channel_0_y < compressed_channel_0_y: - raise ValueError( - f"y={y} mm is unreachable: would require channel 0 at " - f"{target_channel_0_y} mm, below the compressed floor " - f"{compressed_channel_0_y} mm" - ) - if not make_space: - raise ValueError( - f"y={y} mm requires channel 0 at <= {target_channel_0_y} mm " - f"(currently {channel_0_y} mm); pass make_space=True to " - f"reposition channels" - ) - await self.move_all_channels_in_z_safety() - await self.position_channels_in_y_direction({0: target_channel_0_y}, make_space=True) - - y_mm_per_increment = self.iswap_information.y_mm_per_increment - speed_increments = STARBackend.iswap_y_drive_mm_to_increment(speed, y_mm_per_increment) - speed_min, speed_max = self.iswap_information.y_speed_increment_range - if not (speed_min <= speed_increments <= speed_max): - raise ValueError( - f"speed must be between " - f"{STARBackend.iswap_y_drive_increment_to_mm(speed_min, y_mm_per_increment)} and " - f"{STARBackend.iswap_y_drive_increment_to_mm(speed_max, y_mm_per_increment)} mm/sec, " - f"got {speed} mm/sec" - ) - - if not (1 <= acceleration_level <= 2): - raise ValueError(f"acceleration_level must be between 1 and 2, got {acceleration_level}") - - if not (0 <= current_protection_limiter <= 7): - raise ValueError( - f"current_protection_limiter must be between 0 and 7, got {current_protection_limiter}" - ) - - await self.send_command( - module="R0", - command="YA", - ya=f"{round(STARBackend.iswap_y_drive_mm_to_increment(y, y_mm_per_increment)):05}", - yv=f"{round(speed_increments):04}", - yr=f"{int(acceleration_level)}", - yw=f"{int(current_protection_limiter)}", - ) - - async def iswap_rotation_drive_request_predefined_y_positions( - self, y_mm_per_increment: float = iSWAPInformation.y_mm_per_increment - ) -> Dict[str, float]: - """Read iSWAP rotation-drive Y predefined-position table from EEPROM, in mm. - - Sends R0 RA ra=py. Firmware returns 10 signed-integer slots; all 10 are - positions (no length slot, unlike pw/pt). Slots beyond the documented - semantic roles are extra slots addressable via R0 YP yp5..yp9. - - ``y_mm_per_increment`` defaults to the class constant rather than - ``self.iswap_information`` because this method runs during ``setup()`` to - compute ``rotation_drive_y_max`` -- i.e. before ``iswap_information`` exists. - - Keys (mm): - "home" py[0] - home position - "lower_limit" py[1] - lower travel limit - "upper_limit" py[2] - upper travel limit - "parking" py[3] - parking pose (back of travel) - "pre_parking" py[4] - pre-parking pose (firmware requires py[4] < py[3] - 430) - "extra_1" py[5] - extra slot, address via R0 YP yp5 - "extra_2" py[6] - extra slot, address via R0 YP yp6 - "extra_3" py[7] - extra slot, address via R0 YP yp7 - "extra_4" py[8] - extra slot, address via R0 YP yp8 - "extra_5" py[9] - extra slot, address via R0 YP yp9 - - Raises: - RuntimeError: if the iSWAP module is not installed. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="R0", command="RA", ra="py", fmt="py##### (n)") - py = cast(List[int], resp["py"]) - keys = ( - "home", - "lower_limit", - "upper_limit", - "parking", - "pre_parking", - "extra_1", - "extra_2", - "extra_3", - "extra_4", - "extra_5", - ) - return { - k: STARBackend.iswap_y_drive_increment_to_mm(py[i], y_mm_per_increment) - for i, k in enumerate(keys) - } - - async def iswap_rotation_drive_move_z( - self, - z: float, - speed: float = 118.0, - acceleration: float = 643.66, - current_protection_limiter: int = 6, - ): - """Move the iSWAP rotation-drive bottom to an absolute Z (deck coordinates). - - `z` is the rotation-drive bottom Z (lowest physical point of the drive), - matching what `iswap_rotation_drive_request_z` returns. The 13 mm offset - to the finger plane (R0 ZA reference) is applied internally. - - Args: - z: Target rotation-drive-bottom Z coordinate in mm. - speed: Max velocity in mm/sec. Default 118.0 (firmware default). - acceleration: Acceleration in mm/sec^2. Default 643.66 - (firmware default 60 in 1000 incr/sec^2 units). - current_protection_limiter: Motor current limit, 0-7. Default 6 - (firmware default). - - Raises: - RuntimeError: if the iSWAP module is not installed. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - - z_mm_per_increment = self.iswap_information.z_mm_per_increment - z_min_incr, z_max_incr = self.iswap_information.z_increment_range - absolute_min_z = ( - STARBackend.iswap_z_drive_increment_to_mm(z_min_incr, z_mm_per_increment) - + STARBackend.iswap_rotation_drive_z_offset_above_finger_mm - ) - absolute_max_z = ( - STARBackend.iswap_z_drive_increment_to_mm(z_max_incr, z_mm_per_increment) - + STARBackend.iswap_rotation_drive_z_offset_above_finger_mm - ) - if not (absolute_min_z <= z <= absolute_max_z): - raise ValueError(f"z must be between {absolute_min_z} and {absolute_max_z} mm, is {z}") - - finger_plane_z = z - STARBackend.iswap_rotation_drive_z_offset_above_finger_mm - z_increments = STARBackend.iswap_z_drive_mm_to_increment(finger_plane_z, z_mm_per_increment) - - speed_increments = STARBackend.iswap_z_drive_mm_to_increment(speed, z_mm_per_increment) - speed_min, speed_max = self.iswap_information.z_speed_increment_range - if not (speed_min <= speed_increments <= speed_max): - raise ValueError( - f"speed must be between " - f"{STARBackend.iswap_z_drive_increment_to_mm(speed_min, z_mm_per_increment)} and " - f"{STARBackend.iswap_z_drive_increment_to_mm(speed_max, z_mm_per_increment)} mm/sec, " - f"is {speed}" - ) - - acceleration_increments = STARBackend.iswap_z_drive_mm_to_increment( - acceleration / 1000, z_mm_per_increment - ) - accel_min, accel_max = self.iswap_information.z_acceleration_increment_range - if not (accel_min <= acceleration_increments <= accel_max): - raise ValueError( - f"acceleration must be between " - f"{STARBackend.iswap_z_drive_increment_to_mm(accel_min * 1000, z_mm_per_increment)} and " - f"{STARBackend.iswap_z_drive_increment_to_mm(accel_max * 1000, z_mm_per_increment)} mm/sec^2, " - f"is {acceleration}" - ) - - if not (0 <= current_protection_limiter <= 7): - raise ValueError( - f"current_protection_limiter must be between 0 and 7, is {current_protection_limiter}" - ) - - await self.send_command( - module="R0", - command="ZA", - za=f"{round(z_increments):+06}", - zv=f"{round(speed_increments):05}", - zr=f"{round(acceleration_increments):03}", - zw=f"{int(current_protection_limiter)}", - ) - - async def iswap_rotation_drive_request_predefined_z_positions(self) -> Dict[str, float]: - """Read iSWAP rotation-drive Z predefined-position table from EEPROM, in mm. - - Sends R0 RA ra=pz. Firmware returns 10 signed-integer slots; all 10 are - positions (no length slot, unlike pw/pt). Slots beyond home/parking are - extra slots addressable via R0 ZP zp2..zp9. - - Returns rotation-drive-bottom Z (matching `iswap_rotation_drive_request_z` - and `iswap_rotation_drive_move_z`): each EEPROM finger-plane increment is - converted via `iswap_z_drive_increment_to_mm` then offset by - `iswap_rotation_drive_z_offset_above_finger_mm`. - - Keys (mm): - "home" pz[0] - home position - "parking" pz[1] - parking pose - "extra_1" pz[2] - extra slot, address via R0 ZP zp2 - "extra_2" pz[3] - extra slot, address via R0 ZP zp3 - "extra_3" pz[4] - extra slot, address via R0 ZP zp4 - "extra_4" pz[5] - extra slot, address via R0 ZP zp5 - "extra_5" pz[6] - extra slot, address via R0 ZP zp6 - "extra_6" pz[7] - extra slot, address via R0 ZP zp7 - "extra_7" pz[8] - extra slot, address via R0 ZP zp8 - "extra_8" pz[9] - extra slot, address via R0 ZP zp9 - - Raises: - RuntimeError: if the iSWAP module is not installed. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="R0", command="RA", ra="pz", fmt="pz##### (n)") - pz = cast(List[int], resp["pz"]) - offset = STARBackend.iswap_rotation_drive_z_offset_above_finger_mm - keys = ( - "home", - "parking", - "extra_1", - "extra_2", - "extra_3", - "extra_4", - "extra_5", - "extra_6", - "extra_7", - "extra_8", - ) - z_mm_per_increment = self.iswap_information.z_mm_per_increment - return { - k: STARBackend.iswap_z_drive_increment_to_mm(pz[i], z_mm_per_increment) + offset - for i, k in enumerate(keys) - } - - async def _iswap_rotation_drive_request_predefined_increments(self) -> Dict[str, int]: - """Read the iSWAP rotation drive (W) predefined-position table from EEPROM. - - Sends R0 RA ra=pw. Firmware returns 10 signed-integer slots; the 9 position - slots are returned here. Slot pw[9] (arm length) is exposed separately via - `iswap_request_link_1_length`. Undocumented slots are returned as - "extra_1".."extra_4" and addressable via R0 WP wp5..wp8. - - Keys (motor increments; W-drive resolution 0.00310 deg/incr): - "home" pw[0] - home position - "left" pw[1] - LEFT deck position (~ -90 deg) - "front" pw[2] - FRONT deck position (~ 0 deg) - "right" pw[3] - RIGHT deck position (~ +90 deg) - "parking" pw[4] - past-W3 parking pose (firmware requires > iw + 50) - "extra_1" pw[5] - extra slot, address via R0 WP wp5 - "extra_2" pw[6] - extra slot, address via R0 WP wp6 - "extra_3" pw[7] - extra slot, address via R0 WP wp7 - "extra_4" pw[8] - extra slot, address via R0 WP wp8 - - Raises: - RuntimeError: if the iSWAP module is not installed. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="R0", command="RA", ra="pw", fmt="pw##### (n)") - pw = cast(List[int], resp["pw"]) - return { - "home": pw[0], - "left": pw[1], - "front": pw[2], - "right": pw[3], - "parking": pw[4], - "extra_1": pw[5], - "extra_2": pw[6], - "extra_3": pw[7], - "extra_4": pw[8], - } - - async def _request_iswap_rotation_drive_position_increments(self) -> int: - """Query the iSWAP rotation drive position (units: increments) from the firmware.""" - response = await self.send_command(module="R0", command="RW", fmt="rw######") - return cast(int, response["rw"]) - - @staticmethod - def _iswap_rotation_drive_increments_to_angle( - increments: int, - predefined_increments: Dict["STARBackend.RotationDriveOrientation", int], - ) -> float: - """Piecewise-linear map encoder increments -> degrees. - - [LEFT, FRONT] -> [-90, 0] and [FRONT, RIGHT] -> [0, +90]. Increments outside - [LEFT, RIGHT] extrapolate using each segment's slope (e.g. PARKED_RIGHT ~+91 - deg). Anchored on the calibrated stops, so they report exactly -90/0/+90. - """ - front = predefined_increments[STARBackend.RotationDriveOrientation.FRONT] - if increments < front: - left = predefined_increments[STARBackend.RotationDriveOrientation.LEFT] - return -90.0 * (front - increments) / (front - left) - else: - right = predefined_increments[STARBackend.RotationDriveOrientation.RIGHT] - return 90.0 * (increments - front) / (right - front) - - @staticmethod - def _iswap_rotation_drive_angle_to_increments( - angle: float, - predefined_increments: Dict["STARBackend.RotationDriveOrientation", int], - ) -> int: - """Inverse of `_iswap_rotation_drive_increments_to_angle`; rounds to the nearest - integer increment. The named stop angles (-90 / 0 / +90) return exactly the - EEPROM stop increments (rounding is a no-op for the stop values themselves).""" - front = predefined_increments[STARBackend.RotationDriveOrientation.FRONT] - if angle < 0: - left = predefined_increments[STARBackend.RotationDriveOrientation.LEFT] - return round(front - (front - left) * (-angle / 90.0)) - else: - right = predefined_increments[STARBackend.RotationDriveOrientation.RIGHT] - return round(front + (right - front) * (angle / 90.0)) - - @staticmethod - def _iswap_rotation_drive_resolve_to_increments( - angle: Union["STARBackend.RotationDriveOrientation", float], - predefined_increments: Dict["STARBackend.RotationDriveOrientation", int], - deg_per_increment: float = iSWAPInformation.rotation_deg_per_increment, - ) -> int: - """Resolve a rotation-drive target (enum or float deg) to motor increments. - - Enum stops return the EEPROM increment directly. Floats use the calibrated - piecewise-linear conversion, but snap to a stop's exact stored increment - when the float matches that stop's reported deg-form within one increment - of precision - so a value read via `iswap_rotation_drive_request_angle` - round-trips back to the same motor increment, including for PARKED_RIGHT - where the extrapolated formula is FP-vulnerable. - """ - if isinstance(angle, STARBackend.RotationDriveOrientation): - return predefined_increments[angle] - for stop_incr in predefined_increments.values(): - stop_angle = STARBackend._iswap_rotation_drive_increments_to_angle( - stop_incr, predefined_increments - ) - if abs(angle - stop_angle) <= deg_per_increment: - return stop_incr - return STARBackend._iswap_rotation_drive_angle_to_increments(angle, predefined_increments) - - async def iswap_rotation_drive_request_angle(self) -> float: - """Query the iSWAP rotation drive angle in degrees (signed, 0 deg = calibrated FRONT). - - See `_iswap_rotation_drive_increments_to_angle` for the conversion. On a - calibrated STAR the FRONT stop can sit ~+/-300 incr (~1 deg) off the - Hamilton-default zero. - - Raises: - RuntimeError: if `setup()` has not populated the predefined positions. - """ - increments = await self._request_iswap_rotation_drive_position_increments() - return STARBackend._iswap_rotation_drive_increments_to_angle( - increments, self.iswap_information.rotation_drive_predefined_increments - ) - - async def request_iswap_rotation_drive_orientation(self) -> "RotationDriveOrientation": - """Request the iSWAP rotation drive orientation. - - Uses nearest-neighbour classification against the per-machine EEPROM `pw` - values populated at setup. An earlier implementation used +/-50 windows - around the Hamilton factory defaults and faulted on machines calibrated - outside that band; we now pick whichever predefined stop is closest and - only raise if the drive is more than 5 deg from any predefined stop. - - Hamilton factory-default values shown below for reference only; the - per-machine EEPROM table is queried at setup and used at runtime in place - of these (W-drive resolution = 0.00310 deg/incr):: - - LEFT W1 -29068 incr (-90 deg) - FRONT W2 +0 incr ( +0 deg) - RIGHT W3 +29068 incr (+90 deg) - PARKED_RIGHT park +29500 incr (+91 deg, beyond W3 at the stop) - - Returns: - RotationDriveOrientation: The interpreted rotation orientation - (LEFT, FRONT, RIGHT, or PARKED_RIGHT). - - Raises: - RuntimeError: if `setup()` has not populated the predefined positions. - ValueError: if the measured position is more than 5 deg from any - predefined stop (drive is in transit or drifted). - """ - # PARKED_RIGHT is kept as a distinct neighbour so we can report "parked" - # explicitly when the drive sits at the parking stop rather than the W3 - # work stop. - # TODO: add PARKED_LEFT reference for STAR(let)s that park on the left. - predefined_increments = self.iswap_information.rotation_drive_predefined_increments - front = predefined_increments[STARBackend.RotationDriveOrientation.FRONT] - # Compute the per-machine increment delta for 5 deg via the same piecewise mapping the - # angle methods use, so the tolerance reflects the calibrated FRONT->RIGHT slope. - tolerance_incr = ( - STARBackend._iswap_rotation_drive_angle_to_increments(5.0, predefined_increments) - front - ) - - motor_position_increments = await self._request_iswap_rotation_drive_position_increments() - - orientation, offset = min( - ((o, abs(p - motor_position_increments)) for o, p in predefined_increments.items()), - key=lambda pair: pair[1], - ) - if offset > tolerance_incr: - raise ValueError( - f"Unknown rotation orientation: {motor_position_increments} incr is " - f"{offset} incr (~{offset * self.iswap_information.rotation_deg_per_increment:.2f} deg) " - f"from the nearest predefined " - f"stop ({orientation.name} at {predefined_increments[orientation]}). " - "Is the rotation drive in transit or mis-calibrated?" - ) - return orientation - - async def rotate_iswap_rotation_drive(self, orientation: RotationDriveOrientation): - """Rotate the iSWAP rotation drive to a predefined working stop (R0 WP). - - Args: - orientation: must be LEFT, FRONT, or RIGHT. PARKED_RIGHT is not - accepted; use `park_iswap()` for parking. - - Raises: - ValueError: if orientation is not LEFT, FRONT, or RIGHT. - """ - if orientation in { - STARBackend.RotationDriveOrientation.RIGHT, - STARBackend.RotationDriveOrientation.FRONT, - STARBackend.RotationDriveOrientation.LEFT, - }: - return await self.send_command( - module="R0", - command="WP", - auto_id=False, - wp=orientation.value, - ) - else: - raise ValueError(f"Invalid rotation drive orientation: {orientation}") - - async def _iswap_rotation_drive_rotate_to_angle( - self, - angle: Union[RotationDriveOrientation, float], - speed: int = 25_000, - acceleration: int = 170, - current_limit: int = 5, - ) -> None: - """Rotate only the iSWAP rotation drive (Joint 1) to an absolute angle. - - Internal single-axis variant kept for troubleshooting direct one-joint - motion. The public entry point is `iswap_rotate_to_angles`, which covers - this case via `rotation_angle=X, wrist_angle=None` and also drives both - joints together when both are supplied. - - Passing a `RotationDriveOrientation` (LEFT / FRONT / RIGHT / PARKED_RIGHT) - sends the drive to the exact EEPROM-stored increment for that stop. Passing - a float interprets it as degrees signed from the calibrated FRONT (0 deg), - using piecewise-linear interpolation between the three working stops (LEFT - -> FRONT for negative angles, FRONT -> RIGHT for non-negative); the named - stop angles (-90 / 0 / +90) thus round-trip exactly. Angles beyond +/-90 - extrapolate using each segment's slope. The wrist drive is held at its - current position. - - Note on PARKED_RIGHT: this method moves only the rotation drive joint to - the parking increment via an absolute-position command - it does NOT invoke - the full gripper-park procedure provided by `park_iswap()` (which also - closes the gripper, applies a traverse-height constraint, and sets the - internal `_iswap_parked` state). Use `rotate_to_angle(PARKED_RIGHT)` when - you want only the joint motion; use `park_iswap()` when you want the full - safe-park. - - Args: - angle: either a `RotationDriveOrientation` member, or a float in degrees - signed from the calibrated FRONT (~+/-93 deg achievable window; - per-machine, depends on the EEPROM-stored stops populated at setup). - speed: max velocity in increments/sec, range 20..75000. - acceleration: in 1000 increments/sec^2, range 5..200. - current_limit: motor current protection limiter, range 0..7. - - Raises: - RuntimeError: if `setup()` has not populated the predefined positions. - ValueError: if the resulting target increment is outside the hardware range. - """ - predefined_increments = self.iswap_information.rotation_drive_predefined_increments - rotation_min, rotation_max = self.iswap_information.rotation_increment_range - rotation_position_increments = STARBackend._iswap_rotation_drive_resolve_to_increments( - angle, predefined_increments, self.iswap_information.rotation_deg_per_increment - ) - if not (rotation_min <= rotation_position_increments <= rotation_max): - rotation_position_deg = STARBackend._iswap_rotation_drive_increments_to_angle( - rotation_position_increments, predefined_increments - ) - raise ValueError( - f"angle {angle} maps to {rotation_position_increments} incr ({rotation_position_deg:.2f} deg) " - f"(stops LEFT/FRONT/RIGHT=" - f"{predefined_increments[STARBackend.RotationDriveOrientation.LEFT]}/" - f"{predefined_increments[STARBackend.RotationDriveOrientation.FRONT]}/" - f"{predefined_increments[STARBackend.RotationDriveOrientation.RIGHT]}), " - f"outside hardware range [{rotation_min}, " - f"{rotation_max}]" - ) - wrist_position_increments = await self._request_iswap_wrist_drive_position_increments() - - await self._iswap_rotate_increments( - rotation_position_increments=rotation_position_increments, - wrist_position_increments=wrist_position_increments, - rotation_speed=speed, - rotation_acceleration=acceleration, - rotation_current_limit=current_limit, - ) - - # ----------------------------------------------------------------------- - # iSWAP: "Wrist Drive" (Joint 2) - # ----------------------------------------------------------------------- - - class WristDriveOrientation(enum.Enum): - RIGHT = 1 - STRAIGHT = 2 - LEFT = 3 - REVERSE = 4 - - async def _request_iswap_wrist_drive_position_increments(self) -> int: - """Query the iSWAP wrist drive position (units: increments) from the firmware.""" - response = await self.send_command(module="R0", command="RT", fmt="rt######") - return cast(int, response["rt"]) - - async def _iswap_wrist_drive_request_predefined_increments(self) -> Dict[str, int]: - """Read the iSWAP wrist twist drive (T) predefined-position table from EEPROM. - - Sends R0 RA ra=pt. Firmware returns 10 signed-integer slots; the 9 position - slots are returned here. Slot pt[9] (arm length) is exposed separately via - `iswap_request_link_2_length`. Undocumented slots are returned as - "extra_1".."extra_3" and addressable via R0 TP tp6..tp8. - - Keys (motor increments; T-drive resolution 0.00508 deg/incr): - "home" pt[0] - home position - "right" pt[1] - wrist twisted right relative to arm (~ -135 deg) - "straight" pt[2] - wrist aligned with arm (~ -45 deg) - "left" pt[3] - wrist twisted left relative to arm (~ +45 deg) - "reverse" pt[4] - wrist twisted 180 deg from straight (~ +135 deg) - "parking" pt[5] - free pip channel + parking pose (firmware requires < it - 50) - "extra_1" pt[6] - extra slot, address via R0 TP tp6 - "extra_2" pt[7] - extra slot, address via R0 TP tp7 - "extra_3" pt[8] - extra slot, address via R0 TP tp8 - - Raises: - RuntimeError: if the iSWAP module is not installed. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="R0", command="RA", ra="pt", fmt="pt##### (n)") - pt = cast(List[int], resp["pt"]) - return { - "home": pt[0], - "right": pt[1], - "straight": pt[2], - "left": pt[3], - "reverse": pt[4], - "parking": pt[5], - "extra_1": pt[6], - "extra_2": pt[7], - "extra_3": pt[8], - } - - @staticmethod - def _iswap_wrist_drive_increments_to_angle( - increments: int, deg_per_increment: float = iSWAPInformation.wrist_deg_per_increment - ) -> float: - """Linear map encoder increments -> degrees, anchored on motor zero (0 incr = 0 deg). - - Symmetric around the motor's raw zero, so the achievable range is - ~+/-152 deg (the +/-30000 incr hardware limits). Named EEPROM stops - report approximately: - - RIGHT ~ -135 deg - STRAIGHT ~ -45 deg - LEFT ~ +45 deg - REVERSE ~ +135 deg - - with small per-machine drift from the factory defaults. Callers that - need an exact named-stop landing should pass the `WristDriveOrientation` - member to `iswap_rotate_to_angles` rather than a float -- the enum path - uses the calibrated EEPROM increment directly. - """ - return increments * deg_per_increment - - @staticmethod - def _iswap_wrist_drive_angle_to_increments( - angle: float, deg_per_increment: float = iSWAPInformation.wrist_deg_per_increment - ) -> int: - """Inverse of `_iswap_wrist_drive_increments_to_angle`; rounds to nearest int.""" - return round(angle / deg_per_increment) - - @staticmethod - def _iswap_wrist_drive_resolve_to_increments( - angle: Union["STARBackend.WristDriveOrientation", float], - predefined_increments: Dict["STARBackend.WristDriveOrientation", int], - deg_per_increment: float = iSWAPInformation.wrist_deg_per_increment, - ) -> int: - """Resolve a wrist-drive target (enum or float deg) to motor increments. - - Snap-to-stop rule mirrors `_iswap_rotation_drive_resolve_to_increments`; - here the snap is the only mechanism that lands the per-machine stops - bit-exact, since the standard linear conversion is anchored on motor zero - rather than the calibrated stops. - """ - if isinstance(angle, STARBackend.WristDriveOrientation): - return predefined_increments[angle] - for stop_incr in predefined_increments.values(): - stop_angle = STARBackend._iswap_wrist_drive_increments_to_angle(stop_incr, deg_per_increment) - if abs(angle - stop_angle) <= deg_per_increment: - return stop_incr - return STARBackend._iswap_wrist_drive_angle_to_increments(angle, deg_per_increment) - - async def iswap_wrist_drive_request_angle(self) -> float: - """Query the iSWAP wrist drive angle in degrees (signed, 0 deg = motor zero). - - See `_iswap_wrist_drive_increments_to_angle` for the conversion. The - motor's raw zero sits between STRAIGHT and LEFT; this convention keeps - the achievable range symmetric (~+/-152 deg). - """ - increments = await self._request_iswap_wrist_drive_position_increments() - return STARBackend._iswap_wrist_drive_increments_to_angle( - increments, self.iswap_information.wrist_deg_per_increment - ) - - async def request_iswap_wrist_drive_orientation(self) -> "WristDriveOrientation": - """Request the iSWAP wrist drive orientation (relative to the rotation drive). - - e.g.: - - 1) RotationDriveOrientation.FRONT + WristDriveOrientation.STRAIGHT - => wrist also points to the front of the machine. - - 2) RotationDriveOrientation.LEFT + WristDriveOrientation.STRAIGHT - => wrist also points to the left of the machine. - - 3) RotationDriveOrientation.FRONT + WristDriveOrientation.RIGHT - => wrist points to the left of the machine. - - Uses nearest-neighbour classification against the per-machine EEPROM `pt` - values populated at setup. An earlier implementation used +/-50 windows - around the Hamilton factory defaults and faulted on machines calibrated - outside that band; we now pick whichever predefined stop is closest and - only raise if the wrist is more than 5 deg from any predefined stop. - - Hamilton factory-default values shown below for reference only; the - per-machine EEPROM table is queried at setup and used at runtime in place - of these (T-drive resolution = 0.00508 deg/incr):: - - RIGHT T1 -26577 incr (-135 deg) - STRAIGHT T2 -8859 incr ( -45 deg) - LEFT T3 +8859 incr ( +45 deg) - REVERSE T4 +26577 incr (+135 deg) - - Returns: - WristDriveOrientation: The interpreted wrist orientation - (RIGHT, STRAIGHT, LEFT, or REVERSE). - - Raises: - RuntimeError: if `setup()` has not populated the predefined positions. - ValueError: if the measured position is more than 5 deg from any - predefined stop (drive is in transit or drifted). - """ - predefined_increments = self.iswap_information.wrist_drive_predefined_increments - wrist_deg_per_increment = self.iswap_information.wrist_deg_per_increment - tolerance_incr = round(5.0 / wrist_deg_per_increment) # ~1000 - - motor_position_increments = await self._request_iswap_wrist_drive_position_increments() - - orientation, offset = min( - ((o, abs(p - motor_position_increments)) for o, p in predefined_increments.items()), - key=lambda pair: pair[1], - ) - if offset > tolerance_incr: - raise ValueError( - f"Unknown wrist orientation: {motor_position_increments} incr is " - f"{offset} incr (~{offset * wrist_deg_per_increment:.2f} deg) " - f"from the nearest predefined " - f"stop ({orientation.name} at {predefined_increments[orientation]}). " - "Is the wrist drive in transit or mis-calibrated?" - ) - return orientation - - async def rotate_iswap_wrist(self, orientation: WristDriveOrientation): - """Rotate the iSWAP wrist drive to a predefined orientation.""" - return await self.send_command( - module="R0", - command="TP", - auto_id=False, - tp=orientation.value, - ) - - async def _iswap_wrist_drive_rotate_to_angle( - self, - angle: Union[WristDriveOrientation, float], - speed: int = 20_000, - acceleration: int = 145, - current_limit: int = 5, - ) -> None: - """Rotate only the iSWAP wrist drive (Joint 2) to an absolute angle. - - Internal single-axis variant kept for troubleshooting direct one-joint - motion. The public entry point is `iswap_rotate_to_angles`, which covers - this case via `wrist_angle=X, rotation_angle=None` and also drives both - joints together when both are supplied. - - Passing a `WristDriveOrientation` (RIGHT / STRAIGHT / LEFT / REVERSE) sends - the drive to the exact EEPROM-stored increment for that stop. Passing a - float interprets it as degrees signed from motor zero (0 deg = 0 incr), - via the linear `deg_per_increment` conversion. Achievable range is - ~+/-152 deg (the +/-30000 incr hardware limits). The rotation drive is - held at its current position. - - Args: - angle: either a `WristDriveOrientation` member (uses EEPROM stop), or - a float in degrees signed from motor zero. - speed: max velocity in increments/sec, range 20..65000. - acceleration: in 1000 increments/sec^2, range 5..200. - current_limit: motor current protection limiter, range 0..7. - - Raises: - RuntimeError: when an orientation is passed and `setup()` has not - populated the predefined positions yet. - ValueError: if the resulting target increment is outside the hardware range. - """ - predefined_increments = self.iswap_information.wrist_drive_predefined_increments - wrist_deg_per_increment = self.iswap_information.wrist_deg_per_increment - wrist_min, wrist_max = self.iswap_information.wrist_increment_range - wrist_position_increments = STARBackend._iswap_wrist_drive_resolve_to_increments( - angle, predefined_increments, wrist_deg_per_increment - ) - if not (wrist_min <= wrist_position_increments <= wrist_max): - wrist_position_deg = STARBackend._iswap_wrist_drive_increments_to_angle( - wrist_position_increments, wrist_deg_per_increment - ) - min_deg = STARBackend._iswap_wrist_drive_increments_to_angle( - wrist_min, wrist_deg_per_increment - ) - max_deg = STARBackend._iswap_wrist_drive_increments_to_angle( - wrist_max, wrist_deg_per_increment - ) - raise ValueError( - f"angle {angle} ({wrist_position_deg:+.2f} deg) is outside the hardware " - f"range [{min_deg:+.2f}, {max_deg:+.2f}] deg" - ) - rotation_position_increments = await self._request_iswap_rotation_drive_position_increments() - - await self._iswap_rotate_increments( - rotation_position_increments=rotation_position_increments, - wrist_position_increments=wrist_position_increments, - wrist_speed=speed, - wrist_acceleration=acceleration, - wrist_current_limit=current_limit, - ) - - # ----------------------------------------------------------------------- - # iSWAP: Forward Kinematics - # ----------------------------------------------------------------------- - - @staticmethod - def _iswap_fk( - joints: Dict["STARBackend.iSWAPAxis", float], - link_1_length: float, - link_2_length: float, - wrist_straight_angle: float, - ) -> CartesianCoords: - """Pure forward-kinematics map: joint state -> gripper pose (no I/O). - - Takes an `iSWAPAxis`-keyed joint dict and the calibrated link lengths / - STRAIGHT angle, returns just the gripper-frame pose (grip-center location - + yaw). No intermediate frames in the return; callers that need the wrist - XY can recompute trivially from joints + L1. - - Sign convention follows right-hand rule about +Z (CCW positive looking - down). Yaw is the deck-frame direction of link 2: - `alpha_2 = (W - 90) + (T - T_STRAIGHT)`, with 0 deg = +x deck-right. - Z: rotation-drive-bottom Z minus `iswap_rotation_drive_z_offset_above_finger_mm` - (13 mm). - """ - rotation_drive_angle = joints[STARBackend.iSWAPAxis.ROTATION] - wrist_drive_angle = joints[STARBackend.iSWAPAxis.WRIST] - base_x = joints[STARBackend.iSWAPAxis.X] - base_y = joints[STARBackend.iSWAPAxis.Y] - base_z = joints[STARBackend.iSWAPAxis.Z] - - link_1_deck_angle = rotation_drive_angle - 90.0 - link_2_deck_angle = link_1_deck_angle + (wrist_drive_angle - wrist_straight_angle) - - alpha_1_rad = math.radians(link_1_deck_angle) - alpha_2_rad = math.radians(link_2_deck_angle) - - grip_x = base_x + link_1_length * math.cos(alpha_1_rad) + link_2_length * math.cos(alpha_2_rad) - grip_y = base_y + link_1_length * math.sin(alpha_1_rad) + link_2_length * math.sin(alpha_2_rad) - grip_z = base_z - STARBackend.iswap_rotation_drive_z_offset_above_finger_mm - - return CartesianCoords( - location=Coordinate(x=grip_x, y=grip_y, z=grip_z), - rotation=Rotation(z=link_2_deck_angle), - ) - - async def iswap_request_joint_state(self) -> Dict[int, float]: - """Snapshot of the iSWAP's current joint state, keyed by `iSWAPAxis`. - - Composes the per-axis request methods into a single read-through dict. - Units are axis-implicit (see `iSWAPAxis`): X/Y/Z/GRIPPER in mm, ROTATION - and WRIST in degrees. - - Raises: - RuntimeError: if iSWAP is not installed or if `setup()` has not run - (the rotation-drive angle reader needs the predefined-stop table). - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - - return { - STARBackend.iSWAPAxis.X: await self.iswap_rotation_drive_request_x(), - STARBackend.iSWAPAxis.Y: await self.iswap_rotation_drive_request_y(), - STARBackend.iSWAPAxis.Z: await self.iswap_rotation_drive_request_z(), - STARBackend.iSWAPAxis.ROTATION: await self.iswap_rotation_drive_request_angle(), - STARBackend.iSWAPAxis.WRIST: await self.iswap_wrist_drive_request_angle(), - STARBackend.iSWAPAxis.GRIPPER: await self.iswap_gripper_request_width(), - } - - async def iswap_request_pose(self) -> CartesianCoords: - """Compute the iSWAP gripper pose via forward kinematics. - - FK-based alternative to `request_iswap_position` (C0 QG), which is - firmware-state-dependent and only returns correct values after certain - preceding commands. Reads the joint state directly via - `iswap_request_joint_state` and runs `_iswap_fk` against the link lengths - cached during `setup()`. - - Returns: - `CartesianCoords` with `location` = grip-center deck coordinates (mm) and - `rotation.z` = gripper yaw (deg, deck-frame, 0 = +x; `rotation.x`/`.y` = 0 - since the gripper plane stays parallel to the deck). - - Raises: - RuntimeError: if iSWAP is not installed or if `setup()` has not populated - the wrist STRAIGHT calibration / cached link lengths. - """ - wrist_straight_increments = self.iswap_information.wrist_drive_predefined_increments[ - STARBackend.WristDriveOrientation.STRAIGHT - ] - wrist_straight_angle = STARBackend._iswap_wrist_drive_increments_to_angle( - wrist_straight_increments, self.iswap_information.wrist_deg_per_increment - ) - - joints = { - STARBackend.iSWAPAxis(k): v for k, v in (await self.iswap_request_joint_state()).items() - } - - return STARBackend._iswap_fk( - joints=joints, - link_1_length=self.iswap_information.link_1_length, - link_2_length=self.iswap_information.link_2_length, - wrist_straight_angle=wrist_straight_angle, - ) - - # ----------------------------------------------------------------------- - # iSWAP: Combined Rotation-Wrist Moves - # ----------------------------------------------------------------------- - - async def _iswap_rotate_increments( - self, - rotation_position_increments: int, # units: increments - wrist_position_increments: int, # units: increments - rotation_speed: int = 25_000, # units: increments/sec - wrist_speed: int = 20_000, # units: increments/sec - rotation_acceleration: int = 170, # units: 1000 increments/sec^2 - wrist_acceleration: int = 145, # units: 1000 increments/sec^2 - rotation_current_limit: int = 5, - wrist_current_limit: int = 5, - ) -> None: - """Absolute parallel move of rotation (Joint 1) + wrist (Joint 2) drives. - - Args: - rotation_position_increments: signed destination, range -30032..+30032. - wrist_position_increments: signed destination, range -30000..+30000. - rotation_speed [increments/sec]: max velocity, range 20..75000. - wrist_speed [increments/sec]: max velocity, range 20..65000. - rotation_acceleration [1000 increments/sec^2]: range 5..200. - wrist_acceleration [1000 increments/sec^2]: range 5..200. - rotation_current_limit: current protection limiter, range 0..7. - wrist_current_limit: current protection limiter, range 0..7. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - - rotation_min, rotation_max = self.iswap_information.rotation_increment_range - wrist_min, wrist_max = self.iswap_information.wrist_increment_range - if not (rotation_min <= rotation_position_increments <= rotation_max): - raise ValueError( - f"rotation_position_increments must be between " - f"{rotation_min} and " - f"{rotation_max}; got {rotation_position_increments}" - ) - if not (wrist_min <= wrist_position_increments <= wrist_max): - raise ValueError( - f"wrist_position_increments must be between " - f"{wrist_min} and " - f"{wrist_max}; got {wrist_position_increments}" - ) - - if not 20 <= rotation_speed <= 75000: - raise ValueError(f"rotation_speed must be between 20 and 75000; got {rotation_speed}") - if not 20 <= wrist_speed <= 65000: - raise ValueError(f"wrist_speed must be between 20 and 65000; got {wrist_speed}") - if not 5 <= rotation_acceleration <= 200: - raise ValueError( - f"rotation_acceleration must be between 5 and 200; got {rotation_acceleration}" - ) - if not 5 <= wrist_acceleration <= 200: - raise ValueError(f"wrist_acceleration must be between 5 and 200; got {wrist_acceleration}") - if not 0 <= rotation_current_limit <= 7: - raise ValueError( - f"rotation_current_limit must be between 0 and 7; got {rotation_current_limit}" - ) - if not 0 <= wrist_current_limit <= 7: - raise ValueError(f"wrist_current_limit must be between 0 and 7; got {wrist_current_limit}") - - await self.send_command( - module="R0", - command="PA", - wa=f"{rotation_position_increments:+06}", - wv=f"{rotation_speed:05}", - wr=f"{rotation_acceleration:03}", - ww=f"{rotation_current_limit}", - ta=f"{wrist_position_increments:+06}", - tv=f"{wrist_speed:05}", - tr=f"{wrist_acceleration:03}", - tw=f"{wrist_current_limit}", - ) - - async def iswap_rotate_to_angles( - self, - rotation_angle: Optional[Union[RotationDriveOrientation, float]] = None, - wrist_angle: Optional[Union[WristDriveOrientation, float]] = None, - rotation_speed: float = 75.0, - rotation_acceleration: float = 500.0, - rotation_current_limit: int = 5, - wrist_speed: float = 100.0, - wrist_acceleration: float = 725.0, - wrist_current_limit: int = 5, - ) -> None: - """Rotate one or both iSWAP joints to absolute angles in a single motion. - - Public deg-based wrapper around `_iswap_rotate_increments`. When both - angles are supplied, both joints arrive together under a single motion - plan so the gripper sweeps a straight joint-space path; enables IK-driven - trajectory execution. - - When only one angle is supplied, the other drive is requested from device - (i.e. single-axis rotation is covered as well). - At least one of `rotation_angle` or `wrist_angle` must be provided. - - Each angle is either the enum stop (lands on the EEPROM increment) or a - float in degrees: rotation floats interpolate piecewise-linearly between - the LEFT / FRONT / RIGHT EEPROM stops, wrist floats are linear from motor - zero. Speed and acceleration convert linearly for both drives. - - Snap-to-stop: float angles within one motor increment of a calibrated - stop's deg-form land on the exact stored increment, so values read via - `iswap_*_drive_request_angle` round-trip bit-exact. - - Args: - rotation_angle [deg]: predefined `RotationDriveOrientation` enum, or float - signed from FRONT (+/-90), or None to hold current. - wrist_angle [deg]: predefined `WristDriveOrientation` enum, or float signed - from motor zero (+/-152), or None to hold current. - rotation_speed [deg/sec]: max angular velocity, 0.1..230. - rotation_acceleration [deg/sec^2]: max angular acceleration, 16..619. - rotation_current_limit: motor current protection limiter, 0..7. - wrist_speed [deg/sec]: max angular velocity, 0.2..330. - wrist_acceleration [deg/sec^2]: max angular acceleration, 26..1015. - wrist_current_limit: motor current protection limiter, 0..7. - - Raises: - RuntimeError: if iSWAP is not installed or if `setup()` has not populated - the predefined-stop tables. - ValueError: if neither angle is provided, or if either resolved target - increment is outside the hardware range. - """ - if rotation_angle is None and wrist_angle is None: - raise ValueError( - "iswap_rotate_to_angles requires at least one of `rotation_angle` or " - "`wrist_angle` to be provided; both are None" - ) - - if rotation_angle is None: - rotation_position_increments = await self._request_iswap_rotation_drive_position_increments() - else: - rot_predefined = self.iswap_information.rotation_drive_predefined_increments - rotation_min, rotation_max = self.iswap_information.rotation_increment_range - rotation_position_increments = STARBackend._iswap_rotation_drive_resolve_to_increments( - rotation_angle, rot_predefined, self.iswap_information.rotation_deg_per_increment - ) - if not (rotation_min <= rotation_position_increments <= rotation_max): - rotation_position_deg = STARBackend._iswap_rotation_drive_increments_to_angle( - rotation_position_increments, rot_predefined - ) - raise ValueError( - f"rotation_angle {rotation_angle} maps to {rotation_position_increments} incr " - f"({rotation_position_deg:.2f} deg) (stops LEFT/FRONT/RIGHT=" - f"{rot_predefined[STARBackend.RotationDriveOrientation.LEFT]}/" - f"{rot_predefined[STARBackend.RotationDriveOrientation.FRONT]}/" - f"{rot_predefined[STARBackend.RotationDriveOrientation.RIGHT]}), " - f"outside hardware range [{rotation_min}, " - f"{rotation_max}]" - ) - - if wrist_angle is None: - wrist_position_increments = await self._request_iswap_wrist_drive_position_increments() - else: - wrist_predefined = self.iswap_information.wrist_drive_predefined_increments - wrist_deg_per_increment = self.iswap_information.wrist_deg_per_increment - wrist_min, wrist_max = self.iswap_information.wrist_increment_range - wrist_position_increments = STARBackend._iswap_wrist_drive_resolve_to_increments( - wrist_angle, wrist_predefined, wrist_deg_per_increment - ) - if not (wrist_min <= wrist_position_increments <= wrist_max): - wrist_position_deg = STARBackend._iswap_wrist_drive_increments_to_angle( - wrist_position_increments, wrist_deg_per_increment - ) - min_deg = STARBackend._iswap_wrist_drive_increments_to_angle( - wrist_min, wrist_deg_per_increment - ) - max_deg = STARBackend._iswap_wrist_drive_increments_to_angle( - wrist_max, wrist_deg_per_increment - ) - raise ValueError( - f"wrist_angle {wrist_angle} ({wrist_position_deg:+.2f} deg) is outside " - f"the hardware range [{min_deg:+.2f}, {max_deg:+.2f}] deg" - ) - - await self._iswap_rotate_increments( - rotation_position_increments=rotation_position_increments, - wrist_position_increments=wrist_position_increments, - rotation_speed=round(rotation_speed / self.iswap_information.rotation_deg_per_increment), - rotation_acceleration=round( - rotation_acceleration / self.iswap_information.rotation_deg_per_increment / 1000 - ), - rotation_current_limit=rotation_current_limit, - wrist_speed=round(wrist_speed / self.iswap_information.wrist_deg_per_increment), - wrist_acceleration=round( - wrist_acceleration / self.iswap_information.wrist_deg_per_increment / 1000 - ), - wrist_current_limit=wrist_current_limit, - ) - - # ----------------------------------------------------------------------- - # iSWAP: Gripper - # ----------------------------------------------------------------------- - - @staticmethod - def iswap_gripper_drive_increment_to_mm( - value_increments: int, mm_per_increment: float = iSWAPInformation.gripper_mm_per_increment - ) -> float: - return round(value_increments * mm_per_increment, 1) - - @staticmethod - def iswap_gripper_drive_mm_to_increment( - value_mm: float, mm_per_increment: float = iSWAPInformation.gripper_mm_per_increment - ) -> int: - return round(value_mm / mm_per_increment) - - async def iswap_gripper_request_width(self) -> float: - """Request the current iSWAP gripper jaw opening width, in mm. - - RG is always available and reads the raw drive encoder. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - - resp = await self.send_command(module="R0", command="RG", fmt="rg##### (n)") - actual_increments = resp["rg"][1] # rg returns [target, actual]; we want actual - - return STARBackend.iswap_gripper_drive_increment_to_mm( - actual_increments, self.iswap_information.gripper_mm_per_increment - ) - - async def iswap_gripper_request_predefined_positions(self) -> Dict[str, int]: - """Read the iSWAP gripper drive (G) predefined-position table. - - Keys (motor increments; G-drive resolution 0.00554 mm/incr): - "home" pg[0] - home & parking - "fully_open" pg[1] - default 24120 = max jaw width - "closed" pg[2] - gripper closed - "plate_type_1" pg[3] - grip plate type 1 - "plate_type_2" pg[4] - grip plate type 2 - "plate_type_3" pg[5] - grip plate type 3 - "plate_type_4" pg[6] - grip plate type 4 - "plate_type_5" pg[7] - grip plate type 5 - "plate_type_6" pg[8] - grip plate type 6 - "plate_type_7" pg[9] - grip plate type 7 - - Raises: - RuntimeError: if the iSWAP module is not installed. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="R0", command="RA", ra="pg", fmt="pg##### (n)") - pg = cast(List[int], resp["pg"]) - return { - "home": pg[0], - "fully_open": pg[1], - "closed": pg[2], - "plate_type_1": pg[3], - "plate_type_2": pg[4], - "plate_type_3": pg[5], - "plate_type_4": pg[6], - "plate_type_5": pg[7], - "plate_type_6": pg[8], - "plate_type_7": pg[9], - } - - async def request_plate_in_iswap(self) -> bool: - """Request plate in iSWAP - - Returns: - True if holding a plate, False otherwise. - """ - - resp = await self.send_command(module="C0", command="QP", fmt="ph#") - return resp is not None and resp["ph"] == 1 - - async def open_not_initialized_gripper(self): - """Initialize the iSWAP gripper drive (C0 GI). - - Required if the gripper drive hasn't been initialized yet. After init, - the drive sits in a known position from which subsequent open/close - commands can operate. - """ - return await self.send_command(module="C0", command="GI") - - async def iswap_open_gripper(self, open_position: Optional[float] = None): - """Open gripper - - Args: - open_position: Open position [mm] (0.1 mm = 16 increments) The gripper moves to pos + 20. - Must be between 0 and 9999. Default 1320 for iSWAP 4.0 (landscape). Default to - 910 for iSWAP 3 (portrait). - """ - - if open_position is None: - open_position = 91.0 if (await self.get_iswap_version()).startswith("3") else 132.0 - - gripper_mm_per_increment = self.iswap_information.gripper_mm_per_increment - min_incr, max_incr = self.iswap_information.gripper_increment_range - min_width = STARBackend.iswap_gripper_drive_increment_to_mm(min_incr, gripper_mm_per_increment) - max_width = STARBackend.iswap_gripper_drive_increment_to_mm(max_incr, gripper_mm_per_increment) - if not (min_width <= open_position <= max_width): - raise ValueError( - f"open_position must be between {min_width} and {max_width} mm, got {open_position}" - ) - - return await self.send_command(module="C0", command="GF", go=f"{round(open_position * 10):04}") - - async def iswap_close_gripper( - self, - grip_strength: int = 5, - plate_width: float = 86.0, - plate_width_tolerance: float = 2.0, - ): - """Close gripper - - The gripper should be at the position plate_width+plate_width_tolerance+2.0mm before sending this command. - - Args: - grip_strength: Grip strength. 0 = low . 9 = high. Default 5. - plate_width: Plate width [mm] (gb should be > min. Pos. + stop ramp + gt -> gb > 760 + 5 + g ). - Default 86.0 (SBS short side, matching `iswap_get_plate`'s 860 in 0.1 mm units). - plate_width_tolerance: Plate width tolerance [mm]. Must be between 0.5 and 9.9. Default 2.0. - """ - - assert 0 <= grip_strength <= 9, "grip_strength must be between 0 and 9" - # Lower bound 76.0 is the firmware minimum (min position + stop ramp + grip - # tolerance, per the docstring), stricter than the physical jaw minimum. Upper - # bound is the physical jaw maximum derived from gripper_increment_range. - max_incr = self.iswap_information.gripper_increment_range[1] - max_width = STARBackend.iswap_gripper_drive_increment_to_mm( - max_incr, self.iswap_information.gripper_mm_per_increment - ) - if not (76.0 < plate_width <= max_width): - raise ValueError(f"plate_width must be between 76.0 and {max_width} mm, got {plate_width}") - assert 0.5 <= plate_width_tolerance <= 9.9, "plate_width_tolerance must be between 0.5 and 9.9" - - return await self.send_command( - module="C0", - command="GC", - gw=grip_strength, - gb=f"{round(plate_width * 10):04}", - gt=f"{round(plate_width_tolerance * 10):02}", - ) - - # -------------- 3.17.2 Stack handling commands CP -------------- - - async def park_iswap( - self, - minimum_traverse_height_at_beginning_of_a_command: int = 2840, - ): - """Close gripper - - The gripper should be at the position gb+gt+20 before sending this command. - - Args: - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning - of a command [0.1mm]. Must be between 0 and 3600. Default 3600. - """ - - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" - ) - - command_output = await self.send_command( - module="C0", - command="PG", - th=minimum_traverse_height_at_beginning_of_a_command, - ) - - # Once the command has completed successfully, set _iswap_parked to True - self._iswap_parked = True - return command_output - - async def iswap_get_plate( - self, - x_position: int = 0, - x_direction: int = 0, - y_position: int = 0, - y_direction: int = 0, - z_position: int = 0, - z_direction: int = 0, - grip_direction: int = 1, - minimum_traverse_height_at_beginning_of_a_command: int = 3600, - z_position_at_the_command_end: int = 3600, - grip_strength: int = 5, - open_gripper_position: int = 860, - plate_width: int = 860, - plate_width_tolerance: int = 860, - collision_control_level: int = 1, - acceleration_index_high_acc: int = 4, - acceleration_index_low_acc: int = 1, - iswap_fold_up_sequence_at_the_end_of_process: bool = False, - ): - """Get plate using iswap. - - Args: - x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. - x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. - y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. - z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y, - 4 =negative X. Must be between 1 and 4. Default 1. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of - a command 0.1mm]. Must be between 0 and 3600. Default 3600. - z_position_at_the_command_end: Z-Position at the command end [0.1mm]. Must be between 0 - and 3600. Default 3600. - grip_strength: Grip strength 0 = low .. 9 = high. Must be between 1 and 9. Default 5. - open_gripper_position: Open gripper position [0.1mm]. Must be between 0 and 9999. - Default 860. - plate_width: plate width [0.1mm]. Must be between 0 and 9999. Default 860. - plate_width_tolerance: plate width tolerance [0.1mm]. Must be between 0 and 99. Default 860. - collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. - Default 1. - acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4. Default 4. - acceleration_index_low_acc: acceleration index high acc. Must be between 0 and 4. Default 1. - iswap_fold_up_sequence_at_the_end_of_process: fold up sequence at the end of process. Default False. - """ - - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" - assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" - assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" - assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" - assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" - assert 1 <= grip_direction <= 4, "grip_direction must be between 1 and 4" - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" - ) - assert 0 <= z_position_at_the_command_end <= 3600, ( - "z_position_at_the_command_end must be between 0 and 3600" - ) - assert 1 <= grip_strength <= 9, "grip_strength must be between 1 and 9" - assert 0 <= open_gripper_position <= 9999, "open_gripper_position must be between 0 and 9999" - assert 0 <= plate_width <= 9999, "plate_width must be between 0 and 9999" - assert 0 <= plate_width_tolerance <= 99, "plate_width_tolerance must be between 0 and 99" - assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" - assert 0 <= acceleration_index_high_acc <= 4, ( - "acceleration_index_high_acc must be between 0 and 4" - ) - assert 0 <= acceleration_index_low_acc <= 4, ( - "acceleration_index_low_acc must be between 0 and 4" - ) - - command_output = await self.send_command( - module="C0", - command="PP", - xs=f"{x_position:05}", - xd=x_direction, - yj=f"{y_position:04}", - yd=y_direction, - zj=f"{z_position:04}", - zd=z_direction, - gr=grip_direction, - th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", - te=f"{z_position_at_the_command_end:04}", - gw=grip_strength, - go=f"{open_gripper_position:04}", - gb=f"{plate_width:04}", - gt=f"{plate_width_tolerance:02}", - ga=collision_control_level, - # xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}", - gc=iswap_fold_up_sequence_at_the_end_of_process, - ) - - # Once the command has completed successfully, set _iswap_parked to false - self._iswap_parked = False - return command_output - - async def iswap_put_plate( - self, - x_position: int = 0, - x_direction: int = 0, - y_position: int = 0, - y_direction: int = 0, - z_position: int = 0, - z_direction: int = 0, - grip_direction: int = 1, - minimum_traverse_height_at_beginning_of_a_command: int = 3600, - z_position_at_the_command_end: int = 3600, - open_gripper_position: int = 860, - collision_control_level: int = 1, - acceleration_index_high_acc: int = 4, - acceleration_index_low_acc: int = 1, - iswap_fold_up_sequence_at_the_end_of_process: bool = False, - ): - """put plate - - Args: - x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. - x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. - y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. - z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y, 4 = negative - X. Must be between 1 and 4. Default 1. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a - command 0.1mm]. Must be between 0 and 3600. Default 3600. - z_position_at_the_command_end: Z-Position at the command end [0.1mm]. Must be between 0 and - 3600. Default 3600. - open_gripper_position: Open gripper position [0.1mm]. Must be between 0 and 9999. Default - 860. - collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. - Default 1. - acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4. - Default 4. - acceleration_index_low_acc: acceleration index high acc. Must be between 0 and 4. - Default 1. - iswap_fold_up_sequence_at_the_end_of_process: fold up sequence at the end of process. Default False. - """ - - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" - assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" - assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" - assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" - assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" - assert 1 <= grip_direction <= 4, "grip_direction must be between 1 and 4" - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" - ) - assert 0 <= z_position_at_the_command_end <= 3600, ( - "z_position_at_the_command_end must be between 0 and 3600" - ) - assert 0 <= open_gripper_position <= 9999, "open_gripper_position must be between 0 and 9999" - assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" - assert 0 <= acceleration_index_high_acc <= 4, ( - "acceleration_index_high_acc must be between 0 and 4" - ) - assert 0 <= acceleration_index_low_acc <= 4, ( - "acceleration_index_low_acc must be between 0 and 4" - ) - - command_output = await self.send_command( - module="C0", - command="PR", - xs=f"{x_position:05}", - xd=x_direction, - yj=f"{y_position:04}", - yd=y_direction, - zj=f"{z_position:04}", - zd=z_direction, - th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", - te=f"{z_position_at_the_command_end:04}", - gr=grip_direction, - go=f"{open_gripper_position:04}", - ga=collision_control_level, - # xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}" - gc=iswap_fold_up_sequence_at_the_end_of_process, - ) - - # Once the command has completed successfully, set _iswap_parked to false - self._iswap_parked = False - return command_output - - async def iswap_rotate( - self, - rotation_drive: "RotationDriveOrientation", - grip_direction: GripDirection, - gripper_velocity: int = 55_000, - gripper_acceleration: int = 170, - gripper_protection: Literal[0, 1, 2, 3, 4, 5, 6, 7] = 5, - wrist_velocity: int = 48_000, - wrist_acceleration: int = 145, - wrist_protection: Literal[0, 1, 2, 3, 4, 5, 6, 7] = 5, - ): - """ - Rotate the iswap to a predefined position. - Velocity units are "incr/sec" - Acceleration units are "1_000 incr/sec**2" - For a list of the possible positions see the pylabrobot documentation on the R0 module. - """ - assert 20 <= gripper_velocity <= 75_000 - assert 5 <= gripper_acceleration <= 200 - assert 20 <= wrist_velocity <= 65_000 - assert 20 <= wrist_acceleration <= 200 - - position = 0 - - if rotation_drive == STARBackend.RotationDriveOrientation.LEFT: - position += 10 - elif rotation_drive == STARBackend.RotationDriveOrientation.FRONT: - position += 20 - elif rotation_drive == STARBackend.RotationDriveOrientation.RIGHT: - position += 30 - else: - raise ValueError(f"Invalid rotation drive orientation: {rotation_drive}") - - if grip_direction == GripDirection.FRONT: - position += 1 - elif grip_direction == GripDirection.RIGHT: - position += 2 - elif grip_direction == GripDirection.BACK: - position += 3 - elif grip_direction == GripDirection.LEFT: - position += 4 - else: - raise ValueError("Invalid grip direction") - - return await self.send_command( - module="R0", - command="PD", - pd=position, - wv=f"{gripper_velocity:05}", - wr=f"{gripper_acceleration:03}", - ww=gripper_protection, - tv=f"{wrist_velocity:05}", - tr=f"{wrist_acceleration:03}", - tw=wrist_protection, - ) - - async def iswap_dangerous_release_break(self): - return await self.send_command(module="R0", command="BA") - - async def iswap_reengage_break(self): - return await self.send_command(module="R0", command="BO") - - async def iswap_initialize_z_axis(self): - return await self.send_command(module="R0", command="ZI") - - async def move_plate_to_position( - self, - x_position: int = 0, - x_direction: int = 0, - y_position: int = 0, - y_direction: int = 0, - z_position: int = 0, - z_direction: int = 0, - grip_direction: int = 1, - minimum_traverse_height_at_beginning_of_a_command: int = 3600, - collision_control_level: int = 1, - acceleration_index_high_acc: int = 4, - acceleration_index_low_acc: int = 1, - ): - """Move plate to position. - - Args: - x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. - x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. - y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. - z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y, 4 = negative - X. Must be between 1 and 4. Default 1. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a - command 0.1mm]. Must be between 0 and 3600. Default 3600. - collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. - Default 1. - acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4. Default 4. - acceleration_index_low_acc: acceleration index low acc. Must be between 0 and 4. Default 1. - """ - - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" - assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" - assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" - assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" - assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" - assert 1 <= grip_direction <= 4, "grip_direction must be between 1 and 4" - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" - ) - assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" - assert 0 <= acceleration_index_high_acc <= 4, ( - "acceleration_index_high_acc must be between 0 and 4" - ) - assert 0 <= acceleration_index_low_acc <= 4, ( - "acceleration_index_low_acc must be between 0 and 4" - ) - - command_output = await self.send_command( - module="C0", - command="PM", - xs=f"{x_position:05}", - xd=x_direction, - yj=f"{y_position:04}", - yd=y_direction, - zj=f"{z_position:04}", - zd=z_direction, - gr=grip_direction, - th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", - ga=collision_control_level, - xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}", - ) - # Once the command has completed successfully, set _iswap_parked to false - self._iswap_parked = False - return command_output - - async def collapse_gripper_arm( - self, - minimum_traverse_height_at_beginning_of_a_command: int = 3600, - iswap_fold_up_sequence_at_the_end_of_process: bool = False, - ): - """Collapse gripper arm - - Args: - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a - command 0.1mm]. Must be between 0 and 3600. - Default 3600. - iswap_fold_up_sequence_at_the_end_of_process: fold up sequence at the end of process. Default False. - """ - - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" - ) - - return await self.send_command( - module="C0", - command="PN", - th=minimum_traverse_height_at_beginning_of_a_command, - gc=iswap_fold_up_sequence_at_the_end_of_process, - ) - - # -------------- 3.17.3 Hotel handling commands -------------- - - # implemented in UnSafe class - - # -------------- 3.17.4 Barcode commands -------------- - - # TODO:(command:PB) Read barcode using iSWAP - - # -------------- 3.17.5 Teach in commands -------------- - - async def prepare_iswap_teaching( - self, - x_position: int = 0, - x_direction: int = 0, - y_position: int = 0, - y_direction: int = 0, - z_position: int = 0, - z_direction: int = 0, - location: int = 0, - hotel_depth: int = 1300, - grip_direction: int = 1, - minimum_traverse_height_at_beginning_of_a_command: int = 3600, - collision_control_level: int = 1, - acceleration_index_high_acc: int = 4, - acceleration_index_low_acc: int = 1, - ): - """Prepare iSWAP teaching - - Prepare for teaching with iSWAP - - Args: - x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. - x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. - y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. - z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - location: location. 0 = Stack 1 = Hotel. Must be between 0 and 1. Default 0. - hotel_depth: Hotel depth [0.1mm]. Must be between 0 and 3000. Default 1300. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of - a command 0.1mm]. Must be between 0 and 3600. Default 3600. - collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. - Default 1. - acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4. Default 4. - acceleration_index_low_acc: acceleration index high acc. Must be between 0 and 4. Default 1. - """ - - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" - assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" - assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" - assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" - assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" - assert 0 <= location <= 1, "location must be between 0 and 1" - assert 0 <= hotel_depth <= 3000, "hotel_depth must be between 0 and 3000" - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" - ) - assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" - assert 0 <= acceleration_index_high_acc <= 4, ( - "acceleration_index_high_acc must be between 0 and 4" - ) - assert 0 <= acceleration_index_low_acc <= 4, ( - "acceleration_index_low_acc must be between 0 and 4" - ) - - return await self.send_command( - module="C0", - command="PT", - xs=f"{x_position:05}", - xd=x_direction, - yj=f"{y_position:04}", - yd=y_direction, - zj=f"{z_position:04}", - zd=z_direction, - hh=location, - hd=f"{hotel_depth:04}", - gr=grip_direction, - th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", - ga=collision_control_level, - xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}", - ) - - async def get_logic_iswap_position( - self, - x_position: int = 0, - x_direction: int = 0, - y_position: int = 0, - y_direction: int = 0, - z_position: int = 0, - z_direction: int = 0, - location: int = 0, - hotel_depth: int = 1300, - grip_direction: int = 1, - collision_control_level: int = 1, - ): - """Get logic iSWAP position - - Args: - x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. - x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. - y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. - z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - location: location. 0 = Stack 1 = Hotel. Must be between 0 and 1. Default 0. - hotel_depth: Hotel depth [0.1mm]. Must be between 0 and 3000. Default 1300. - grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y, - 4 = negative X. Must be between 1 and 4. Default 1. - collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. - Default 1. - """ - - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" - assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" - assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" - assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" - assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" - assert 0 <= location <= 1, "location must be between 0 and 1" - assert 0 <= hotel_depth <= 3000, "hotel_depth must be between 0 and 3000" - assert 1 <= grip_direction <= 4, "grip_direction must be between 1 and 4" - assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" - - return await self.send_command( - module="C0", - command="PC", - xs=x_position, - xd=x_direction, - yj=y_position, - yd=y_direction, - zj=z_position, - zd=z_direction, - hh=location, - hd=hotel_depth, - gr=grip_direction, - ga=collision_control_level, - ) - - # -------------- 3.17.6 iSWAP query -------------- - - async def request_iswap_in_parking_position(self): - """Request iSWAP in parking position - - Returns: - 0 = gripper is not in parking position - 1 = gripper is in parking position - """ - - return await self.send_command(module="C0", command="RG", fmt="rg#") - - async def request_iswap_position(self) -> Coordinate: - """Request iSWAP gripper finger center position. - - Returns: - xs: Gripper finger center in X direction [1mm] - xd: X direction 0 = positive 1 = negative - yj: Gripper finger center in Y direction [1mm] - yd: Y direction 0 = positive 1 = negative - zj: Gripper finger center Z height [1mm] - zd: Z direction 0 = positive 1 = negative - """ - - resp = await self.send_command(module="C0", command="QG", fmt="xs#####xd#yj####yd#zj####zd#") - return Coordinate( - x=(resp["xs"] / 10) * (1 if resp["xd"] == 0 else -1), - y=(resp["yj"] / 10) * (1 if resp["yd"] == 0 else -1), - z=(resp["zj"] / 10) * (1 if resp["zd"] == 0 else -1), - ) - - async def request_iswap_initialization_status(self) -> bool: - """Request iSWAP initialization status - - Returns: - True if iSWAP is fully initialized - """ - - resp = await self.send_command(module="R0", command="QW", fmt="qw#") - return cast(int, resp["qw"]) == 1 - - async def request_iswap_version(self) -> str: - """Firmware command for getting iswap version""" - return cast(str, (await self.send_command("R0", "RF", fmt="rf" + "&" * 15))["rf"]) - - async def measure_iswap_gripper_force(self) -> float: - """Measure the force currently exerted by the iSWAP gripper, in Newtons. - - Sends R0 RH (request gripper current and force sensor). The firmware - returns 5 fields; the last is the calibrated force in mN, which this - method converts to N. Useful for closed-loop grip verification, - grip-slip detection, and adaptive grip-strength tuning. - """ - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="R0", command="RH") - # Response: rh#### #### #### #### ##### - # Fields: max drive current, max force during movement, idle offset, - # last measured (all AD values), and force in mN (firmware-calibrated). - match = re.search(r"rh\s*-?\d+\s+-?\d+\s+-?\d+\s+-?\d+\s+(-?\d+)", resp or "") - if match is None: - raise RuntimeError(f"unexpected RH response: {resp!r}") - return round(int(match.group(1)) / 1000.0, 3) - - # -------------- 3.18 Cover and port control -------------- - - async def lock_cover(self): - """Lock cover""" - - return await self.send_command(module="C0", command="CO") - - async def unlock_cover(self): - """Unlock cover""" - - return await self.send_command(module="C0", command="HO") - - async def disable_cover_control(self): - """Disable cover control""" - - return await self.send_command(module="C0", command="CD") - - async def enable_cover_control(self): - """Enable cover control""" - - return await self.send_command(module="C0", command="CE") - - async def set_cover_output(self, output: int = 0): - """Set cover output - - Args: - output: 1 = cover lock; 2 = reserve out; 3 = reserve out. - """ - - assert 1 <= output <= 3, "output must be between 1 and 3" - return await self.send_command(module="C0", command="OS", on=output) - - async def reset_output(self, output: int = 0): - """Reset output - - Returns: - output: 1 = cover lock; 2 = reserve out; 3 = reserve out. - """ - - assert 1 <= output <= 3, "output must be between 1 and 3" - return await self.send_command(module="C0", command="QS", on=output, fmt="#") - - async def request_cover_open(self) -> bool: - """Request cover open - - Returns: True if the cover is open - """ - - resp = await self.send_command(module="C0", command="QC", fmt="qc#") - return bool(resp["qc"]) - - # -------------- Extra - Probing labware with STAR - making STAR into a CMM -------------- - - y_drive_mm_per_increment = 0.046302083 - z_drive_mm_per_increment = 0.01072765 - - dispensing_drive_vol_per_increment = 0.046876 # uL / increment - dispensing_drive_mm_per_increment = 0.002734375 - - @staticmethod - def mm_to_y_drive_increment(value_mm: float) -> int: - return round(value_mm / STARBackend.y_drive_mm_per_increment) - - @staticmethod - def y_drive_increment_to_mm(value_mm: int) -> float: - return round(value_mm * STARBackend.y_drive_mm_per_increment, 2) - - @staticmethod - def mm_to_z_drive_increment(value_mm: float) -> int: - return round(value_mm / STARBackend.z_drive_mm_per_increment) - - @staticmethod - def z_drive_increment_to_mm(value_increments: int) -> float: - return round(value_increments * STARBackend.z_drive_mm_per_increment, 2) - - # Dispensing drive conversions - # --- uL <-> increments --- - @staticmethod - def dispensing_drive_vol_to_increment(volume: float) -> int: - return round(volume / STARBackend.dispensing_drive_vol_per_increment) - - @staticmethod - def dispensing_drive_increment_to_volume(position_increment: int) -> float: - return round(position_increment * STARBackend.dispensing_drive_vol_per_increment, 1) - - # --- mm <-> increments --- - @staticmethod - def dispensing_drive_mm_to_increment(position_mm: float) -> int: - return round(position_mm / STARBackend.dispensing_drive_mm_per_increment) - - @staticmethod - def dispensing_drive_increment_to_mm(position_increment: int) -> float: - return round(position_increment * STARBackend.dispensing_drive_mm_per_increment, 3) - - # --- uL <-> mm --- - @staticmethod - def dispensing_drive_vol_to_mm(vol: float) -> float: - inc = STARBackend.dispensing_drive_vol_to_increment(vol) - return STARBackend.dispensing_drive_increment_to_mm(inc) - - @staticmethod - def dispensing_drive_mm_to_vol(position_mm: float) -> float: - inc = STARBackend.dispensing_drive_mm_to_increment(position_mm) - return STARBackend.dispensing_drive_increment_to_volume(inc) - - async def clld_probe_x_position_using_channel( - self, - channel_idx: int, # 0-based indexing of channels! - probing_direction: Literal["right", "left"], - end_pos_search: Optional[float] = None, # mm - post_detection_dist: float = 2.0, # mm, - tip_bottom_diameter: float = 1.2, # mm - read_timeout=240.0, # seconds - ) -> float: - """ - Probe the x-position of a conductive material using a channel's capacitive liquid - level detection (cLLD) via a lateral X scan. - - Starting from the channel's current X position, the channel is moved laterally in - the specified direction using the XL command until cLLD triggers or the configured - end position is reached. After the scan, the channel is retracted inward by - `post_detection_dist`. - - The returned value is a first-order geometric estimate of the material boundary, - corrected by half the tip bottom diameter assuming cylindrical tip contact. - - Notes: - - The XL command does not report whether cLLD triggered; reaching the end position is indistinguishable from a successful detection. - - This function assumes cLLD triggers before `end_pos_search`. - - Preconditions: - - The channel must already be at a Z height safe for lateral X motion. - - The current X position must be consistent with `probing_direction`. - - Side effects: - - Moves the specified channel in X. - - Leaves the channel retracted from the detected object. - - Returns: - Estimated x-position of the detected material boundary in millimeters. - """ - - assert channel_idx in range(self.num_channels), ( - f"Channel index must be between 0 and {self.num_channels - 1}, is {channel_idx}." - ) - assert probing_direction in [ - "right", - "left", - ], f"Probing direction must be either 'right' or 'left', is {probing_direction}." - assert post_detection_dist >= 0.0, ( - f"Post-detection distance must be non-negative, is {post_detection_dist} mm." - "(always marks a movement away from the detected material)." - ) - - # TODO: Anti-channel-crash feature -> use self.deck with recursive logic - current_x_position = await self.request_x_pos_channel_n(channel_idx) - # y_position = await self.request_y_pos_channel_n(channel_idx) - # current_z_position = await self.request_z_pos_channel_n(channel_idx) - - # Use identified rail number to calculate possible upper limit: - # STAR = 95 - 1415 mm, STARlet = 95 - 800mm - num_rails = self.extended_conf.instrument_size_slots - track_width = 22.5 # mm - reachable_dist_to_last_rail = 125.0 - - max_safe_upper_x_pos = num_rails * track_width + reachable_dist_to_last_rail - max_safe_lower_x_pos = 95.0 # unit: mm - - if end_pos_search is None: - if probing_direction == "right": - end_pos_search = max_safe_upper_x_pos - else: # probing_direction == "left" - end_pos_search = max_safe_lower_x_pos - else: - assert max_safe_lower_x_pos <= end_pos_search <= max_safe_upper_x_pos, ( - f"End position for x search must be between " - f"{max_safe_lower_x_pos} and {max_safe_upper_x_pos} mm, " - f"is {end_pos_search} mm." - ) - - # Assert probing direction matches start and end positions - if probing_direction == "right": - assert current_x_position < end_pos_search, ( - f"Current position ({current_x_position} mm) must be less than " - + f"end position ({end_pos_search} mm) when probing right." - ) - else: # probing_direction == "left" - assert current_x_position > end_pos_search, ( - f"Current position ({current_x_position} mm) must be greater than " - + f"end position ({end_pos_search} mm) when probing left." - ) - - # Move channel in x until cLLD (Note: does not return detected x-position!) - await self.send_command( - module="C0", - command="XL", - xs=f"{int(round(end_pos_search * 10)):05}", - read_timeout=read_timeout, - ) - - sensor_triggered_x_pos = await self.request_x_pos_channel_n(channel_idx) - - # Move channel post-detection - if probing_direction == "left": - final_x_pos = sensor_triggered_x_pos + post_detection_dist - - # tip_bottom_diameter geometric correction assuming cylindrical tip contact - material_x_pos = sensor_triggered_x_pos - tip_bottom_diameter / 2 - - else: # probing_direction == "right" - final_x_pos = sensor_triggered_x_pos - post_detection_dist - - material_x_pos = sensor_triggered_x_pos + tip_bottom_diameter / 2 - - # Move away from detected object to avoid mechanical interference - # e.g. touch carrier, then carrier moves -> friction on channel! - await self.move_channel_x(x=final_x_pos, channel=channel_idx) - - return round(material_x_pos, 1) - - async def clld_probe_y_position_using_channel( - self, - channel_idx: int, # 0-based indexing of channels! - probing_direction: Literal["forward", "backward"], - start_pos_search: Optional[float] = None, # mm - end_pos_search: Optional[float] = None, # mm - channel_speed: float = 10.0, # mm/sec - channel_acceleration_int: Literal[1, 2, 3, 4] = 4, # * 5_000 steps/sec**2 == 926 mm/sec**2 - detection_edge: int = 10, - current_limit_int: Literal[1, 2, 3, 4, 5, 6, 7] = 7, - post_detection_dist: float = 2.0, # mm, - tip_bottom_diameter: float = 1.2, # mm - ) -> float: - """ - Probe the y-position of a conductive material using the channel's capacitive Liquid Level - Detection (cLLD). - - This method carefully moves a specified STAR channel along the y-axis to detect the presence - of a conductive surface. It uses STAR's built-in capacitive sensing to measure where the - needle tip first encounters the material, applying safety checks to prevent channel collisions - with adjacent channels. After detection, the channel is retracted by a configurable safe - distance (`post_detection_dist`) to avoid mechanical interference. - - By default, the parameter `tip_bottom_diameter` assumes STAR's **integrated teaching needles**, - which feature an extended, straight bottom section. The correction accounts for the needle's - geometry by adjusting the final reported material y-position to represent the material center - rather than the conductive detection edge. If you are using different tips or needle designs - (e.g., conical tips or third-party teaching needles), you should adapt the - `tip_bottom_diameter` value to reflect their actual geometry. - - Args: - channel_idx: Index of the channel to probe (0-based). The backmost channel is 0. - probing_direction: Direction of probing: - - "forward" decreases y-position, - - "backward" increases y-position. - start_pos_search: Initial y-position for the search (in mm). If not set, defaults to the current channel y-position. - end_pos_search: Final y-position for the search (in mm). If not set, defaults to the maximum safe travel range. - channel_speed: Channel movement speed during probing (mm/sec). Defaults to 10.0 mm/sec. - channel_acceleration_int: Acceleration ramp setting [1-4], where the physical acceleration is `value * 5,000 steps/sec**2`. Defaults to 4. - detection_edge: Edge steepness for capacitive detection [0-1024]. Defaults to 10. - current_limit_int: Current limit setting [1-7]. Defaults to 7. - post_detection_dist: Retraction distance after detection (in mm). Defaults to 2.0 mm. - tip_bottom_diameter: Effective diameter of the needle/tip bottom (in mm). Defaults to 1.2 mm, corresponding to STAR's integrated teaching needles. - - Returns: - The corrected y-position (in mm) of the detected conductive material, adjusted for the specified `tip_bottom_diameter`. - - Raises: - ValueError: - - If `probing_direction` is invalid. - - If `start_pos_search` or `end_pos_search` is outside the safe range. - - If the configured end position conflicts with the probing direction. - - If no conductive material is detected. - """ - - assert probing_direction in [ - "forward", - "backward", - ], f"Probing direction must be either 'forward' or 'backward', is {probing_direction}." - - # Anti-channel-crash feature - if channel_idx > 0: - adj_upper_y = await self.request_y_pos_channel_n(channel_idx - 1) - max_safe_upper_y_pos = adj_upper_y - self._min_spacing_between(channel_idx, channel_idx - 1) - else: - max_safe_upper_y_pos = self.extended_conf.pip_maximal_y_position - - if channel_idx < (self.num_channels - 1): - adj_lower_y = await self.request_y_pos_channel_n(channel_idx + 1) - max_safe_lower_y_pos = adj_lower_y + self._min_spacing_between(channel_idx, channel_idx + 1) - else: - max_safe_lower_y_pos = self.extended_conf.left_arm_min_y_position - - # Enable safe start and end positions - if start_pos_search: - assert max_safe_lower_y_pos <= start_pos_search <= max_safe_upper_y_pos, ( - f"Start position for y search must be between \n{max_safe_lower_y_pos} and " - + f"{max_safe_upper_y_pos} mm, is {end_pos_search} mm. Otherwise channel will crash." - ) - await self.move_channel_y(y=start_pos_search, channel=channel_idx) - - if end_pos_search: - assert max_safe_lower_y_pos <= end_pos_search <= max_safe_upper_y_pos, ( - f"End position for y search must be between \n{max_safe_lower_y_pos} and " - + f"{max_safe_upper_y_pos} mm, is {end_pos_search} mm. Otherwise channel will crash." - ) - - # Set safe y-search end position based on the probing direction - current_channel_y_pos = await self.request_y_pos_channel_n(channel_idx) - if probing_direction == "backward": - max_y_search_pos = end_pos_search or max_safe_upper_y_pos - if max_y_search_pos < current_channel_y_pos: - raise ValueError( - f"Channel {channel_idx} cannot move forward: " - f"End position = {max_y_search_pos} < current position = {current_channel_y_pos}" - f"\nDid you mean to move forward?" - ) - else: # probing_direction == "forward" - max_y_search_pos = end_pos_search or max_safe_lower_y_pos - if max_y_search_pos > current_channel_y_pos: - raise ValueError( - f"Channel {channel_idx} cannot move forward: " - f"End position = {max_y_search_pos} > current position = {current_channel_y_pos}" - f"\nDid you mean to move backward?" - ) - - # Convert mm to increments - max_y_search_pos_increments = STAR.mm_to_y_drive_increment(max_y_search_pos) - channel_speed_increments = STAR.mm_to_y_drive_increment(channel_speed) - - # Machine-compatibility check of calculated parameters - assert 0 <= max_y_search_pos_increments <= 13_714, ( - "Maximum y search position must be between 0 and " - + f"{STARBackend.y_drive_increment_to_mm(13_714):.1f} mm, " - + f"is {max_y_search_pos:.1f} mm" - ) - assert 20 <= channel_speed_increments <= 8_000, ( - f"LLD search speed must be between \n{STARBackend.y_drive_increment_to_mm(20)}" - + f"and {STARBackend.y_drive_increment_to_mm(8_000)} mm/sec, is {channel_speed} mm/sec" - ) - assert channel_acceleration_int in [1, 2, 3, 4], ( - "Channel speed must be in [1, 2, 3, 4] (* 5_000 steps/sec**2)" - + f", is {channel_speed} mm/sec" - ) - assert 0 <= detection_edge <= 1_023, ( - "Edge steepness at capacitive LLD detection must be between 0 and 1023" - ) - assert 0 <= current_limit_int <= 7, ( - f"Current limit must be in [0, 1, 2, 3, 4, 5, 6, 7], is {channel_speed} mm/sec" - ) - - # Move channel for cLLD (Note: does not return detected y-position!) - await self.send_command( - module=STARBackend.channel_id(channel_idx), - command="YL", - ya=f"{max_y_search_pos_increments:05}", # Maximum search position [steps] - gt=f"{detection_edge:04}", # Edge steepness at capacitive LLD detection - gl=f"{0:04}", # Offset after edge detection -> always 0 to measure y-pos! - yv=f"{channel_speed_increments:04}", # Max speed [steps/second] - yr=f"{channel_acceleration_int}", # Acceleration ramp [yr * 5_000 steps/second**2] - yw=f"{current_limit_int}", # Current limit - read_timeout=120, # default 30 seconds is often not enough - ) - - detected_material_y_pos = await self.request_y_pos_channel_n(channel_idx) - - # Dynamically evaluate post-detection distance to avoid crashes - if probing_direction == "backward": - if channel_idx < self.num_channels - 1: - min_y = await self.request_y_pos_channel_n(channel_idx + 1) + self._min_spacing_between( - channel_idx, channel_idx + 1 - ) - else: - min_y = self.extended_conf.left_arm_min_y_position - - max_safe_dist = detected_material_y_pos - min_y - move_target = detected_material_y_pos - min(post_detection_dist, max_safe_dist) - - else: # probing_direction == "forward" - if channel_idx > 0: - max_y = await self.request_y_pos_channel_n(channel_idx - 1) - self._min_spacing_between( - channel_idx, channel_idx - 1 - ) - else: - max_y = self.extended_conf.pip_maximal_y_position - - max_safe_dist = max_y - detected_material_y_pos - move_target = detected_material_y_pos + min(post_detection_dist, max_safe_dist) - - await self.move_channel_y(y=move_target, channel=channel_idx) - - # Correct for tip_bottom_diameter - if probing_direction == "backward": - material_y_pos = detected_material_y_pos + tip_bottom_diameter / 2 - else: # probing_direction == "forward" - material_y_pos = detected_material_y_pos - tip_bottom_diameter / 2 - - return round(material_y_pos, 1) - - async def _move_z_drive_to_liquid_surface_using_clld( - self, - channel_idx: int, # 0-based indexing of channels! - lowest_immers_pos: float = 99.98, # mm - start_pos_search: float = 334.7, # mm - channel_speed: float = 10.0, # mm - channel_acceleration: float = 800.0, # mm/sec**2 - detection_edge: int = 10, - detection_drop: int = 2, - post_detection_trajectory: Literal[0, 1] = 1, - post_detection_dist: float = 2.0, # mm - ): - """Move the tip on a channel to the liquid surface using capacitive LLD (cLLD). - - Runs a downward capacitive liquid-level detection (cLLD) search on the specified - 0-indexed channel. The search will not go below lowest_immers_pos. After detection, - the channel performs the configured post-detection move (by default retracting 2.0 mm). - - This is a low level method that takes parameters in "head space", not using the tip length. - - Args: - channel_idx: Channel index (0-based). - lowest_immers_pos: Lowest allowed search position in mm (hard stop). Defaults to 99.98. - start_pos_search: Search start position in mm. If None, computed from tip length. - channel_speed: Search speed in mm/s. Defaults to 10.0. - channel_acceleration: Search acceleration in mm/s^2. Defaults to 800.0. - detection_edge: Edge steepness threshold for cLLD detection (0-1023). Defaults to 10. - detection_drop: Offset applied after cLLD edge detection (0-1023). Defaults to 2. - post_detection_trajectory: Instrument post-detection move mode (0 or 1). Defaults to 1. - post_detection_dist: Distance in mm to move after detection (interpreted per trajectory). - Defaults to 2.0. - - Raises: - ValueError: If channel_idx is out of range. - RuntimeError: If no tip is mounted on channel_idx. - AssertionError: If any parameter is outside the instrument-supported range. - """ - - # Preconditions checks - # Ensure valid channel index - if not isinstance(channel_idx, int) or not (0 <= channel_idx <= self.num_channels - 1): - raise ValueError(f"channel_idx must be in [0, {self.num_channels - 1}], is {channel_idx}") - - # Conversions & machine-compatibility check of parameters - lowest_immers_pos_increments = STARBackend.mm_to_z_drive_increment(lowest_immers_pos) - start_pos_search_increments = STARBackend.mm_to_z_drive_increment(start_pos_search) - channel_speed_increments = STARBackend.mm_to_z_drive_increment(channel_speed) - channel_acceleration_thousand_increments = STARBackend.mm_to_z_drive_increment( - channel_acceleration / 1000 - ) - post_detection_dist_increments = STARBackend.mm_to_z_drive_increment(post_detection_dist) - - assert 9_320 <= lowest_immers_pos_increments <= 31_200, ( - f"Lowest immersion position must be between \n{STARBackend.z_drive_increment_to_mm(9_320)}" - + f" and {STARBackend.z_drive_increment_to_mm(31_200)} mm, is {lowest_immers_pos} mm" - ) - assert 9_320 <= start_pos_search_increments <= 31_200, ( - f"Start position of LLD search must be between \n{STARBackend.z_drive_increment_to_mm(9_320)}" - + f" and {STARBackend.z_drive_increment_to_mm(31_200)} mm, is {start_pos_search} mm" - ) - assert 20 <= channel_speed_increments <= 15_000, ( - f"LLD search speed must be between \n{STARBackend.z_drive_increment_to_mm(20)}" - + f"and {STARBackend.z_drive_increment_to_mm(15_000)} mm/sec, is {channel_speed} mm/sec" - ) - assert 5 <= channel_acceleration_thousand_increments <= 150, ( - f"Channel acceleration must be between \n{STARBackend.z_drive_increment_to_mm(5 * 1_000)} " - + f" and {STARBackend.z_drive_increment_to_mm(150 * 1_000)} mm/sec**2, is {channel_acceleration} mm/sec**2" - ) - assert 0 <= detection_edge <= 1_023, ( - "Edge steepness at capacitive LLD detection must be between 0 and 1023" - ) - assert 0 <= detection_drop <= 1_023, ( - "Offset after capacitive LLD edge detection must be between 0 and 1023" - ) - assert 0 <= post_detection_dist_increments <= 9_999, ( - "Post cLLD-detection movement distance must be between \n0" - + f" and {STARBackend.z_drive_increment_to_mm(9_999)} mm, is {post_detection_dist} mm" - ) - - await self.send_command( - module=STARBackend.channel_id(channel_idx), - command="ZL", - zh=f"{lowest_immers_pos_increments:05}", # Lowest immersion position [increment] - zc=f"{start_pos_search_increments:05}", # Start position of LLD search [increment] - zl=f"{channel_speed_increments:05}", # Speed of channel movement - zr=f"{channel_acceleration_thousand_increments:03}", # Acceleration [1000 increment/second^2] - gt=f"{detection_edge:04}", # Edge steepness at capacitive LLD detection - gl=f"{detection_drop:04}", # Offset after capacitive LLD edge detection - zj=post_detection_trajectory, # Movement of the channel after contacting surface - zi=f"{post_detection_dist_increments:04}", # Distance to move up after detection [increment] - ) - - async def clld_probe_z_height_using_channel( - self, - channel_idx: int, # 0-based indexing of channels! - lowest_immers_pos: float = 99.98, - start_pos_search: Optional[float] = None, - channel_speed: float = 10.0, - channel_acceleration: float = 800.0, - detection_edge: int = 10, - detection_drop: int = 2, - post_detection_trajectory: Literal[0, 1] = 1, - post_detection_dist: float = 2.0, - move_channels_to_safe_pos_after: bool = False, - ) -> float: - """Probe the liquid surface Z-height using a channel's capacitive LLD (cLLD). - - Uses the specified channel to perform a downward cLLD search and returns the - last liquid level detected by the instrument for that channel. - - This helper is responsible for: - - Ensuring a tip is mounted on the chosen channel. - - Reading the mounted tip length and applying the fixed fitting depth (8 mm) - to convert *tip-referenced* Z positions (C0-style coordinates) into the - channel Z-drive coordinates required by the firmware `ZL` cLLD command. - - Optionally moving channels to a Z-safe position after probing. - - Note: - cLLD requires a conductive target (e.g., conductive liquid / surface). - - Args: - channel_idx: Channel index to probe with (0-based; backmost channel = 0). - lowest_immers_pos: Lowest allowed search position in mm, expressed in the *tip-referenced* coordinate system (i.e., the position you would use for commands that include tip length). Internally converted to channel Z-drive coordinates before issuing `ZL`. - start_pos_search: Start position for the cLLD search in mm, expressed in the *tip-referenced* coordinate system. Internally converted to channel Z-drive coordinates before issuing `ZL`. If None, the highest safe position is used based on tip length. - channel_speed: Search speed in mm/s. Defaults to 10.0. - channel_acceleration: Search acceleration in mm/s^2. Defaults to 800.0. - detection_edge: Edge steepness threshold for cLLD detection (0-1023). Defaults to 10. - detection_drop: Offset applied after cLLD edge detection (0-1023). Defaults to 2. - post_detection_trajectory: Firmware post-detection move mode (0 or 1). Defaults to 1. - post_detection_dist: Distance in mm to move after detection (interpreted per trajectory). Defaults to 2.0. - move_channels_to_safe_pos_after: If True, moves all channels to a Z-safe position after the probing sequence completes. - - Raises: - RuntimeError: If no tip is mounted on `channel_idx`. - ValueError: If the computed start position is outside the allowed safe range. - STARFirmwareError: If the firmware reports an error during cLLD (channels are moved to Z-safe before re-raising). - - Returns: - The detected liquid surface Z-height in mm as reported by `request_pip_height_last_lld()` for `channel_idx`. - """ - - # Ensure tip is mounted - tip_presence = await self.request_tip_presence() - if not tip_presence[channel_idx]: - raise RuntimeError(f"No tip mounted on channel {channel_idx}") - - # Compute the highest position the tip can start the search from based on the known highest head position - tip_len = await self.request_tip_len_on_channel(channel_idx) - safe_tip_top_z_pos = ( - STARBackend.MAXIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH - ) # head space -> tip space - - if start_pos_search is None: - start_pos_search = safe_tip_top_z_pos - - # Check if lowest_immers_pos is allowed - if lowest_immers_pos < STARBackend.MINIMUM_CHANNEL_Z_POSITION: - raise ValueError(f"lowest_immers_pos must be at least 99.98 mm but is {lowest_immers_pos} mm") - - # Correct for tip length + fitting depth (low level command is in head space, we are in tip space) - lowest_immers_pos_head_space = ( - lowest_immers_pos + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH - ) # tip space -> head space - channel_head_start_pos = round( - start_pos_search + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH, 2 - ) - - # Check that start position is within allowed range - if not (lowest_immers_pos <= start_pos_search <= safe_tip_top_z_pos): - raise ValueError( - f"Start position of LLD search must be between \n{lowest_immers_pos} and {safe_tip_top_z_pos} mm, is {start_pos_search} mm" - ) - - try: - await self._move_z_drive_to_liquid_surface_using_clld( - channel_idx=channel_idx, - lowest_immers_pos=lowest_immers_pos_head_space, - start_pos_search=channel_head_start_pos, - channel_speed=channel_speed, - channel_acceleration=channel_acceleration, - detection_edge=detection_edge, - detection_drop=detection_drop, - post_detection_trajectory=post_detection_trajectory, - post_detection_dist=post_detection_dist, - ) - except STARFirmwareError: - await self.move_all_channels_in_z_safety() - raise - - if move_channels_to_safe_pos_after: - await self.move_all_channels_in_z_safety() - - current_absolute_liquid_heights = await self.request_pip_height_last_lld() - return current_absolute_liquid_heights[channel_idx] - - async def _search_for_surface_using_plld( - self, - channel_idx: int, # 0-based indexing of channels! - lowest_immers_pos: float = 99.98, # mm of the head_probe! - start_pos_search: float = 334.7, # mm of the head_probe! - channel_speed_above_start_pos_search: float = 120.0, # mm/sec - channel_speed: float = 10.0, # mm - channel_acceleration: float = 800.0, # mm/sec**2 - z_drive_current_limit: int = 3, - tip_has_filter: bool = False, - dispense_drive_speed: float = 5.0, # mm/sec - dispense_drive_acceleration: float = 0.2, # mm/sec**2 - dispense_drive_max_speed: float = 14.5, # mm/sec - dispense_drive_current_limit: int = 3, - plld_detection_edge: int = 30, - plld_detection_drop: int = 10, - clld_verification: bool = False, # cLLD Verification feature - clld_detection_edge: int = 10, # cLLD Verification feature - clld_detection_drop: int = 2, # cLLD Verification feature - max_delta_plld_clld: float = 5.0, # cLLD Verification feature; mm - plld_mode: Optional[PressureLLDMode] = None, # Foam feature - plld_foam_detection_drop: int = 30, # Foam feature - plld_foam_detection_edge_tolerance: int = 30, # Foam feature - plld_foam_ad_values: int = 30, # Foam feature; unknown unit - plld_foam_search_speed: float = 10.0, # Foam feature; mm/sec - dispense_back_plld_volume: Optional[float] = None, # uL - post_detection_trajectory: Literal[0, 1] = 1, - post_detection_dist: float = 2.0, # mm - ) -> Tuple[float, float]: - """Search a surface using pressured-based liquid level detection (pLLD) - (1) with or (2) without additional cLLD verification, and (a) with foam detection sub-mode or - (b) without foam detection sub-mode. - - Notes: - - This command is implemented via the PX command module, i.e. it IS parallelisable - - lowest_immers_pos & start_pos_search refer to the head_probe z-coordinate (not the tip) - - The return values represent head_probe z-positions (not the tip) in mm - - Args: - lowest_immers_pos: Lowest allowed Z during the search (mm). Default 99.98. - start_pos_search: Z position where the search begins (mm). Default 334.7. - channel_speed_above_start_pos_search: Z speed above the start position (mm/s). Default 120.0. - channel_speed: Z search speed (mm/s). Default 10.0. - channel_acceleration: Z acceleration (mm/s**2). Default 800.0. - z_drive_current_limit: Z drive current limit (instrument units). Default 3. - tip_has_filter: Whether a filter tip is mounted. Default False. - dispense_drive_speed: Dispense drive speed (mm/s). Default 5.0. - dispense_drive_acceleration: Dispense drive acceleration (mm/s**2). Default 0.2. - dispense_drive_max_speed: Dispense drive max speed (mm/s). Default 14.5. - dispense_drive_current_limit: Dispense drive current limit (instrument units). Default 3. - plld_detection_edge: Pressure detection edge threshold. Default 30. - plld_detection_drop: Pressure detection drop threshold. Default 10. - clld_verification: Activates cLLD sensing concurrently with the pressure probing. Note: cLLD - measurement itself cannot be retrieved. Instead it can be used for other applications, including - (1) verification of the surface level detected by pLLD based on max_delta_plld_clld, - (2) detection of foam (more easily triggers cLLD), if desired, causing an error. - This activates all cLLD-specific arguments. Default False. - max_delta_plld_clld: Max allowed delta between pressure/capacitive detections (mm). Default 5.0. - clld_detection_edge: Capacitive detection edge threshold. Default 10. - clld_detection_drop: Capacitive detection drop threshold. Default 2. - plld_mode: Pressure-detection sub-mode (instrument-defined). Default None. - plld_foam_detection_drop: Foam detection drop threshold. Default 30. - plld_foam_detection_edge_tolerance: Foam detection edge tolerance. Default 30. - plld_foam_ad_values: Foam AD values (instrument units). Default 30. - plld_foam_search_speed: Foam search speed (mm/s). Default 10.0. - dispense_back_plld_volume: Optional dispense-back volume after detection (uL). Default None. - post_detection_trajectory: Post-detection movement pattern selector. Default 1. - post_detection_dist: Post-detection movement distance (mm). Default 2.0. - - Returns: - Two z-coordinates (mm), head_probe, meaning depends on the selected pressure sub-mode: - - Single-detection modes/PressureLLDMode.LIQUID: (liquid_level_pos, 0) - - Two-detection modes/PressureLLDMode.FOAM: (first_detection_pos, liquid_level_pos) - """ - - # Preconditions checks - # Ensure valid channel index - if not isinstance(channel_idx, int) or not (0 <= channel_idx <= self.num_channels - 1): - raise ValueError(f"channel_idx must be in [0, {self.num_channels - 1}], is {channel_idx}") - - if plld_mode is None: - plld_mode = self.PressureLLDMode.LIQUID - - if dispense_back_plld_volume is None: - dispense_back_plld_volume_mode = 0 - dispense_back_plld_volume_increments = 0 - else: - dispense_back_plld_volume_mode = 1 - dispense_back_plld_volume_increments = STARBackend.dispensing_drive_vol_to_increment( - dispense_back_plld_volume - ) - - # Conversions to machine units - lowest_immers_pos_increments = STARBackend.mm_to_z_drive_increment(lowest_immers_pos) - start_pos_search_increments = STARBackend.mm_to_z_drive_increment(start_pos_search) - - channel_speed_above_start_pos_search_increments = STARBackend.mm_to_z_drive_increment( - channel_speed_above_start_pos_search - ) - channel_speed_increments = STARBackend.mm_to_z_drive_increment(channel_speed) - channel_acceleration_thousand_increments = STARBackend.mm_to_z_drive_increment( - channel_acceleration / 1000 - ) - - dispense_drive_speed_increments = STARBackend.dispensing_drive_mm_to_increment( - dispense_drive_speed - ) - dispense_drive_acceleration_increments = STARBackend.dispensing_drive_mm_to_increment( - dispense_drive_acceleration - ) - dispense_drive_max_speed_increments = STARBackend.dispensing_drive_mm_to_increment( - dispense_drive_max_speed - ) - - post_detection_dist_increments = STARBackend.mm_to_z_drive_increment(post_detection_dist) - max_delta_plld_clld_increments = STARBackend.mm_to_z_drive_increment(max_delta_plld_clld) - - plld_foam_search_speed_increments = STARBackend.mm_to_z_drive_increment(plld_foam_search_speed) - - # Machine-compatibility parameter checks - assert 9320 <= lowest_immers_pos_increments <= 31_200, ( - f"Lowest immersion position must be between \n{STARBackend.z_drive_increment_to_mm(9_320)}" - + f" and {STARBackend.z_drive_increment_to_mm(31_200)} mm, is {lowest_immers_pos} mm" - ) - assert 9320 <= start_pos_search_increments <= 31_200, ( - f"Start position of LLD search must be between \n{STARBackend.z_drive_increment_to_mm(9_320)}" - + f" and {STARBackend.z_drive_increment_to_mm(31_200)} mm, is {start_pos_search} mm" - ) - - assert tip_has_filter in [True, False], "tip_has_filter must be a boolean" - - assert isinstance(clld_verification, bool), ( - f"clld_verification must be a boolean, is {clld_verification}" - ) - - assert plld_mode in [self.PressureLLDMode.LIQUID, self.PressureLLDMode.FOAM], ( - f"plld_mode must be either PressureLLDMode.LIQUID ({self.PressureLLDMode.LIQUID}) or " - + f"PressureLLDMode.FOAM ({self.PressureLLDMode.FOAM}), is {plld_mode}" - ) - - assert 20 <= channel_speed_above_start_pos_search_increments <= 15_000, ( - "Speed above start position of LLD search must be between \n" - + f"{STARBackend.z_drive_increment_to_mm(20)} and " - + f"{STARBackend.z_drive_increment_to_mm(15_000)} mm/sec, is " - + f"{channel_speed_above_start_pos_search} mm/sec" - ) - assert 20 <= channel_speed_increments <= 15_000, ( - f"LLD search speed must be between \n{STARBackend.z_drive_increment_to_mm(20)}" - + f"and {STARBackend.z_drive_increment_to_mm(15_000)} mm/sec, is {channel_speed} mm/sec" - ) - assert 5 <= channel_acceleration_thousand_increments <= 150, ( - f"Channel acceleration must be between \n{STARBackend.z_drive_increment_to_mm(5 * 1_000)} " - + f" and {STARBackend.z_drive_increment_to_mm(150 * 1_000)} mm/sec**2, is {channel_acceleration} mm/sec**2" - ) - assert 0 <= z_drive_current_limit <= 7, ( - f"Z-drive current limit must be between 0 and 7, is {z_drive_current_limit}" - ) - - assert 20 <= dispense_drive_speed_increments <= 13_500, ( - "Dispensing drive speed must be between \n" - + f"{STARBackend.dispensing_drive_increment_to_mm(20)} and " - + f"{STARBackend.dispensing_drive_increment_to_mm(13_500)} mm/sec, is {dispense_drive_speed} mm/sec" - ) - assert 1 <= dispense_drive_acceleration_increments <= 100, ( - "Dispensing drive acceleration must be between \n" - + f"{STARBackend.dispensing_drive_increment_to_mm(1)} and " - + f"{STARBackend.dispensing_drive_increment_to_mm(100)} mm/sec**2, is {dispense_drive_acceleration} mm/sec**2" - ) - assert 20 <= dispense_drive_max_speed_increments <= 13_500, ( - "Dispensing drive max speed must be between \n" - + f"{STARBackend.dispensing_drive_increment_to_mm(20)} and " - + f"{STARBackend.dispensing_drive_increment_to_mm(13_500)} mm/sec, is {dispense_drive_max_speed} mm/sec" - ) - assert 0 <= dispense_drive_current_limit <= 7, ( - f"Dispensing drive current limit must be between 0 and 7, is {dispense_drive_current_limit}" - ) - - assert 0 <= clld_detection_edge <= 1_023, ( - "Edge steepness at capacitive LLD detection must be between 0 and 1023" - ) - assert 0 <= clld_detection_drop <= 1_023, ( - "Offset after capacitive LLD edge detection must be between 0 and 1023" - ) - assert 0 <= plld_detection_edge <= 1_023, ( - "Edge steepness at pressure LLD detection must be between 0 and 1023" - ) - assert 0 <= plld_detection_drop <= 1_023, ( - "Offset after pressure LLD edge detection must be between 0 and 1023" - ) - - assert 0 <= max_delta_plld_clld_increments <= 9_999, ( - "Maximum allowed difference between pressure LLD and capacitive LLD detection z-positions " - + f"must be between 0 and {STARBackend.z_drive_increment_to_mm(9_999)} mm," - + f" is {max_delta_plld_clld} mm" - ) - - assert 0 <= plld_foam_detection_drop <= 1_023, ( - f"Pressure LLD foam detection drop must be between 0 and 1023, is {plld_foam_detection_drop}" - ) - assert 0 <= plld_foam_detection_edge_tolerance <= 1_023, ( - "Pressure LLD foam detection edge tolerance must be between 0 and 1023, " - + f"is {plld_foam_detection_edge_tolerance}" - ) - assert 0 <= plld_foam_ad_values <= 4_999, ( - f"Pressure LLD foam AD values must be between 0 and 4999, is {plld_foam_ad_values}" - ) - assert 20 <= plld_foam_search_speed_increments <= 13_500, ( - "Pressure LLD foam search speed must be between \n" - + f"{STARBackend.z_drive_increment_to_mm(20)} and " - + f"{STARBackend.z_drive_increment_to_mm(13_500)} mm/sec, is {plld_foam_search_speed} mm/sec" - ) - - assert dispense_back_plld_volume_mode in [0, 1], ( - "dispense_back_plld_volume_mode must be either 0 ('normal') or 1 " - + "('dispense back dispense_back_plld_volume'), " - + f"is {dispense_back_plld_volume_mode}" - ) - - assert 0 <= dispense_back_plld_volume_increments <= 26_666, ( - "Dispense back pressure LLD volume must be between \n0" - + f" and {STARBackend.dispensing_drive_increment_to_volume(26_666)} uL, is {dispense_back_plld_volume} uL" - ) - - assert 0 <= post_detection_dist_increments <= 9_999, ( - "Post cLLD-detection movement distance must be between \n0" - + f" and {STARBackend.z_drive_increment_to_mm(9_999)} mm, is {post_detection_dist} mm" - ) - - resp_raw = await self.send_command( - module=STARBackend.channel_id(channel_idx), - command="ZE", - zh=f"{lowest_immers_pos_increments:05}", - zc=f"{start_pos_search_increments:05}", - zi=f"{post_detection_dist_increments:04}", - zj=f"{post_detection_trajectory:01}", - gf=str(int(tip_has_filter)), - gt=f"{clld_detection_edge:04}", - gl=f"{clld_detection_drop:04}", - gu=f"{plld_detection_edge:04}", - gn=f"{plld_detection_drop:04}", - gm=str(int(clld_verification)), - gz=f"{max_delta_plld_clld_increments:04}", - cj=str(plld_mode.value), - co=f"{plld_foam_detection_drop:04}", - cp=f"{plld_foam_detection_edge_tolerance:04}", - cq=f"{plld_foam_ad_values:04}", - cl=f"{plld_foam_search_speed_increments:05}", - cc=str(dispense_back_plld_volume_mode), - cd=f"{dispense_back_plld_volume_increments:05}", - zv=f"{channel_speed_above_start_pos_search_increments:05}", - zl=f"{channel_speed_increments:05}", - zr=f"{channel_acceleration_thousand_increments:03}", - zw=f"{z_drive_current_limit}", - dl=f"{dispense_drive_speed_increments:05}", - dr=f"{dispense_drive_acceleration_increments:03}", - dv=f"{dispense_drive_max_speed_increments:05}", - dw=f"{dispense_drive_current_limit}", - read_timeout=max(self.read_timeout, 120), # it can take long (>30s) - ) - assert resp_raw is not None - - resp_probe_mm = [ - STARBackend.z_drive_increment_to_mm(int(return_val)) - for return_val in resp_raw.split("if")[-1].split() - ] - - # return depending on mode - return ( - (resp_probe_mm[0], 0) - if plld_mode == self.PressureLLDMode.LIQUID - else (resp_probe_mm[0], resp_probe_mm[1]) - ) - - async def plld_probe_z_height_using_channel( - self, - channel_idx: int, # 0-based indexing of channels! - lowest_immers_pos: float = 99.98, # mm - start_pos_search: Optional[float] = None, # mm - channel_speed_above_start_pos_search: float = 120.0, # mm/sec - channel_speed: float = 10.0, # mm - channel_acceleration: float = 800.0, # mm/sec**2 - z_drive_current_limit: int = 3, - tip_has_filter: bool = False, - dispense_drive_speed: float = 5.0, # mm/sec - dispense_drive_acceleration: float = 0.2, # mm/sec**2 - dispense_drive_max_speed: float = 14.5, # mm/sec - dispense_drive_current_limit: int = 3, - plld_detection_edge: int = 30, - plld_detection_drop: int = 10, - clld_verification: bool = False, # cLLD Verification feature - clld_detection_edge: int = 10, # cLLD Verification feature - clld_detection_drop: int = 2, # cLLD Verification feature - max_delta_plld_clld: float = 5.0, # cLLD Verification feature; mm - plld_mode: Optional[PressureLLDMode] = None, # Foam feature - plld_foam_detection_drop: int = 30, # Foam feature - plld_foam_detection_edge_tolerance: int = 30, # Foam feature - plld_foam_ad_values: int = 30, # Foam feature; unknown unit - plld_foam_search_speed: float = 10.0, # Foam feature; mm/sec - dispense_back_plld_volume: Optional[float] = None, # uL - post_detection_trajectory: Literal[0, 1] = 1, - post_detection_dist: float = 2.0, # mm - move_channels_to_safe_pos_after: bool = False, - ) -> Tuple[float, float]: - """Detect liquid level using pressured-based liquid level detection (pLLD) - (1) with or (2) without additional cLLD verification, and (a) with foam detection sub-mode or - (b) without foam detection sub-mode. - - Notes: - - This command is implemented via BOTH the PX and C0 command modules, i.e. it is NOT parallelisable! - - lowest_immers_pos & start_pos_search refer to the tip z-coordinate (not the head_probe)! - - The return values represent tip z-positions (not the head_probe) in mm! - - Args: - lowest_immers_pos: Lowest allowed search position in mm, expressed in the *tip-referenced* coordinate system (i.e., the position you would use for commands that include tip length). Internally converted to channel Z-drive coordinates before issuing `ZL`. - start_pos_search: Start position for the cLLD search in mm, expressed in the *tip-referenced* coordinate system. Internally converted to channel Z-drive coordinates before issuing `ZL`. If None, the highest safe position is used based on tip length. - channel_speed_above_start_pos_search: Z speed above the start position (mm/s). Default 120.0. - channel_speed: Z search speed (mm/s). Default 10.0. - channel_acceleration: Z acceleration (mm/s**2). Default 800.0. - z_drive_current_limit: Z drive current limit (instrument units). Default 3. - tip_has_filter: Whether a filter tip is mounted. Default False. - dispense_drive_speed: Dispense drive speed (mm/s). Default 5.0. - dispense_drive_acceleration: Dispense drive acceleration (mm/s**2). Default 0.2. - dispense_drive_max_speed: Dispense drive max speed (mm/s). Default 14.5. - dispense_drive_current_limit: Dispense drive current limit (instrument units). Default 3. - plld_detection_edge: Pressure detection edge threshold. Default 30. - plld_detection_drop: Pressure detection drop threshold. Default 10. - clld_verification: Activates cLLD sensing concurrently with the pressure probing. Note: cLLD - measurement itself cannot be retrieved. Instead it can be used for other applications, including - (1) verification of the surface level detected by pLLD based on max_delta_plld_clld, - (2) detection of foam (more easily triggers cLLD), if desired causing and error. - This activates all cLLD-specific arguments. Default False. - clld_detection_edge: Capacitive detection edge threshold. Default 10. - clld_detection_drop: Capacitive detection drop threshold. Default 2. - max_delta_plld_clld: Max allowed delta between pressure/capacitive detections (mm). Default 5.0. - plld_mode: Pressure-detection sub-mode (instrument-defined). Default None. - plld_foam_detection_drop: Foam detection drop threshold. Default 30. - plld_foam_detection_edge_tolerance: Foam detection edge tolerance. Default 30. - plld_foam_ad_values: Foam AD values (instrument units). Default 30. - plld_foam_search_speed: Foam search speed (mm/s). Default 10.0. - dispense_back_plld_volume: Optional dispense-back volume after detection (uL). Default None. - post_detection_trajectory: Post-detection movement pattern selector. Default 1. - post_detection_dist: Post-detection movement distance (mm). Default 2.0. - - Returns: - Two z-coordinates (mm), tip, meaning depends on the selected pressure sub-mode: - - Single-detection modes/PressureLLDMode.LIQUID: (liquid_level_pos, 0) - - Two-detection modes/PressureLLDMode.FOAM: (first_detection_pos, liquid_level_pos) - """ - - # Ensure tip is mounted - tip_presence = await self.request_tip_presence() - if not tip_presence[channel_idx]: - raise RuntimeError(f"No tip mounted on channel {channel_idx}") - - # Compute the highest position the tip can start the search from based on the known highest head position - tip_len = await self.request_tip_len_on_channel(channel_idx) - safe_tip_top_z_pos = ( - STARBackend.MAXIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH - ) # head space -> tip space - - if start_pos_search is None: - start_pos_search = safe_tip_top_z_pos - - # Check if lowest_immers_pos is allowed - if lowest_immers_pos < STARBackend.MINIMUM_CHANNEL_Z_POSITION: - raise ValueError(f"lowest_immers_pos must be at least 99.98 mm but is {lowest_immers_pos} mm") - - # Correct for tip length + fitting depth (low level command is in head space, we are in tip space) - lowest_immers_pos_head_space = ( - lowest_immers_pos + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH - ) # tip space -> head space - channel_head_start_pos = round( - start_pos_search + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH, 2 - ) - - # Check that start position is within allowed range - if not (lowest_immers_pos <= start_pos_search <= safe_tip_top_z_pos): - raise ValueError( - f"Start position of LLD search must be between \n{lowest_immers_pos} and {safe_tip_top_z_pos} mm, is {start_pos_search} mm" - ) - - try: - resp_probe_mm = await self._search_for_surface_using_plld( - channel_idx=channel_idx, - lowest_immers_pos=lowest_immers_pos_head_space, - start_pos_search=channel_head_start_pos, - channel_speed_above_start_pos_search=channel_speed_above_start_pos_search, - channel_speed=channel_speed, - channel_acceleration=channel_acceleration, - z_drive_current_limit=z_drive_current_limit, - tip_has_filter=tip_has_filter, - dispense_drive_speed=dispense_drive_speed, - dispense_drive_acceleration=dispense_drive_acceleration, - dispense_drive_max_speed=dispense_drive_max_speed, - dispense_drive_current_limit=dispense_drive_current_limit, - plld_detection_edge=plld_detection_edge, - plld_detection_drop=plld_detection_drop, - clld_verification=clld_verification, - clld_detection_edge=clld_detection_edge, - clld_detection_drop=clld_detection_drop, - max_delta_plld_clld=max_delta_plld_clld, - plld_mode=plld_mode, - plld_foam_detection_drop=plld_foam_detection_drop, - plld_foam_detection_edge_tolerance=plld_foam_detection_edge_tolerance, - plld_foam_ad_values=plld_foam_ad_values, - plld_foam_search_speed=plld_foam_search_speed, - dispense_back_plld_volume=dispense_back_plld_volume, - post_detection_trajectory=post_detection_trajectory, - post_detection_dist=post_detection_dist, - ) - except STARFirmwareError: - await self.move_all_channels_in_z_safety() - raise - - if plld_mode == self.PressureLLDMode.FOAM: - resp_tip_mm = ( - round(resp_probe_mm[0] - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH, 2), - round(resp_probe_mm[1] - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH, 2), - ) - else: - resp_tip_mm = ( - round(resp_probe_mm[0] - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH, 2), - 0.0, - ) - - if move_channels_to_safe_pos_after: - await self.move_all_channels_in_z_safety() - - return resp_tip_mm - - # TODO(v1): rename to request_stop_disk_z_position for consistency with - # move_channel_stop_disk_z and channels_request_stop_disk_z_positions. - async def request_probe_z_position(self, channel_idx: int) -> float: - """Request the z-position of the channel probe (EXCLUDING the tip)""" - resp = await self.send_command( - module=self.channel_id(channel_idx), command="RZ", fmt="rz######" - ) - increments = resp["rz"] - return self.z_drive_increment_to_mm(increments) - - async def request_tip_len_on_channel(self, channel_idx: int) -> float: - """Measures the length of the tip attached to the specified pipetting channel. - Checks if a tip is present on the given channel. Raises an error if no tip is present. - - Parameters: - channel_idx: Index of the pipetting channel (0-indexed). - - Returns: - The measured tip length in millimeters. - - Raises: - RuntimeError: If no tip is present on the channel. - """ - - # Check there is a tip on the channel - all_channel_occupancy = await self.request_tip_presence() - if not all_channel_occupancy[channel_idx]: - raise RuntimeError(f"No tip present on channel {channel_idx}") - - # Request z position of probe bottom - probe_position = await self.request_probe_z_position(channel_idx=channel_idx) - - # Request z-coordinate of probe+tip bottom - tip_bottom_z_coordinate = await self.request_tip_bottom_z_position(channel_idx=channel_idx) - - fitting_depth_of_all_standard_channel_tips = 8 # mm - return round( - probe_position - (tip_bottom_z_coordinate - fitting_depth_of_all_standard_channel_tips), - 1, - ) - - MAXIMUM_CHANNEL_Z_POSITION = 334.7 # mm (= z-drive increment 31_200) - MINIMUM_CHANNEL_Z_POSITION = 99.98 # mm (= z-drive increment 9_320) - DEFAULT_TIP_FITTING_DEPTH = 8 # mm, for 10, 50, 300, 1000 ul Hamilton tips - SEARCH_START_CLEARANCE_MM = 5 # mm above container top for LLD search start position - - async def ztouch_probe_z_height_using_channel( - self, - channel_idx: int, # 0-based indexing of channels! - tip_len: Optional[float] = None, # mm - lowest_immers_pos: float = 99.98, # mm - start_pos_search: Optional[float] = None, # mm - channel_speed: float = 10.0, # mm/sec - channel_acceleration: float = 800.0, # mm/sec**2 - channel_speed_upwards: float = 125.0, # mm - detection_limiter_in_PWM: int = 1, - push_down_force_in_PWM: int = 0, - post_detection_dist: float = 2.0, # mm - move_channels_to_safe_pos_after: bool = False, - ) -> float: - """Probes the Z-height below the specified channel on a Hamilton STAR liquid handling machine - using the channels 'z-touchoff' capabilities, i.e. a controlled triggering of the z-drive, - aka a controlled 'crash'. - - Args: - channel_idx: The index of the channel to use for probing. Backmost channel = 0. - tip_len: override the tip length (of tip on channel `channel_idx`). Default is the tip length - of the tip that was picked up. - lowest_immers_pos: The lowest immersion position in mm. - start_pos_lld_search: The start position for z-touch search in mm. - channel_speed: The speed of channel movement in mm/sec. - channel_acceleration: The acceleration of the channel in mm/sec**2. - detection_limiter_in_PWM: Offset PWM limiter value for searching - push_down_force_in_PWM: Offset PWM value for push down force. - cf000 = No push down force, drive is switched off. - post_detection_dist: Distance to move into the trajectory after detection in mm. - move_channels_to_safe_pos_after: Flag to move channels to a safe position after - operation. - - Returns: - The detected Z-height in mm. - """ - - version = await self.request_pip_channel_version(channel_idx) - year_matches = re.search(r"\b\d{4}\b", version) - if year_matches is not None: - year = int(year_matches.group()) - if year < 2022: - raise ValueError( - "Z-touch probing is not supported for PIP versions predating 2022, " - f"found version '{version}'" - ) - - if tip_len is None: - # currently a bug, will be fixed in the future - # reverted to previous implementation - # tip_len = self.head[channel_idx].get_tip().total_tip_length - tip_len = await self.request_tip_len_on_channel(channel_idx) - - if start_pos_search is None: - start_pos_search = ( - STARBackend.MAXIMUM_CHANNEL_Z_POSITION - tip_len + STARBackend.DEFAULT_TIP_FITTING_DEPTH - ) - - tip_len_used_in_increments = ( - tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH - ) / STARBackend.z_drive_mm_per_increment - channel_head_start_pos = ( - start_pos_search + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH - ) # start_pos of the head itself! - safe_head_bottom_z_pos = ( - STARBackend.MINIMUM_CHANNEL_Z_POSITION + tip_len - STARBackend.DEFAULT_TIP_FITTING_DEPTH - ) - safe_head_top_z_pos = STARBackend.MAXIMUM_CHANNEL_Z_POSITION - - lowest_immers_pos_increments = STARBackend.mm_to_z_drive_increment(lowest_immers_pos) - start_pos_search_increments = STARBackend.mm_to_z_drive_increment(channel_head_start_pos) - channel_speed_increments = STARBackend.mm_to_z_drive_increment(channel_speed) - channel_acceleration_thousand_increments = STARBackend.mm_to_z_drive_increment( - channel_acceleration / 1000 - ) - channel_speed_upwards_increments = STARBackend.mm_to_z_drive_increment(channel_speed_upwards) - - assert 0 <= channel_idx <= 15, f"channel_idx must be between 0 and 15, is {channel_idx}" - assert 20 <= tip_len <= 120, "Total tip length must be between 20 and 120" - - assert 9320 <= lowest_immers_pos_increments <= 31_200, ( - "Lowest immersion position must be between \n99.98" - + f" and 334.7 mm, is {lowest_immers_pos} mm" - ) - assert safe_head_bottom_z_pos <= channel_head_start_pos <= safe_head_top_z_pos, ( - f"Start position of LLD search must be between \n{safe_head_bottom_z_pos}" - + f" and {safe_head_top_z_pos} mm, is {channel_head_start_pos} mm" - ) - assert 20 <= channel_speed_increments <= 15_000, ( - f"Z-touch search speed must be between \n{STARBackend.z_drive_increment_to_mm(20)}" - + f" and {STARBackend.z_drive_increment_to_mm(15_000)} mm/sec, is {channel_speed} mm/sec" - ) - assert 5 <= channel_acceleration_thousand_increments <= 150, ( - f"Channel acceleration must be between \n{STARBackend.z_drive_increment_to_mm(5 * 1_000)}" - + f" and {STARBackend.z_drive_increment_to_mm(150 * 1_000)} mm/sec**2, is {channel_speed} mm/sec**2" - ) - assert 20 <= channel_speed_upwards_increments <= 15_000, ( - f"Channel retraction speed must be between \n{STARBackend.z_drive_increment_to_mm(20)}" - + f" and {STARBackend.z_drive_increment_to_mm(15_000)} mm/sec, is {channel_speed_upwards} mm/sec" - ) - assert 0 <= detection_limiter_in_PWM <= 125, ( - "Detection limiter value must be between 0 and 125 PWM." - ) - assert 0 <= push_down_force_in_PWM <= 125, "Push down force between 0 and 125 PWM values" - assert 0 <= post_detection_dist <= 245, ( - f"Post detection distance must be between 0 and 245 mm, is {post_detection_dist}" - ) - - lowest_immers_pos_str = f"{lowest_immers_pos_increments:05}" - start_pos_search_str = f"{start_pos_search_increments:05}" - channel_speed_str = f"{channel_speed_increments:05}" - channel_acc_str = f"{channel_acceleration_thousand_increments:03}" - channel_speed_up_str = f"{channel_speed_upwards_increments:05}" - detection_limiter_in_PWM_str = f"{detection_limiter_in_PWM:03}" - push_down_force_in_PWM_str = f"{push_down_force_in_PWM:03}" - - ztouch_probed_z_height = await self.send_command( - module=STARBackend.channel_id(channel_idx), - command="ZH", - zb=start_pos_search_str, # begin of searching range [increment] - za=lowest_immers_pos_str, # end of searching range [increment] - zv=channel_speed_up_str, # speed z-drive upper section [increment/second] - zr=channel_acc_str, # acceleration z-drive [1000 increment/second] - zu=channel_speed_str, # speed z-drive lower section [increment/second] - cg=detection_limiter_in_PWM_str, # offset PWM limiter value for searching - cf=push_down_force_in_PWM_str, # offset PWM value for push down force - fmt="rz#####", - ) - # Subtract tip_length from measurement in increment, and convert to mm - result_in_mm = STARBackend.z_drive_increment_to_mm( - ztouch_probed_z_height["rz"] - tip_len_used_in_increments - ) - if post_detection_dist != 0: # Safety first - await self.move_channel_z(z=result_in_mm + post_detection_dist, channel=channel_idx) - if move_channels_to_safe_pos_after: - await self.move_all_channels_in_z_safety() - - return float(result_in_mm) - - @staticmethod - def channel_id(channel_idx: int) -> str: - """channel_idx: plr style, 0-indexed from the back""" - channel_ids = "123456789ABCDEFG" - return "P" + channel_ids[channel_idx] - - async def get_channels_y_positions(self) -> Dict[int, float]: - """Get the Y position of all channels in mm""" - resp = await self.send_command( - module="C0", - command="RY", - fmt="ry#### (n)", - ) - y_positions = [round(y / 10, 2) for y in resp["ry"]] - - # sometimes there is (likely) a floating point error and channels are reported to be - # closer together than the minimum required spacing. (When you set channels using - # position_channels_in_y_direction, it will raise an error.) We first ensure the last - # channel is not reported in front of the known minimum Y position, then traverse the - # list in reverse and enforce the per-channel minimum spacing. - min_y = self.extended_conf.left_arm_min_y_position - if y_positions[-1] < min_y - 0.2: - raise RuntimeError( - "Channels are reported to be too close to the front of the machine. " - f"The known minimum is {min_y}, which will be fixed automatically for " - f"{min_y - 0.2} 650: - raise ValueError("Channel 0 would hit the back of the robot") - - if channel_locations[self.num_channels - 1] < 6: - raise ValueError("Channel N would hit the front of the robot") - - for i in range(len(channel_locations) - 1): - required = self._min_spacing_between(i, i + 1) - actual = channel_locations[i] - channel_locations[i + 1] - if round(actual * 1000) < round(required * 1000): # compare in um to avoid float issues - raise ValueError( - f"Channels {i} and {i + 1} must be at least {required}mm apart, " - f"but are {actual:.2f}mm apart." - ) - - yp = " ".join([f"{round(y * 10):04}" for y in channel_locations.values()]) - return await self.send_command( - module="C0", - command="JY", - yp=yp, - ) - - async def get_channels_z_positions(self) -> Dict[int, float]: - """Get the Y position of all channels in mm""" - resp = await self.send_command( - module="C0", - command="RZ", - fmt="rz#### (n)", - ) - return {channel_idx: round(y / 10, 2) for channel_idx, y in enumerate(resp["rz"])} - - async def position_channels_in_z_direction(self, zs: Dict[int, float]): - channel_locations = await self.get_channels_z_positions() - - for channel_idx, z in zs.items(): - channel_locations[channel_idx] = z - - return await self.send_command( - module="C0", command="JZ", zp=[f"{round(z * 10):04}" for z in channel_locations.values()] - ) - - async def pierce_foil( - self, - wells: Union[Well, List[Well]], - piercing_channels: List[int], - hold_down_channels: List[int], - move_inwards: float, - spread: Literal["wide", "tight"] = "wide", - one_by_one: bool = False, - distance_from_bottom: float = 20.0, - ): - """Pierce the foil of the media source plate at the specified column. Throw away the tips - after piercing because there will be a bit of foil stuck to the tips. Use this method - before aspirating from a foil-sealed plate to make sure the tips are clean and the - aspirations are accurate. - - Args: - wells: Well or wells in the plate to pierce the foil. If multiple wells, they must be on one - column. - piercing_channels: The channels to use for piercing the foil. - hold_down_channels: The channels to use for holding down the plate when moving up the - piercing channels. - spread: The spread of the piercing channels in the well. - one_by_one: If True, the channels will pierce the foil one by one. If False, all channels - will pierce the foil simultaneously. - """ - - x: float - ys: List[float] - z: float - - # if only one well is give, but in a list, convert to Well so we fall into single-well logic. - if isinstance(wells, list) and len(wells) == 1: - wells = wells[0] - - if isinstance(wells, Well): - well = wells - x, y, z = well.get_location_wrt(self.deck, "c", "c", "cavity_bottom") - - if spread == "wide": - offsets = get_wide_single_resource_liquid_op_offsets( - resource=well, - num_channels=len(piercing_channels), - min_spacing=max( - self._min_spacing_between(lo, hi) - for lo, hi in zip(sorted(piercing_channels)[:-1], sorted(piercing_channels)[1:]) - ), - ) - else: - offsets = get_tight_single_resource_liquid_op_offsets( - well, num_channels=len(piercing_channels) - ) - ys = [y + offset.y for offset in offsets] - else: - assert len(set(w.get_location_wrt(self.deck).x for w in wells)) == 1, ( - "Wells must be on the same column" - ) - absolute_center = wells[0].get_location_wrt(self.deck, "c", "c", "cavity_bottom") - x = absolute_center.x - ys = [well.get_location_wrt(self.deck, x="c", y="c").y for well in wells] - z = absolute_center.z - - await self.move_channel_x(0, x=x) - - await self.position_channels_in_y_direction( - {channel: y for channel, y in zip(piercing_channels, ys)} - ) - - zs = [z + distance_from_bottom for _ in range(len(piercing_channels))] - if one_by_one: - for channel in piercing_channels: - await self.move_channel_z(channel, z) - else: - await self.position_channels_in_z_direction( - {channel: z for channel, z in zip(piercing_channels, zs)} - ) - - await self.step_off_foil( - [wells] if isinstance(wells, Well) else wells, - back_channel=hold_down_channels[0], - front_channel=hold_down_channels[1], - move_inwards=move_inwards, - ) - - async def step_off_foil( - self, - wells: Union[Well, List[Well]], - front_channel: int, - back_channel: int, - move_inwards: float = 2, - move_height: float = 15, - ): - """ - Hold down a plate by placing two channels on the edges of a plate that is sealed with foil - while moving up the channels that are still within the foil. This is useful when, for - example, aspirating from a plate that is sealed: without holding it down, the tips might get - stuck in the plate and move it up when retracting. Putting plates on the edge prevents this. - - When aspirating or dispensing in the foil, be sure to set the `min_z_endpos` parameter in - `lh.aspirate` or `lh.dispense` to a value in the foil. You might want to use something like - - .. code-block:: python - - well = plate.get_well("A3") - await lh.aspirate( - [well]*4, vols=[100]*4, use_channels=[7,8,9,10], - min_z_endpos=well.get_location_wrt(self.deck, z="cavity_bottom").z, - surface_following_distance=0, - pull_out_distance_transport_air=[0] * 4) - await step_off_foil(lh.backend, [well], front_channel=11, back_channel=6, move_inwards=3) - - Args: - wells: Wells in the plate to hold down. (x-coordinate of channels will be at center of wells). - Must be sorted from back to front. - front_channel: The channel to place on the front of the plate. - back_channel: The channel to place on the back of the plate. - move_inwards: mm to move inwards (backward on the front channel; frontward on the back). - move_height: mm to move upwards after piercing the foil. front_channel and back_channel will hold the plate down. - """ - - if front_channel <= back_channel: - raise ValueError( - "front_channel should be in front of back_channel. Channels are 0-indexed from the back." - ) - - if isinstance(wells, Well): - wells = [wells] - - plates = set(well.parent for well in wells) - assert len(plates) == 1, "All wells must be in the same plate" - plate = plates.pop() - assert plate is not None - - z_location = plate.get_location_wrt(self.deck, z="top").z - - if plate.get_absolute_rotation().z % 360 == 0: - back_location = plate.get_location_wrt(self.deck, y="b") - front_location = plate.get_location_wrt(self.deck, y="f") - elif plate.get_absolute_rotation().z % 360 == 90: - back_location = plate.get_location_wrt(self.deck, x="r") - front_location = plate.get_location_wrt(self.deck, x="l") - elif plate.get_absolute_rotation().z % 360 == 180: - back_location = plate.get_location_wrt(self.deck, y="f") - front_location = plate.get_location_wrt(self.deck, y="b") - elif plate.get_absolute_rotation().z % 360 == 270: - back_location = plate.get_location_wrt(self.deck, x="l") - front_location = plate.get_location_wrt(self.deck, x="r") - else: - raise ValueError("Plate rotation must be a multiple of 90 degrees") - - try: - # Then move all channels in the y-space simultaneously. - await self.position_channels_in_y_direction( - { - front_channel: front_location.y + move_inwards, - back_channel: back_location.y - move_inwards, - } - ) - - await self.move_channel_z(front_channel, z_location) - await self.move_channel_z(back_channel, z_location) - finally: - # Move channels that are lower than the `front_channel` and `back_channel` to - # the just above the foil, in case the foil pops up. - zs = await self.get_channels_z_positions() - indices = [channel_idx for channel_idx, z in zs.items() if z < z_location] - idx = { - idx: z_location + move_height for idx in indices if idx not in (front_channel, back_channel) - } - await self.position_channels_in_z_direction(idx) - - # After that, all channels are clear to move up. - await self.move_all_channels_in_z_safety() - - async def request_volume_in_tip(self, channel: int) -> float: - resp = await self.send_command(STARBackend.channel_id(channel), "QC", fmt="qc##### (n)") - _, current_volume = resp["qc"] # first is max volume - return float(current_volume) / 10 - - @asynccontextmanager - async def slow_iswap(self, wrist_velocity: int = 20_000, gripper_velocity: int = 20_000): - """A context manager that sets the iSWAP to slow speed during the context""" - assert 20 <= gripper_velocity <= 75_000 - assert 20 <= wrist_velocity <= 65_000 - - original_wv = (await self.send_command("R0", "RA", ra="wv", fmt="wv#####"))["wv"] - original_tv = (await self.send_command("R0", "RA", ra="tv", fmt="tv#####"))["tv"] - - await self.send_command("R0", "AA", wv=gripper_velocity) # wrist velocity - await self.send_command("R0", "AA", tv=wrist_velocity) # gripper velocity - try: - yield - finally: - await self.send_command("R0", "AA", wv=original_wv) - await self.send_command("R0", "AA", tv=original_tv) - - # HamiltonHeaterShakerInterface - - async def send_hhs_command(self, index: int, command: str, **kwargs) -> str: - resp = await self.send_command( - module=f"T{index}", - command=command, - **kwargs, - ) - assert isinstance(resp, str) - return resp - - # ------------ STAR(RS-232/TCC1/2)-connected Hamilton Heater Cooler (HHS) ------------- - - async def check_type_is_hhc(self, device_number: int): - """ - Convenience method to check that connected device is an HHC. - Executed through firmware query - """ - - firmware_version = await self.send_command(module=f"T{device_number}", command="RF") - if "Hamilton Heater Cooler" not in firmware_version: - raise ValueError( - f"Device number {device_number} does not connect to a Hamilton" - f" Heater-Cooler, found {firmware_version} instead." - f"Have you called the wrong device number?" - ) - - async def initialize_hhc(self, device_number: int) -> str: - """Initialize Hamilton Heater Cooler (HHC) at specified TCC port - - Args: - device_number: TCC connect number to the HHC - """ - - module_pointer = f"T{device_number}" - - # Request module configuration - try: - await self.send_command(module=module_pointer, command="QU") - except TimeoutError as exc: - error_message = ( - f"No Hamilton Heater Cooler found at device_number {device_number}" - f", have you checked your connections? Original error: {exc}" - ) - raise ValueError(error_message) from exc - - await self.check_type_is_hhc(device_number) - - # Request module configuration - hhc_init_status = await self.send_command(module=module_pointer, command="QW", fmt="qw#") - hhc_init_status = hhc_init_status["qw"] - - info = "HHC already initialized" - # Initializing HHS if necessary - if hhc_init_status != 1: - # Initialize device - await self.send_command(module=module_pointer, command="LI") - info = f"HHS at device number {device_number} initialized." - - return info - - async def start_temperature_control_at_hhc( - self, - device_number: int, - temp: Union[float, int], - ): - """Start temperature regulation of specified HHC""" - - await self.check_type_is_hhc(device_number) - assert 0 < temp <= 105 - - # Ensure proper temperature input handling - if isinstance(temp, (float, int)): - safe_temp_str = f"{round(temp * 10):04d}" - else: - safe_temp_str = str(temp) - - return await self.send_command( - module=f"T{device_number}", - command="TA", # temperature adjustment - ta=safe_temp_str, - tb="1800", # TODO: identify precise purpose? - tc="0020", # TODO: identify precise purpose? - ) - - async def get_temperature_at_hhc(self, device_number: int) -> dict: - """Query current temperatures of both sensors of specified HHC""" - - await self.check_type_is_hhc(device_number) - - request_temperature = await self.send_command(module=f"T{device_number}", command="RT") - processed_t_info = [int(x) / 10 for x in request_temperature.split("+")[-2:]] - - return { - "middle_T": processed_t_info[0], - "edge_T": processed_t_info[-1], - } - - async def query_whether_temperature_reached_at_hhc(self, device_number: int): - """Stop temperature regulation of specified HHC""" - - await self.check_type_is_hhc(device_number) - query_current_control_status = await self.send_command( - module=f"T{device_number}", command="QD", fmt="qd#" - ) - - return query_current_control_status["qd"] == 0 - - async def stop_temperature_control_at_hhc(self, device_number: int): - """Stop temperature regulation of specified HHC""" - - await self.check_type_is_hhc(device_number) - - return await self.send_command(module=f"T{device_number}", command="TO") - - # -------------- Extra - Probing labware with STAR - making STAR into a CMM -------------- - - -class UnSafe: - """ - Namespace for actions that are unsafe to perform. - For example, actions that send the iSWAP outside of the Hamilton Deck - """ - - def __init__(self, star: "STARBackend"): - self.star = star - - async def put_in_hotel( - self, - hotel_center_x_coord: int = 0, - hotel_center_y_coord: int = 0, - hotel_center_z_coord: int = 0, - hotel_center_x_direction: Literal[0, 1] = 0, - hotel_center_y_direction: Literal[0, 1] = 0, - hotel_center_z_direction: Literal[0, 1] = 0, - clearance_height: int = 50, - hotel_depth: int = 1_300, - grip_direction: GripDirection = GripDirection.FRONT, - traverse_height_at_beginning: int = 3_600, - z_position_at_end: int = 3_600, - grip_strength: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] = 5, - open_gripper_position: int = 860, - collision_control: Literal[0, 1] = 1, - high_acceleration_index: Literal[1, 2, 3, 4] = 4, - low_acceleration_index: Literal[1, 2, 3, 4] = 1, - fold_up_at_end: bool = True, - ): - """ - A hotel is a location to store a plate. This can be a loading - dock for an external machine such as a cytomat or a centrifuge. - - Take care when using this command to interact with hotels located - outside of the hamilton deck area. Ensure that rotations of the - iSWAP arm don't collide with anything. - - tip: set the hotel depth big enough so that the boundary is inside the - hamilton deck. The iSWAP rotations will happen before it enters the hotel. - - The units of all relevant variables are in 0.1mm - """ - - assert 0 <= hotel_center_x_coord <= 99_999 - assert 0 <= hotel_center_y_coord <= 6_500 - assert 0 <= hotel_center_z_coord <= 3_500 - assert 0 <= clearance_height <= 999 - assert 0 <= hotel_depth <= 3_000 - assert 0 <= traverse_height_at_beginning <= 3_600 - assert 0 <= z_position_at_end <= 3_600 - assert 0 <= open_gripper_position <= 9_999 - - return await self.star.send_command( - module="C0", - command="PI", - xs=f"{hotel_center_x_coord:05}", - xd=hotel_center_x_direction, - yj=f"{hotel_center_y_coord:04}", - yd=hotel_center_y_direction, - zj=f"{hotel_center_z_coord:04}", - zd=hotel_center_z_direction, - zc=f"{clearance_height:03}", - hd=f"{hotel_depth:04}", - gr={ - GripDirection.FRONT: 1, - GripDirection.RIGHT: 2, - GripDirection.BACK: 3, - GripDirection.LEFT: 4, - }[grip_direction], - th=f"{traverse_height_at_beginning:04}", - te=f"{z_position_at_end:04}", - gw=grip_strength, - go=f"{open_gripper_position:04}", - ga=collision_control, - xe=f"{high_acceleration_index} {low_acceleration_index}", - gc=int(fold_up_at_end), - ) - - async def get_from_hotel( - self, - hotel_center_x_coord: int = 0, - hotel_center_y_coord: int = 0, - hotel_center_z_coord: int = 0, - # for direction, 0 is positive, 1 is negative - hotel_center_x_direction: Literal[0, 1] = 0, - hotel_center_y_direction: Literal[0, 1] = 0, - hotel_center_z_direction: Literal[0, 1] = 0, - clearance_height: int = 50, - hotel_depth: int = 1_300, - grip_direction: GripDirection = GripDirection.FRONT, - traverse_height_at_beginning: int = 3_600, - z_position_at_end: int = 3_600, - grip_strength: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] = 5, - open_gripper_position: int = 860, - plate_width: int = 800, - plate_width_tolerance: int = 20, - collision_control: Literal[0, 1] = 1, - high_acceleration_index: Literal[1, 2, 3, 4] = 4, - low_acceleration_index: Literal[1, 2, 3, 4] = 1, - fold_up_at_end: bool = True, - ): - """ - A hotel is a location to store a plate. This can be a loading - dock for an external machine such as a cytomat or a centrifuge. - - Take care when using this command to interact with hotels located - outside of the hamilton deck area. Ensure that rotations of the - iSWAP arm don't collide with anything. - - tip: set the hotel depth big enough so that the boundary is inside the - hamilton deck. The iSWAP rotations will happen before it enters the hotel. - - The units of all relevant variables are in 0.1mm - """ - - assert 0 <= hotel_center_x_coord <= 99_999 - assert 0 <= hotel_center_y_coord <= 6_500 - assert 0 <= hotel_center_z_coord <= 3_500 - assert 0 <= clearance_height <= 999 - assert 0 <= hotel_depth <= 3_000 - assert 0 <= traverse_height_at_beginning <= 3_600 - assert 0 <= z_position_at_end <= 3_600 - assert 0 <= open_gripper_position <= 9_999 - assert 0 <= plate_width <= 9_999 - assert 0 <= plate_width_tolerance <= 99 - - return await self.star.send_command( - module="C0", - command="PO", - xs=f"{hotel_center_x_coord:05}", - xd=hotel_center_x_direction, - yj=f"{hotel_center_y_coord:04}", - yd=hotel_center_y_direction, - zj=f"{hotel_center_z_coord:04}", - zd=hotel_center_z_direction, - zc=f"{clearance_height:03}", - hd=f"{hotel_depth:04}", - gr={ - GripDirection.FRONT: 1, - GripDirection.RIGHT: 2, - GripDirection.BACK: 3, - GripDirection.LEFT: 4, - }[grip_direction], - th=f"{traverse_height_at_beginning:04}", - te=f"{z_position_at_end:04}", - gw=grip_strength, - go=f"{open_gripper_position:04}", - gb=f"{plate_width:04}", - gt=f"{plate_width_tolerance:02}", - ga=collision_control, - xe=f"{high_acceleration_index} {low_acceleration_index}", - gc=int(fold_up_at_end), - ) - - async def violently_shoot_down_tip(self, channel_idx: int): - """Shoot down the tip on the specified channel by releasing the drive that holds the spring. The - tips will shoot down in place at an acceleration bigger than g. This is done by initializing - the squeezer drive wihile a tip is mounted. - - Safe to do when above a tip rack, for example directly after a tip pickup. - - .. warning:: - - Consider this method an easter egg. Not for serious use. - """ - await self.star.send_command(module=STARBackend.channel_id(channel_idx), command="SI") - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 - -class STAR(STARBackend): - def __init__(self, *args, **kwargs): - warnings.warn( - "`STAR` is deprecated and will be removed in a future release. " - "Please use `STARBackend` instead.", - DeprecationWarning, - stacklevel=2, - ) - super().__init__(*args, **kwargs) +from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_backend import * # noqa: F401,F403,E402 diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index e976569dbb6..d58114a756d 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -1,516 +1,10 @@ -import copy -import datetime -import logging import warnings -from contextlib import asynccontextmanager -from dataclasses import replace -from typing import Dict, List, Literal, Optional, Tuple, Union -from pylabrobot.io.validation_utils import LOG_LEVEL_IO -from pylabrobot.liquid_handling.backends import LiquidHandlerBackend -from pylabrobot.liquid_handling.backends.hamilton.STAR_backend import ( - DriveConfiguration, - ExtendedConfiguration, - Head96Information, - MachineConfiguration, - STARBackend, - iSWAPInformation, +warnings.warn( + "Importing from pylabrobot.liquid_handling.backends.hamilton.STAR_chatterbox is deprecated. " + "Use pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_chatterbox instead.", + DeprecationWarning, + stacklevel=2, ) -from pylabrobot.resources.container import Container -from pylabrobot.resources.tip_tracker import does_tip_tracking -from pylabrobot.resources.well import Well -logger = logging.getLogger("pylabrobot") - -_DEFAULT_MACHINE_CONFIGURATION = MachineConfiguration( - pip_type_1000ul=True, - kb_iswap_installed=True, - auto_load_installed=True, - num_pip_channels=8, -) - -_DEFAULT_EXTENDED_CONFIGURATION = ExtendedConfiguration( - left_x_drive_large=True, - iswap_gripper_wide=True, - instrument_size_slots=30, - auto_load_size_slots=30, - tip_waste_x_position=800.0, - left_x_drive=DriveConfiguration(iswap_installed=True, core_96_head_installed=True), - min_iswap_collision_free_position=350.0, - max_iswap_collision_free_position=600.0, -) - -# Minimal left-drive X position of a dual-rail arm. Validated against real hardware; -# the single-rail minimum is not yet known (#822). Only the chatterbox needs this -# literal - a physical STAR reports its own value from the drive-range query. -_DUAL_RAIL_LEFT_X_MIN = 95.0 - -# Hamilton factory defaults. Per-machine EEPROM calibration will differ -# slightly (e.g., L1=137.8, L2=137.7, STRAIGHT=-45.01 on one tested machine); -# these defaults are accurate enough for simulation but not for -# calibration-sensitive applications. -_DEFAULT_ISWAP_INFORMATION = iSWAPInformation( - fw_version="simulated", - rotation_drive_x_offset=34.0, - rotation_drive_y_max=627.4, - link_1_length=138.0, - link_2_length=138.0, - rotation_drive_predefined_increments={ - STARBackend.RotationDriveOrientation.LEFT: -29068, # ~-90 deg - STARBackend.RotationDriveOrientation.FRONT: 0, # ~+0 deg - STARBackend.RotationDriveOrientation.RIGHT: 29068, # ~+90 deg - STARBackend.RotationDriveOrientation.PARKED_RIGHT: 29500, # ~+91 deg - }, - wrist_drive_predefined_increments={ - STARBackend.WristDriveOrientation.RIGHT: -26577, # ~-135 deg - STARBackend.WristDriveOrientation.STRAIGHT: -8859, # ~-45 deg - STARBackend.WristDriveOrientation.LEFT: 8859, # ~+45 deg - STARBackend.WristDriveOrientation.REVERSE: 26577, # ~+135 deg - }, -) - - -class STARChatterboxBackend(STARBackend): - """Chatterbox backend for 'STAR'""" - - def __init__( - self, - num_channels: int = 8, - machine_configuration: MachineConfiguration = _DEFAULT_MACHINE_CONFIGURATION, - extended_configuration: ExtendedConfiguration = _DEFAULT_EXTENDED_CONFIGURATION, - iswap_information: Optional[iSWAPInformation] = None, - channels_minimum_y_spacing: Optional[List[float]] = None, - # deprecated parameters - core96_head_installed: Optional[bool] = None, - iswap_installed: Optional[bool] = None, - ): - """Initialize a chatter box backend. - - Args: - num_channels: Number of pipetting channels (default: 8) - machine_configuration: Machine configuration to return from `request_machine_configuration`. - extended_configuration: Extended configuration to return from `request_extended_configuration`. - iswap_information: Optional override for the simulated iSWAP setup state - (link lengths, EEPROM-calibrated stops, fw version). None means use - `_DEFAULT_ISWAP_INFORMATION` (Hamilton factory defaults). Only used - when the extended configuration reports iSWAP as installed. - channels_minimum_y_spacing: Per-channel minimum Y spacing in mm. If None, defaults to - `extended_configuration.min_raster_pitch_pip_channels` for all channels. - core96_head_installed: Deprecated. Set `extended_configuration.left_x_drive - .core_96_head_installed` instead. - iswap_installed: Deprecated. Set `extended_configuration.left_x_drive - .iswap_installed` instead. - """ - super().__init__() - self._num_channels = num_channels - self._iswap_parked = True - self._sim_iswap_information = iswap_information # None means use default at setup - - if core96_head_installed is not None or iswap_installed is not None: - extended_configuration = copy.deepcopy(extended_configuration) - xl = copy.deepcopy(extended_configuration.left_x_drive) - if core96_head_installed is not None: - warnings.warn( - "core96_head_installed is deprecated. Pass an ExtendedConfiguration with " - "left_x_drive.core_96_head_installed set instead.", - DeprecationWarning, - stacklevel=2, - ) - xl.core_96_head_installed = core96_head_installed - if iswap_installed is not None: - warnings.warn( - "iswap_installed is deprecated. Pass an ExtendedConfiguration with " - "left_x_drive.iswap_installed set instead.", - DeprecationWarning, - stacklevel=2, - ) - xl.iswap_installed = iswap_installed - extended_configuration.left_x_drive = xl - - self._machine_configuration = machine_configuration - self._extended_conf = extended_configuration - - if channels_minimum_y_spacing is not None: - if len(channels_minimum_y_spacing) != num_channels: - raise ValueError( - f"channels_minimum_y_spacing has {len(channels_minimum_y_spacing)} entries, " - f"expected {num_channels}." - ) - self._channels_minimum_y_spacing = list(channels_minimum_y_spacing) - else: - self._channels_minimum_y_spacing = [ - extended_configuration.min_raster_pitch_pip_channels - ] * num_channels - - async def setup( - self, - skip_instrument_initialization=False, - skip_pip=False, - skip_autoload=False, - skip_iswap=False, - skip_core96_head=False, - ): - """Initialize the chatterbox backend and detect installed modules. - - Args: - skip_instrument_initialization: If True, skip instrument initialization. - skip_pip: If True, skip pipetting channel initialization. - skip_autoload: If True, skip initializing the autoload module, if applicable. - skip_iswap: If True, skip initializing the iSWAP module, if applicable. - skip_core96_head: If True, skip initializing the CoRe 96 head module, if applicable. - """ - await LiquidHandlerBackend.setup(self) - - self.id_ = 0 - - # Request machine information - self._machine_conf = await self.request_machine_configuration() - self._extended_conf = await self.request_extended_configuration() - - # Mock firmware information for 96-head if installed - if self.extended_conf.left_x_drive.core_96_head_installed and not skip_core96_head: - fw_version = datetime.date(2023, 1, 1) - instrument_type: Head96Information.InstrumentType = "FM-STAR" - self._head96_information = Head96Information( - fw_version=fw_version, - x_offset=365.0, # factory default; hardware reads the per-machine value from EEPROM (kf) - supports_clot_monitoring_clld=False, - stop_disc_type="core_ii", - instrument_type=instrument_type, - head_type="96 head II", - z_range=self._head96_resolve_z_range(instrument_type), - ) - # Seed the mutable drive defaults from the machine (mirrors STARBackend); the head96_request_* - # overrides below return the canned 2013+ factory registers. - self._head96_y_drive_speed_default = await self.head96_request_y_speed() - self._head96_y_drive_acceleration_default = await self.head96_request_y_acceleration() - self._head96_z_drive_speed_default = await self.head96_request_z_speed() - self._head96_z_drive_acceleration_default = await self.head96_request_z_acceleration() - else: - self._head96_information = None - - # Mock iSWAP setup state if installed. One assignment - constructor override - # (if given) takes precedence over the factory-default record. - if self.extended_conf.left_x_drive.iswap_installed and not skip_iswap: - self._iswap_information = self._sim_iswap_information or _DEFAULT_ISWAP_INFORMATION - else: - self._iswap_information = None - - async def stop(self): - await LiquidHandlerBackend.stop(self) - self._setup_done = False - - # # # # # # # # Low-level command sending/receiving # # # # # # # # - - async def _write_and_read_command( - self, - id_: Optional[int], - cmd: str, - write_timeout: Optional[int] = None, - read_timeout: Optional[int] = None, - wait: bool = True, - ) -> Optional[str]: - logger.log(LOG_LEVEL_IO, "%s", cmd) - return None - - async def send_raw_command( - self, - command: str, - write_timeout: Optional[int] = None, - read_timeout: Optional[int] = None, - wait: bool = True, - ) -> Optional[str]: - logger.log(LOG_LEVEL_IO, "%s", command) - return None - - # # # # # # # # STAR configuration # # # # # # # # - - async def request_machine_configuration(self) -> MachineConfiguration: - return self._machine_configuration - - async def request_extended_configuration(self) -> ExtendedConfiguration: - """Return the configured extended configuration with X-arm geometry resolved. - - Mirrors STARBackend.request_extended_configuration: fills each installed drive's - geometry from the mocked X-drive range/envelope replies. A right drive that was not - configured (None) stays None. - """ - conf = self._extended_conf - assert conf is not None - ranges = await self.request_maximal_ranges_of_x_drives() - wraps = await self.request_working_envelopes_per_arm() - - def _with_geometry( - drive: Optional[DriveConfiguration], side: str, width: float - ) -> Optional[DriveConfiguration]: - if drive is None: - return None - wrap, workspace_range = wraps[side] - if wrap == 0: # arm not installed - return None - return replace(drive, width=width, x_range=ranges[side], workspace_range=workspace_range) - - left_x_drive = _with_geometry(conf.left_x_drive, "left", conf.left_x_arm_width) - assert left_x_drive is not None, "STAR must have a left X-arm" - - return replace( - conf, - left_x_drive=left_x_drive, - right_x_drive=_with_geometry(conf.right_x_drive, "right", conf.right_x_arm_width), - ) - - def _simulated_x_reach_max(self) -> float: - """Rightmost reachable X (mm) in simulation, from the deck's width. - - The hardware backend reads the true drive travel from the RU query; here there is no - machine, so the deck's own width is the closest bound that covers every on-deck - position without under-reporting reach (a per-rail estimate clips the deck's right edge). - """ - deck = self._deck - if deck is not None: - return deck.get_size_x() - return 1338.0 # nominal STAR reach - - async def request_maximal_ranges_of_x_drives(self) -> Dict[str, Tuple[float, float]]: - x_range = (_DUAL_RAIL_LEFT_X_MIN, self._simulated_x_reach_max()) - return {"left": x_range, "right": x_range} - - async def request_working_envelopes_per_arm( - self, - ) -> Dict[str, Tuple[float, Tuple[float, float]]]: - workspace = (-323.2, self._simulated_x_reach_max()) - left = (595.2, workspace) # wrap, workspace - # A wrap of 0 signals "arm not installed" (per the base method's contract). - right_installed = self.extended_conf.right_x_drive is not None - right = (595.2, workspace) if right_installed else (0.0, (0.0, 0.0)) - return {"left": left, "right": right} - - # # # # # # # # 1_000 uL Channel: Basic Commands # # # # # # # # - - async def request_tip_presence(self) -> List[Optional[bool]]: - """Return mock tip presence based on the tip tracker state. - - Returns: - A list of length `num_channels` where each element is `True` if a tip is mounted, - `False` if not, or `None` if unknown. - """ - return [self.head[ch].has_tip for ch in range(self.num_channels)] - - async def request_z_pos_channel_n(self, channel: int) -> float: - return 285.0 - - async def channel_dispensing_drive_request_position( - self, channel_idx: int, simulated_value: float = 0.0 - ) -> float: - """Override to return mock dispensing drive position. - - This method is called when the system needs to know the current position - of a channel's dispensing drive (e.g., before emptying tips). - - Returns a mock position with a default value of 0.0 for all channels. - """ - if not (0 <= channel_idx < self.num_channels): - raise ValueError(f"channel_idx must be between 0 and {self.num_channels - 1}") - - return simulated_value - - async def channel_request_y_minimum_spacing(self, channel_idx: int) -> float: - """Return mock minimum Y spacing for the given channel. - - Returns the value stored in ``_channels_minimum_y_spacing`` (set during - ``__init__()``) without issuing any hardware commands. - """ - if not 0 <= channel_idx <= self.num_channels - 1: - raise ValueError( - f"channel_idx must be between 0 and {self.num_channels - 1}, got {channel_idx}." - ) - return self._channels_minimum_y_spacing[channel_idx] - - async def channels_request_y_minimum_spacing(self) -> List[float]: - """Return mock per-channel minimum Y spacings for all channels.""" - return list(self._channels_minimum_y_spacing) - - async def move_channel_y(self, channel: int, y: float): - logger.info("moving channel %s to y: %s", channel, y) - - async def move_all_channels_in_z_safety(self): - logger.info("moving all channels to z safety") - - async def position_channels_in_z_direction(self, zs: Dict[int, float]): - logger.info("positioning channels in z: %s", zs) - - # # # # # # # # 1_000 uL Channel: Complex Commands # # # # # # # # - - async def step_off_foil( - self, - wells: Union[Well, List[Well]], - front_channel: int, - back_channel: int, - move_inwards: float = 2, - move_height: float = 15, - ): - logger.info( - "stepping off foil | wells: %s | front channel: %s | " - "back channel: %s | move inwards: %s | move height: %s", - wells, - front_channel, - back_channel, - move_inwards, - move_height, - ) - - async def pierce_foil( - self, - wells: Union[Well, List[Well]], - piercing_channels: List[int], - hold_down_channels: List[int], - move_inwards: float, - spread: Literal["wide", "tight"] = "wide", - one_by_one: bool = False, - distance_from_bottom: float = 20.0, - ): - logger.info( - "piercing foil | wells: %s | piercing channels: %s | " - "hold down channels: %s | move inwards: %s | " - "spread: %s | one by one: %s | distance from bottom: %s", - wells, - piercing_channels, - hold_down_channels, - move_inwards, - spread, - one_by_one, - distance_from_bottom, - ) - - # # # # # # # # Extension: 96-Head # # # # # # # # - - async def head96_request_firmware_version(self) -> datetime.date: - """Return mock 96-head firmware version.""" - return datetime.date(2023, 1, 1) - - # The Y/Z drive speed/acceleration registers a 2013+ (2023 mock) head reports at setup, returned - # through the real unit conversions so the seeded defaults match a live machine's factory values. - async def head96_request_y_speed(self) -> float: - return self._head96_y_drive_increment_to_mm(25000) - - async def head96_request_y_acceleration(self) -> float: - return self._head96_y_drive_increment_to_mm(35000) - - async def head96_request_z_speed(self) -> float: - return 85.0 - - async def head96_request_z_acceleration(self) -> float: - return 400.0 - - async def head96_request_tip_presence(self) -> int: - """Mock 96-head tip presence from the tip tracker: 1 if any channel holds a tip, else 0. - - Raises if tip tracking is disabled, since the tracker is then not updated and has no state to report. - """ - if not does_tip_tracking() or self.head96 is None: - raise RuntimeError( - "cannot report 96-head tip presence with tip tracking disabled in simulation; " - "enable it with set_tip_tracking(True) or call with requires_tip=False" - ) - return int(any(tracker.has_tip for tracker in self.head96.values())) - - # # # # # # # # Extension: iSWAP # # # # # # # # - - async def request_iswap_initialization_status(self) -> bool: - """Return mock iSWAP initialization status.""" - return True - - @property - def iswap_parked(self) -> bool: - return self._iswap_parked is True - - async def move_iswap_x( - self, - x_position: float, - acceleration_level: int = 3, - current_protection_limiter: int = 7, - ): - logger.info("moving iswap x to %s", x_position) - - async def move_iswap_y( - self, - y_position: float, - speed: float = 220.0, - acceleration_level: int = 2, - current_protection_limiter: int = 7, - make_space: bool = False, - ): - logger.info("moving iswap y to %s", y_position) - - async def move_iswap_z( - self, - z_position: float, - speed: float = 118.0, - acceleration: float = 643.66, - current_protection_limiter: int = 6, - ): - logger.info("moving iswap z to %s", z_position) - - @asynccontextmanager - async def slow_iswap(self, wrist_velocity: int = 20_000, gripper_velocity: int = 20_000): - """A context manager that sets the iSWAP to slow speed during the context.""" - assert 20 <= gripper_velocity <= 75_000, "Gripper velocity out of range." - assert 20 <= wrist_velocity <= 65_000, "Wrist velocity out of range." - - messages = ["start slow iswap"] - try: - yield - finally: - messages.append("end slow iswap") - logger.info("%s", " | ".join(messages)) - - # # # # # # # # Liquid Level Detection (LLD) # # # # # # # # - - async def request_tip_len_on_channel(self, channel_idx: int) -> float: - """Return tip length from the tip tracker. - - Args: - channel_idx: Index of the pipetting channel (0-indexed). - - Returns: - The tip length in mm from the tip tracker. - - Raises: - NoTipError: If no tip is present on the channel (via tip tracker). - """ - tip = self.head[channel_idx].get_tip() - return tip.total_tip_length - - async def position_channels_in_y_direction(self, ys, make_space=True): - logger.info("positioning channels in y: %s make_space: %s", ys, make_space) - - async def request_pip_height_last_lld(self): - return list(range(12)) - - async def _run_lld_on_channel_batch( - self, - batch, - containers: List[Container], - tip_lengths: List[float], - z_cavity_bottom: List[float], - z_top: List[float], - lld_mode: List["STARBackend.LLDMode"], - search_speed: float, - n_replicates: int, - ) -> Dict[int, List[Optional[float]]]: - """Simulate LLD by computing absolute heights from each container's volume tracker. - - Empty containers report the cavity-bottom Z (relative height 0). Non-empty - containers report ``cavity_bottom + compute_height_from_volume(volume)`` so the - parent ``probe_liquid_heights`` can subtract ``z_cavity_bottom`` consistently. - """ - measurements: Dict[int, List[Optional[float]]] = {} - for orig_idx in batch.indices: - container = containers[orig_idx] - volume = container.tracker.get_used_volume() - if volume == 0: - absolute_height = z_cavity_bottom[orig_idx] - else: - absolute_height = z_cavity_bottom[orig_idx] + container.compute_height_from_volume(volume) - measurements[orig_idx] = [absolute_height] * n_replicates - return measurements +from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_chatterbox import * # noqa: F401,F403,E402 diff --git a/pylabrobot/liquid_handling/backends/hamilton/__init__.py b/pylabrobot/liquid_handling/backends/hamilton/__init__.py index 4c36917049f..0f84dffa2bb 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/__init__.py +++ b/pylabrobot/liquid_handling/backends/hamilton/__init__.py @@ -1,6 +1,10 @@ -"""Hamilton backends for liquid handling.""" +import warnings -from .base import HamiltonLiquidHandler -from .pump import Pump # TODO: move elsewhere. -from .STAR_backend import STAR -from .vantage_backend import Vantage +warnings.warn( + "Importing from pylabrobot.liquid_handling.backends.hamilton is deprecated. " + "Use pylabrobot.legacy.liquid_handling.backends.hamilton instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.liquid_handling.backends.hamilton import * # noqa: F401,F403,E402 diff --git a/pylabrobot/liquid_handling/backends/opentrons_backend.py b/pylabrobot/liquid_handling/backends/opentrons_backend.py index 6401df24261..6b76758e082 100644 --- a/pylabrobot/liquid_handling/backends/opentrons_backend.py +++ b/pylabrobot/liquid_handling/backends/opentrons_backend.py @@ -1,757 +1,10 @@ -import inspect -import logging -import uuid -from typing import Any, Dict, List, Optional, Tuple, Union, cast +import warnings -from pylabrobot import utils -from pylabrobot.io import LOG_LEVEL_IO -from pylabrobot.liquid_handling.backends.backend import ( - LiquidHandlerBackend, +warnings.warn( + "Importing from pylabrobot.liquid_handling.backends.opentrons_backend is deprecated. " + "Use pylabrobot.legacy.liquid_handling.backends.opentrons_backend instead.", + DeprecationWarning, + stacklevel=2, ) -from pylabrobot.liquid_handling.errors import NoChannelError -from pylabrobot.liquid_handling.standard import ( - Drop, - DropTipRack, - MultiHeadAspirationContainer, - MultiHeadAspirationPlate, - MultiHeadDispenseContainer, - MultiHeadDispensePlate, - Pickup, - PickupTipRack, - ResourceDrop, - ResourceMove, - ResourcePickup, - SingleChannelAspiration, - SingleChannelDispense, -) -from pylabrobot.resources import ( - Coordinate, - Tip, -) -from pylabrobot.resources.opentrons import OTDeck -from pylabrobot.resources.tip_rack import TipRack - -try: - import ot_api - - USE_OT = True -except ImportError as e: - USE_OT = False - _OT_IMPORT_ERROR = e - - -# https://github.com/Opentrons/opentrons/issues/14590 -# https://labautomation.io/t/connect-pylabrobot-to-ot2/2862/18 -_OT_DECK_IS_ADDRESSABLE_AREA_VERSION = "7.1.0" - -logger = logging.getLogger(__name__) - - -class _IOLogger: - """Transparent proxy over the ``ot_api`` module that logs every call at - ``LOG_LEVEL_IO``. - - The OT-2 talks HTTP through ``ot_api`` rather than a pylabrobot.io transport, so - this wrapper gives it the same wire-level logging every other backend gets from - its io object. Submodules (``lh``, ``health``, ...) are wrapped recursively; - plain attributes (e.g. ``run_id``) pass through untouched. - """ - - def __init__(self, target: Any, prefix: str = ""): - self._target = target - self._prefix = prefix - - def __getattr__(self, name: str) -> Any: - attr = getattr(self._target, name) - qualified = f"{self._prefix}.{name}" if self._prefix else name - if inspect.ismodule(attr): - return _IOLogger(attr, qualified) - if callable(attr): - - def _logged(*args, **kwargs): - parts = [repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()] - logger.log(LOG_LEVEL_IO, "%s(%s)", qualified, ", ".join(parts)) - return attr(*args, **kwargs) - - return _logged - return attr - - -class OpentronsOT2Backend(LiquidHandlerBackend): - """Backends for the Opentrons OT2 liquid handling robots.""" - - pipette_name2volume = { - "p10_single": 10, - "p10_multi": 10, - "p20_single_gen2": 20, - "p20_multi_gen2": 20, - "p50_single": 50, - "p50_multi": 50, - "p300_single": 300, - "p300_multi": 300, - "p300_single_gen2": 300, - "p300_multi_gen2": 300, - "p1000_single": 1000, - "p1000_single_gen2": 1000, - "p300_single_gen3": 300, - "p1000_single_gen3": 1000, - } - - def __init__(self, host: str, port: int = 31950): - super().__init__() - - if not USE_OT: - raise RuntimeError( - "Opentrons is not installed. Please run pip install pylabrobot[opentrons]." - f" Import error: {_OT_IMPORT_ERROR}." - ) - - self.host = host - self.port = port - - # All hardware I/O goes through this handle so a subclass (e.g. the chatterbox) - # can dry-run the backend by swapping it for a recording stand-in. The real handle - # wraps ot_api to log every HTTP call at LOG_LEVEL_IO, like other backends' io. - self._ot: Any = _IOLogger(ot_api) - - self._ot.set_host(host) - self._ot.set_port(port) - - self.ot_api_version: Optional[str] = None - self.left_pipette: Optional[Dict[str, str]] = None - self.right_pipette: Optional[Dict[str, str]] = None - - self.traversal_height = 120 # test - self._tip_racks: Dict[str, int] = {} # tip_rack.name -> slot index - self._plr_name_to_load_name: Dict[str, str] = {} - - def serialize(self) -> dict: - return { - **super().serialize(), - "host": self.host, - "port": self.port, - } - - async def setup(self, skip_home: bool = False): - # create run - run_id = self._ot.runs.create() - self._ot.set_run(run_id) - - # get pipettes, then assign them - self.left_pipette, self.right_pipette = self._ot.lh.add_mounted_pipettes() - - self.left_pipette_has_tip = self.right_pipette_has_tip = False - - # get api version - health = self._ot.health.get() - self.ot_api_version = health["api_version"] - - if not skip_home: - await self.home() - - @property - def num_channels(self) -> int: - return len([p for p in [self.left_pipette, self.right_pipette] if p is not None]) - - async def stop(self): - """Cancel any active OT run, then clear labware definitions.""" - self._plr_name_to_load_name = {} - self._tip_racks = {} - self.left_pipette = None - self.right_pipette = None - - # cancel the HTTP-API run if it exists (helpful to make device available again in official Opentrons app) - run_id = getattr(self._ot, "run_id", None) - if run_id: - try: - self._ot.requestor.post(f"/runs/{run_id}/cancel") - except Exception: - try: - self._ot.requestor.post(f"/runs/{run_id}/actions/cancel") - except Exception: - try: - self._ot.requestor.delete(f"/runs/{run_id}") - except Exception: - pass - - def get_ot_name(self, plr_resource_name: str) -> str: - """Opentrons only allows names in ^[a-z0-9._]+$, but in PLR we are flexible. - So we map PLR names to OT names here. - """ - if plr_resource_name not in self._plr_name_to_load_name: - ot_load_name = uuid.uuid4().hex - self._plr_name_to_load_name[plr_resource_name] = ot_load_name - return self._plr_name_to_load_name[plr_resource_name] - - def select_tip_pipette(self, tip: Tip, with_tip: bool) -> Optional[str]: - """Select a pipette based on maximum tip volume for tip pick up or drop. - - The volume of the head must match the maximum tip volume. If both pipettes have the same - maximum volume, the left pipette is selected. - - Args: - with_tip: If True, get a channel that has a tip. - - Returns: - The id of the pipette, or None if no pipette is available. - """ - - if self.can_pick_up_tip(0, tip) and with_tip == self.left_pipette_has_tip: - assert self.left_pipette is not None - return cast(str, self.left_pipette["pipetteId"]) - - if self.can_pick_up_tip(1, tip) and with_tip == self.right_pipette_has_tip: - assert self.right_pipette is not None - return cast(str, self.right_pipette["pipetteId"]) - - return None - - async def _assign_tip_rack(self, tip_rack: TipRack, tip: Tip): - ot_slot_size_y = 86 - lw = { - "schemaVersion": 2, - "version": 1, - "namespace": "pylabrobot", - "metadata": { - "displayName": self.get_ot_name(tip_rack.name), - "displayCategory": "tipRack", - "displayVolumeUnits": "µL", - }, - "brand": { - "brand": "unknown", - }, - "parameters": { - "format": "96Standard", - "isTiprack": True, - # should we get the tip length from calibration on the robot? /calibration/tip_length - "tipLength": tip.total_tip_length, - "tipOverlap": tip.fitting_depth, - "loadName": self.get_ot_name(tip_rack.name), - "isMagneticModuleCompatible": False, # do we really care? If yes, store. - }, - "ordering": utils.reshape_2d( - [self.get_ot_name(tip_spot.name) for tip_spot in tip_rack.get_all_items()], - (tip_rack.num_items_x, tip_rack.num_items_y), - ), - "cornerOffsetFromSlot": { - "x": 0, - "y": ot_slot_size_y - - tip_rack.get_absolute_size_y(), # hinges push it to the back (PLR is LFB, OT is LBB) - "z": 0, - }, - "dimensions": { - "xDimension": tip_rack.get_absolute_size_x(), - "yDimension": tip_rack.get_absolute_size_y(), - "zDimension": tip_rack.get_absolute_size_z(), - }, - "wells": { - self.get_ot_name(child.name): { - "depth": child.get_absolute_size_z(), - "x": cast(Coordinate, child.location).x + child.get_absolute_size_x() / 2, - "y": cast(Coordinate, child.location).y + child.get_absolute_size_y() / 2, - "z": cast(Coordinate, child.location).z, - "shape": "circular", - "diameter": child.get_absolute_size_x(), - "totalLiquidVolume": tip.maximal_volume, - } - for child in tip_rack.children - }, - "groups": [ - { - "wells": [self.get_ot_name(tip_spot.name) for tip_spot in tip_rack.get_all_items()], - "metadata": { - "displayName": None, - "displayCategory": "tipRack", - "wellBottomShape": "flat", # required even for tip racks - }, - } - ], - } - - data = self._ot.labware.define(lw) - namespace, definition, version = data["data"]["definitionUri"].split("/") - - # assign labware to robot - labware_uuid = self.get_ot_name(tip_rack.name) - - deck = tip_rack.parent - while deck is not None and not isinstance(deck, OTDeck): - deck = deck.parent # labware sits in a slot holder, whose parent is the deck - assert isinstance(deck, OTDeck) - slot = deck.get_slot(tip_rack) - assert slot is not None, "tip rack must be on deck" - - self._ot.labware.add( - load_name=definition, - namespace=namespace, - ot_location=slot, - version=version, - labware_id=labware_uuid, - display_name=self.get_ot_name(tip_rack.name), - ) - - self._tip_racks[tip_rack.name] = slot - - def _get_pickup_pipette(self, ops: List[Pickup]) -> str: - """Get the pipette for a tip pick-up, or raise.""" - assert len(ops) == 1, "only one channel supported for now" - op = ops[0] - assert op.resource.parent is not None, "must not be a floating resource" - pipette_id = self.select_tip_pipette(op.tip, with_tip=False) - if not pipette_id: - raise NoChannelError("No pipette channel of right type with no tip available.") - return pipette_id - - def _get_drop_pipette(self, ops: List[Drop]) -> str: - """Get the pipette for a tip drop, or raise.""" - assert len(ops) == 1, "only one channel supported for now" - op = ops[0] - assert op.resource.parent is not None, "must not be a floating resource" - pipette_id = self.select_tip_pipette(op.tip, with_tip=True) - if not pipette_id: - raise NoChannelError("No pipette channel of right type with tip available.") - return pipette_id - - def _get_liquid_pipette( - self, ops: Union[List[SingleChannelAspiration], List[SingleChannelDispense]] - ) -> str: - """Get the pipette for an aspirate/dispense, or raise.""" - assert len(ops) == 1, "only one channel supported for now" - pipette_id = self.select_liquid_pipette(ops[0].volume) - if pipette_id is None: - raise NoChannelError("No pipette channel of right type with tip available.") - return pipette_id - - def _set_tip_state(self, pipette_id: str, has_tip: bool): - """Update tip-mounted state for the pipette that was used. - - This method now validates the provided ``pipette_id`` against both the left - and right pipette configurations. It updates the state only if the ID - matches a known, configured pipette; otherwise it raises an error to avoid - silently putting the backend into an inconsistent state. - """ - if self.left_pipette is not None and pipette_id == self.left_pipette["pipetteId"]: - self.left_pipette_has_tip = has_tip - return - - if self.right_pipette is not None and pipette_id == self.right_pipette["pipetteId"]: - self.right_pipette_has_tip = has_tip - return - - raise ValueError(f"Unknown or unconfigured pipette_id {pipette_id!r} in _set_tip_state.") - - async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int]): - """Pick up tips from the specified resource.""" - - pipette_id = self._get_pickup_pipette(ops) - op = ops[0] - - offset_x, offset_y, offset_z = ( - op.offset.x, - op.offset.y, - op.offset.z, - ) - - # define tip rack JIT if it's not already assigned - tip_rack = op.resource.parent - assert isinstance(tip_rack, TipRack), "TipSpot's parent must be a TipRack." - if tip_rack.name not in self._tip_racks: - await self._assign_tip_rack(tip_rack, op.tip) - - offset_z += op.tip.total_tip_length - - self._ot.lh.pick_up_tip( - labware_id=self.get_ot_name(tip_rack.name), - well_name=self.get_ot_name(op.resource.name), - pipette_id=pipette_id, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - ) - - self._set_tip_state(pipette_id, True) - - async def drop_tips(self, ops: List[Drop], use_channels: List[int]): - """Drop tips from the specified resource.""" - - pipette_id = self._get_drop_pipette(ops) - op = ops[0] - - use_fixed_trash = ( - cast(str, self.ot_api_version) >= _OT_DECK_IS_ADDRESSABLE_AREA_VERSION - and op.resource.name == "trash" - ) - if use_fixed_trash: - labware_id = "fixedTrash" - else: - tip_rack = op.resource.parent - assert isinstance(tip_rack, TipRack), "TipSpot's parent must be a TipRack." - if tip_rack.name not in self._tip_racks: - await self._assign_tip_rack(tip_rack, op.tip) - labware_id = self.get_ot_name(tip_rack.name) - - offset_x, offset_y, offset_z = ( - op.offset.x, - op.offset.y, - op.offset.z, - ) - - # ad-hoc offset adjustment that makes it smoother. - offset_z += 10 - - if use_fixed_trash: - self._ot.lh.move_to_addressable_area_for_drop_tip( - pipette_id=pipette_id, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - ) - self._ot.lh.drop_tip_in_place(pipette_id=pipette_id) - else: - self._ot.lh.drop_tip( - labware_id, - well_name=self.get_ot_name(op.resource.name), - pipette_id=pipette_id, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - ) - - self._set_tip_state(pipette_id, False) - - def select_liquid_pipette(self, volume: float) -> Optional[str]: - """Select a pipette based on volume for an aspiration or dispense. - - The volume of the tip mounted on the head must be greater than the volume to aspirate or - dispense. If both pipettes have the same maximum volume, the left pipette is selected. - - Only heads with a tip are considered. - - Args: - volume: The volume to aspirate or dispense. - - Returns: - The id of the pipette, or None if no pipette is available. - """ - - if self.left_pipette is not None: - left_volume = OpentronsOT2Backend.pipette_name2volume[self.left_pipette["name"]] - if left_volume >= volume and self.left_pipette_has_tip: - return cast(str, self.left_pipette["pipetteId"]) - - if self.right_pipette is not None: - right_volume = OpentronsOT2Backend.pipette_name2volume[self.right_pipette["name"]] - if right_volume >= volume and self.right_pipette_has_tip: - return cast(str, self.right_pipette["pipetteId"]) - - return None - - def get_pipette_name(self, pipette_id: str) -> str: - """Get the name of a pipette from its id.""" - - if self.left_pipette is not None and pipette_id == self.left_pipette["pipetteId"]: - return cast(str, self.left_pipette["name"]) - if self.right_pipette is not None and pipette_id == self.right_pipette["pipetteId"]: - return cast(str, self.right_pipette["name"]) - raise ValueError(f"Unknown pipette id: {pipette_id}") - - def _get_default_aspiration_flow_rate(self, pipette_name: str) -> float: - """Get the default aspiration flow rate for the specified pipette in uL/s. - - Data from https://archive.ph/ZUN9f - """ - - return { - "p300_multi_gen2": 94, - "p10_single": 5, - "p10_multi": 5, - "p50_single": 25, - "p50_multi": 25, - "p300_single": 150, - "p300_multi": 150, - "p1000_single": 500, - "p20_single_gen2": 3.78, - "p300_single_gen2": 46.43, - "p1000_single_gen2": 137.35, - "p20_multi_gen2": 7.6, - }[pipette_name] - - def _deck_to_robot_frame(self, location: Coordinate) -> Coordinate: - """Convert a deck-frame coordinate to the OT-2 robot frame. - - pylabrobot positions OT deck slots from the deck plate corner, whereas the OT-2 motion API - expects coordinates in the robot frame whose origin is slot 1's corner. The two frames differ by - slot 1's position in the deck frame, so subtract it. - """ - return location - cast(OTDeck, self.deck).slot_locations[0] - - async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int]): - """Aspirate liquid from the specified resource using pip.""" - - pipette_id = self._get_liquid_pipette(ops) - op = ops[0] - volume = op.volume - - pipette_name = self.get_pipette_name(pipette_id) - flow_rate = op.flow_rate or self._get_default_aspiration_flow_rate(pipette_name) - - location = self._deck_to_robot_frame( - op.resource.get_location_wrt(self.deck, "c", "c", "cavity_bottom") - + op.offset - + Coordinate(z=op.liquid_height or 0) - ) - - await self.move_pipette_head( - location=location, - minimum_z_height=self.traversal_height, - pipette_id=pipette_id, - ) - - if op.mix is not None: - for _ in range(op.mix.repetitions): - self._ot.lh.aspirate_in_place( - volume=op.mix.volume, - flow_rate=op.mix.flow_rate, - pipette_id=pipette_id, - ) - self._ot.lh.dispense_in_place( - volume=op.mix.volume, - flow_rate=op.mix.flow_rate, - pipette_id=pipette_id, - ) - - self._ot.lh.aspirate_in_place( - volume=volume, - flow_rate=flow_rate, - pipette_id=pipette_id, - ) - - traversal_location = self._deck_to_robot_frame( - op.resource.get_location_wrt(self.deck, "c", "c", "cavity_bottom") + op.offset - ) - traversal_location.z = self.traversal_height - await self.move_pipette_head( - location=traversal_location, - minimum_z_height=self.traversal_height, - pipette_id=pipette_id, - ) - - def _get_default_dispense_flow_rate(self, pipette_name: str) -> float: - """Get the default dispense flow rate for the specified pipette. - - Data from https://archive.ph/ZUN9f - - Returns: - The default flow rate in ul/s. - """ - - return { - "p300_multi_gen2": 94, - "p10_single": 10, - "p10_multi": 10, - "p50_single": 50, - "p50_multi": 50, - "p300_single": 300, - "p300_multi": 300, - "p1000_single": 1000, - "p20_single_gen2": 7.56, - "p300_single_gen2": 92.86, - "p1000_single_gen2": 274.7, - "p20_multi_gen2": 7.6, - }[pipette_name] - - async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[int]): - """Dispense liquid from the specified resource using pip.""" - - pipette_id = self._get_liquid_pipette(ops) - op = ops[0] - volume = op.volume - - pipette_name = self.get_pipette_name(pipette_id) - flow_rate = op.flow_rate or self._get_default_dispense_flow_rate(pipette_name) - - location = self._deck_to_robot_frame( - op.resource.get_location_wrt(self.deck, "c", "c", "cavity_bottom") - + op.offset - + Coordinate(z=op.liquid_height or 0) - ) - await self.move_pipette_head( - location=location, - minimum_z_height=self.traversal_height, - pipette_id=pipette_id, - ) - - self._ot.lh.dispense_in_place( - volume=volume, - flow_rate=flow_rate, - pipette_id=pipette_id, - ) - - if op.mix is not None: - for _ in range(op.mix.repetitions): - self._ot.lh.aspirate_in_place( - volume=op.mix.volume, - flow_rate=op.mix.flow_rate, - pipette_id=pipette_id, - ) - self._ot.lh.dispense_in_place( - volume=op.mix.volume, - flow_rate=op.mix.flow_rate, - pipette_id=pipette_id, - ) - - traversal_location = self._deck_to_robot_frame( - op.resource.get_location_wrt(self.deck, "c", "c", "cavity_bottom") + op.offset - ) - traversal_location.z = self.traversal_height - await self.move_pipette_head( - location=traversal_location, - minimum_z_height=self.traversal_height, - pipette_id=pipette_id, - ) - - async def home(self): - self._ot.health.home() - - async def pick_up_tips96(self, pickup: PickupTipRack): - raise NotImplementedError("The Opentrons backend does not support the 96 head.") - - async def drop_tips96(self, drop: DropTipRack): - raise NotImplementedError("The Opentrons backend does not support the 96 head.") - - async def aspirate96( - self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] - ): - raise NotImplementedError("The Opentrons backend does not support the 96 head.") - - async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): - raise NotImplementedError("The Opentrons backend does not support the 96 head.") - - async def pick_up_resource(self, pickup: ResourcePickup): - raise NotImplementedError("The Opentrons backend does not support the robotic arm.") - - async def move_picked_up_resource(self, move: ResourceMove): - raise NotImplementedError("The Opentrons backend does not support the robotic arm.") - - async def drop_resource(self, drop: ResourceDrop): - raise NotImplementedError("The Opentrons backend does not support the robotic arm.") - - async def list_connected_modules(self) -> List[dict]: - """List all connected temperature modules.""" - return cast(List[dict], self._ot.modules.list_connected_modules()) - - def _pipette_id_for_channel(self, channel: int) -> str: - pipettes = [] - if self.left_pipette is not None: - pipettes.append(self.left_pipette["pipetteId"]) - if self.right_pipette is not None: - pipettes.append(self.right_pipette["pipetteId"]) - if channel < 0 or channel >= len(pipettes): - raise NoChannelError(f"Channel {channel} not available on this OT-2 setup.") - return pipettes[channel] - - def _current_channel_position(self, channel: int) -> Tuple[str, Coordinate]: - """Return the pipette id and current coordinate for a given channel.""" - - pipette_id = self._pipette_id_for_channel(channel) - try: - res = self._ot.lh.save_position(pipette_id=pipette_id) - pos = res["data"]["result"]["position"] - current = Coordinate(pos["x"], pos["y"], pos["z"]) - except Exception as exc: # noqa: BLE001 - raise RuntimeError("Failed to query current pipette position") from exc - - return pipette_id, current - - async def prepare_for_manual_channel_operation(self, channel: int): - """Validate channel exists (no-op otherwise for OT-2).""" - - _ = self._pipette_id_for_channel(channel) - - async def move_channel_x(self, channel: int, x: float): - """Move a channel to an absolute x coordinate using savePosition to seed pose.""" - - pipette_id, current = self._current_channel_position(channel) - target = Coordinate(x=x, y=current.y, z=current.z) - await self.move_pipette_head( - location=target, minimum_z_height=self.traversal_height, pipette_id=pipette_id - ) - - async def move_channel_y(self, channel: int, y: float): - """Move a channel to an absolute y coordinate using savePosition to seed pose.""" - - pipette_id, current = self._current_channel_position(channel) - target = Coordinate(x=current.x, y=y, z=current.z) - await self.move_pipette_head( - location=target, minimum_z_height=self.traversal_height, pipette_id=pipette_id - ) - - async def move_channel_z(self, channel: int, z: float): - """Move a channel to an absolute z coordinate using savePosition to seed pose.""" - - pipette_id, current = self._current_channel_position(channel) - target = Coordinate(x=current.x, y=current.y, z=z) - await self.move_pipette_head( - location=target, minimum_z_height=self.traversal_height, pipette_id=pipette_id - ) - - async def move_pipette_head( - self, - location: Coordinate, - speed: Optional[float] = None, - minimum_z_height: Optional[float] = None, - pipette_id: Optional[str] = None, - force_direct: bool = False, - ): - """Move the pipette head to the specified location. When a tip is mounted, the location refers - to the bottom of the tip. If no tip is mounted, the location refers to the bottom of the - pipette head. - - Args: - location: The location to move to. - speed: The speed to move at, in mm/s. - minimum_z_height: The minimum z height to move to. Appears to be broken in the Opentrons API. - pipette_id: The id of the pipette to move. If `"left"` or `"right"`, the left or right - pipette is used. - force_direct: If True, move the pipette head directly in all dimensions. - """ - - if self.left_pipette is not None and pipette_id == "left": - pipette_id = self.left_pipette["pipetteId"] - elif self.right_pipette is not None and pipette_id == "right": - pipette_id = self.right_pipette["pipetteId"] - - if pipette_id is None: - raise ValueError("No pipette id given or left/right pipette not available.") - - self._ot.lh.move_arm( - pipette_id=pipette_id, - location_x=location.x, - location_y=location.y, - location_z=location.z, - minimum_z_height=minimum_z_height, - speed=speed, - force_direct=force_direct, - ) - - def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: - def supports_tip(channel_vol: float, tip_vol: float) -> bool: - if channel_vol == 20: - return tip_vol in {10, 20} - if channel_vol == 300: - return tip_vol in {200, 300} - if channel_vol == 1000: - return tip_vol in {1000} - raise ValueError(f"Unknown channel volume: {channel_vol}") - if channel_idx == 0: - if self.left_pipette is None: - return False - left_volume = OpentronsOT2Backend.pipette_name2volume[self.left_pipette["name"]] - return supports_tip(left_volume, tip.maximal_volume) - if channel_idx == 1: - if self.right_pipette is None: - return False - right_volume = OpentronsOT2Backend.pipette_name2volume[self.right_pipette["name"]] - return supports_tip(right_volume, tip.maximal_volume) - return False +from pylabrobot.legacy.liquid_handling.backends.opentrons_backend import * # noqa: F401,F403,E402 diff --git a/pylabrobot/liquid_handling/backends/tecan/__init__.py b/pylabrobot/liquid_handling/backends/tecan/__init__.py index de91df1008f..c4692bd228c 100644 --- a/pylabrobot/liquid_handling/backends/tecan/__init__.py +++ b/pylabrobot/liquid_handling/backends/tecan/__init__.py @@ -1 +1,10 @@ -from .EVO_backend import EVOBackend +import warnings + +warnings.warn( + "Importing from pylabrobot.liquid_handling.backends.tecan is deprecated. " + "Use pylabrobot.legacy.liquid_handling.backends.tecan instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.liquid_handling.backends.tecan import * # noqa: F401,F403,E402 diff --git a/pylabrobot/liquid_handling/liquid_classes/__init__.py b/pylabrobot/liquid_handling/liquid_classes/__init__.py index e69de29bb2d..5dcdcd969fa 100644 --- a/pylabrobot/liquid_handling/liquid_classes/__init__.py +++ b/pylabrobot/liquid_handling/liquid_classes/__init__.py @@ -0,0 +1,10 @@ +import warnings + +warnings.warn( + "Importing from pylabrobot.liquid_handling.liquid_classes is deprecated. " + "Use pylabrobot.legacy.liquid_handling.liquid_classes instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.liquid_handling.liquid_classes import * # noqa: F401,F403,E402 diff --git a/pylabrobot/liquid_handling/liquid_classes/hamilton/__init__.py b/pylabrobot/liquid_handling/liquid_classes/hamilton/__init__.py index d0f28ccbfd3..1d97d22c93e 100644 --- a/pylabrobot/liquid_handling/liquid_classes/hamilton/__init__.py +++ b/pylabrobot/liquid_handling/liquid_classes/hamilton/__init__.py @@ -1,3 +1,10 @@ -from .base import HamiltonLiquidClass -from .star import get_star_liquid_class -from .vantage import get_vantage_liquid_class +import warnings + +warnings.warn( + "Importing from pylabrobot.liquid_handling.liquid_classes.hamilton is deprecated. " + "Use pylabrobot.legacy.liquid_handling.liquid_classes.hamilton instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton import * # noqa: F401,F403,E402 diff --git a/pylabrobot/liquid_handling/liquid_classes/hamilton/star.py b/pylabrobot/liquid_handling/liquid_classes/hamilton/star.py index 81fd3dee5fe..cc840788b0a 100644 --- a/pylabrobot/liquid_handling/liquid_classes/hamilton/star.py +++ b/pylabrobot/liquid_handling/liquid_classes/hamilton/star.py @@ -1,15005 +1,10 @@ -from typing import Dict, Optional, Tuple +import warnings -from pylabrobot.liquid_handling.liquid_classes.hamilton.base import ( - HamiltonLiquidClass, +warnings.warn( + "Importing from pylabrobot.liquid_handling.liquid_classes.hamilton.star is deprecated. " + "Use pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.star instead.", + DeprecationWarning, + stacklevel=2, ) -from pylabrobot.resources.liquid import Liquid -star_mapping: Dict[ - Tuple[int, bool, bool, bool, Liquid, bool, bool], - HamiltonLiquidClass, -] = {} - - -def get_star_liquid_class( - tip_volume: float, - is_core: bool, - is_tip: bool, - has_filter: bool, - liquid: Liquid, - jet: bool, - blow_out: bool, -) -> Optional[HamiltonLiquidClass]: - """Get the Hamilton STAR liquid class for the given parameters. - - Args: - tip_volume: The volume of the tip in microliters. - is_core: Whether the tip is a core tip. - is_tip: Whether the tip is a tip tip or a needle. - has_filter: Whether the tip has a filter. - liquid: The liquid to be dispensed. - jet: Whether the liquid is dispensed using a jet. - blow_out: This is called "empty" in the Hamilton Liquid editor and liquid class names, but - "blow out" in the firmware documentation. "Empty" in the firmware documentation means fully - emptying the tip, which is the terminology PyLabRobot adopts. Blow_out is the opposite of - partial dispense. - """ - - # Tip volumes from resources (mostly where they have filters) are slightly different from the ones - # in the liquid class mapping, so we need to map them here. If no mapping is found, we use the - # given maximal volume of the tip. - tip_volume = int( - { - 360.0: 300.0, - 1065.0: 1000.0, - 1250.0: 1000.0, - 4367.0: 4000.0, - 5420.0: 5000.0, - }.get(tip_volume, tip_volume) - ) - - return star_mapping.get( - (tip_volume, is_core, is_tip, has_filter, liquid, jet, blow_out), - None, - ) - - -star_mapping[(1000, False, False, False, Liquid.WATER, True, True)] = ( - _1000ulNeedleCRWater_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 520.0, - 50.0: 61.2, - 0.0: 0.0, - 20.0: 22.5, - 100.0: 113.0, - 10.0: 11.1, - 200.0: 214.0, - 1000.0: 1032.0, - }, - aspiration_flow_rate=500.0, - aspiration_mix_flow_rate=500.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=500.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, False, False, Liquid.WATER, True, False)] = ( - _1000ulNeedleCRWater_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 520.0, - 50.0: 62.2, - 0.0: 0.0, - 20.0: 32.0, - 100.0: 115.5, - 1000.0: 1032.0, - }, - aspiration_flow_rate=500.0, - aspiration_mix_flow_rate=500.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=500.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=10.0, -) - - -# -star_mapping[(1000, False, False, False, Liquid.WATER, False, True)] = ( - _1000ulNeedleCRWater_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 50.0: 59.0, - 0.0: 0.0, - 20.0: 25.9, - 10.0: 12.9, - 1000.0: 1000.0, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=50.0, - dispense_mode=5.0, - dispense_mix_flow_rate=50.0, - dispense_air_transport_volume=1.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -# -star_mapping[(1000, False, False, False, Liquid.WATER, False, False)] = ( - _1000ulNeedleCRWater_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 50.0: 55.0, - 0.0: 0.0, - 20.0: 25.9, - 10.0: 12.9, - 1000.0: 1000.0, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=50.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=1.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth Asp. 0.5mm, without pre-rinsing -# - Disp.: jet mode empty tip -# - Pipetting-Volumes jet-dispense between 20 - 1000µl -# -# -# -# Typical performance data under laboratory conditions: -# Volume µl Precision % Trueness % -# 20 7.15 - 5.36 -# 50 2.81 - 1.49 -# 100 2.48 - 1.94 -# 200 1.25 - 0.51 -# 500 0.91 0.02 -# 1000 0.66 - 0.46 -star_mapping[(1000, False, False, False, Liquid.WATER, True, False)] = ( - _1000ulNeedle_Water_DispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 530.0, - 50.0: 56.0, - 0.0: 0.0, - 100.0: 110.0, - 20.0: 22.5, - 1000.0: 1055.0, - 200.0: 214.0, - }, - aspiration_flow_rate=500.0, - aspiration_mix_flow_rate=500.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=10.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=500.0, - dispense_mode=0.0, - dispense_mix_flow_rate=500.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=0.4, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing -# - dispense mode: surface empty tip -# - Pipetting-Volumes surface-dispense between 20 - 50µl -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 10.12 - 4.66 -# 50 3.79 - 1.18 -# -star_mapping[(1000, False, False, False, Liquid.WATER, False, False)] = ( - _1000ulNeedle_Water_DispenseSurface -) = HamiltonLiquidClass( - curve={50.0: 59.0, 0.0: 0.0, 20.0: 25.9, 1000.0: 1000.0}, - aspiration_flow_rate=500.0, - aspiration_mix_flow_rate=500.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=10.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=500.0, - dispense_mode=1.0, - dispense_mix_flow_rate=500.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=0.4, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, False, False, Liquid.WATER, False, True)] = ( - _10ulNeedleCRWater_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.7, - 0.5: 0.5, - 0.0: 0.0, - 1.0: 1.2, - 2.0: 2.4, - 10.0: 11.4, - }, - aspiration_flow_rate=60.0, - aspiration_mix_flow_rate=60.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=60.0, - dispense_mode=5.0, - dispense_mix_flow_rate=60.0, - dispense_air_transport_volume=1.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, False, False, Liquid.WATER, False, False)] = ( - _10ulNeedleCRWater_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 5.0: 5.7, - 0.5: 0.5, - 0.0: 0.0, - 1.0: 1.2, - 2.0: 2.4, - 10.0: 11.4, - }, - aspiration_flow_rate=60.0, - aspiration_mix_flow_rate=60.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=60.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=1.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, True, Liquid.DMSO, True, False)] = ( - _150ul_Piercing_Tip_Filter_DMSO_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={150.0: 150.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, - aspiration_flow_rate=180.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=1.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=2.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, True, Liquid.DMSO, True, True)] = ( - _150ul_Piercing_Tip_Filter_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={150.0: 154.0, 50.0: 52.9, 0.0: 0.0, 20.0: 21.8}, - aspiration_flow_rate=180.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=1.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, True, Liquid.DMSO, False, True)] = ( - _150ul_Piercing_Tip_Filter_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 3.0: 4.5, - 5.0: 6.5, - 150.0: 155.0, - 50.0: 53.7, - 0.0: 0.0, - 10.0: 12.0, - 2.0: 3.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=1.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -# - Volume 20 - 300ul -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing, in case of drops pre-rinsing 3x with Aspiratevolume, -# ( >100ul perhaps 2x or set mix speed to 100ul/s) -# - dispense mode jet empty tip -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 6.68 -2.95 -# 50 1.71 1.93 -# 100 1.67 -0.35 -# 300 0.46 -0.61 -# -star_mapping[(50, False, True, True, Liquid.ETHANOL, True, True)] = ( - _150ul_Piercing_Tip_Filter_Ethanol_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={150.0: 166.0, 50.0: 58.3, 0.0: 0.0, 20.0: 25.5}, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=7.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=7.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -# - Volume 5 - 50ul -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing, in case of drops pre-rinsing 3x with Aspiratevolume, -# ( >100ul perhaps 2x or set mix speed to 100ul/s) -# - dispense mode surface empty tip -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 5 7.96 -0.03 -# 10 7.99 5.88 -# 20 0.95 2.97 -# 50 0.31 -0.10 -# -star_mapping[(50, False, True, True, Liquid.ETHANOL, False, True)] = ( - _150ul_Piercing_Tip_Filter_Ethanol_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 3.0: 5.0, - 5.0: 7.6, - 150.0: 165.0, - 50.0: 56.9, - 0.0: 0.0, - 10.0: 13.2, - 2.0: 3.3, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=7.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=5.0, - dispense_mix_flow_rate=150.0, - dispense_air_transport_volume=7.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing -# - dispense mode surface empty tip -# - Pipetting-Volumes jet-dispense between 5 - 300µl -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 5 3.28 0.86 -# 10 4.88 -0.29 -# 20 2.92 2.68 -# 50 2.44 1.18 -# 100 1.33 1.29 -# 300 1.08 -0.87 -# -# -star_mapping[(50, False, True, True, Liquid.GLYCERIN80, False, True)] = ( - _150ul_Piercing_Tip_Filter_Glycerin80_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 3.0: 4.5, - 5.0: 7.2, - 150.0: 167.5, - 50.0: 60.0, - 0.0: 0.0, - 1.0: 2.7, - 10.0: 13.0, - 2.0: 2.5, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=5.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=10.0, - dispense_mode=5.0, - dispense_mix_flow_rate=50.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=5.0, - dispense_swap_speed=2.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# - without pre-rinsing -# - dispense mode jet empty tip -# - Pipetting-Volumes jet-dispense between 20 - 300µl -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 2.78 -0.05 -# 50 0.89 1.06 -# 100 0.81 0.99 -# 300 1.00 0.65 -# -star_mapping[(50, False, True, True, Liquid.SERUM, True, True)] = ( - _150ul_Piercing_Tip_Filter_Serum_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={150.0: 162.0, 50.0: 55.9, 0.0: 0.0, 20.0: 23.0}, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing -# - dispense mode surface empty tip -# - Pipetting-Volumes surface-dispense between 1 - 50µl -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 1 17.32 3.68 -# 2 16.68 0.24 -# 5 6.30 1.37 -# 10 2.03 5.71 -# 20 1.72 3.91 -# 50 1.39 -0.12 -# -# -star_mapping[(50, False, True, True, Liquid.SERUM, False, True)] = ( - _150ul_Piercing_Tip_Filter_Serum_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 3.0: 3.4, - 5.0: 5.9, - 150.0: 161.5, - 50.0: 56.2, - 0.0: 0.0, - 10.0: 11.6, - 2.0: 2.2, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=5.0, - dispense_mix_flow_rate=150.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, True, Liquid.WATER, True, False)] = ( - _150ul_Piercing_Tip_Filter_Water_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={150.0: 150.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=2.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(50, False, True, True, Liquid.WATER, True, True)] = ( - _150ul_Piercing_Tip_Filter_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 6.6, - 150.0: 159.1, - 50.0: 55.0, - 0.0: 0.0, - 100.0: 107.0, - 1.0: 1.6, - 20.0: 22.9, - 10.0: 12.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=1.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=3.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, True, Liquid.WATER, False, True)] = ( - _150ul_Piercing_Tip_Filter_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 3.0: 3.5, - 5.0: 6.5, - 150.0: 158.1, - 50.0: 54.5, - 0.0: 0.0, - 1.0: 1.6, - 10.0: 11.9, - 2.0: 2.8, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=1.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, False, Liquid.DMSO, True, True)] = ( - _250ul_Piercing_Tip_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={250.0: 255.5, 50.0: 52.9, 0.0: 0.0, 20.0: 21.8}, - aspiration_flow_rate=180.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=1.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, False, Liquid.DMSO, False, True)] = ( - _250ul_Piercing_Tip_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 3.0: 4.2, - 5.0: 6.5, - 250.0: 256.0, - 50.0: 53.7, - 0.0: 0.0, - 10.0: 12.0, - 2.0: 3.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=1.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -# - Volume 20 - 300ul -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing, in case of drops pre-rinsing 3x with Aspiratevolume, -# ( >100ul perhaps 2x or set mix speed to 100ul/s) -# - dispense mode jet empty tip -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 6.68 -2.95 -# 50 1.71 1.93 -# 100 1.67 -0.35 -# 300 0.46 -0.61 -# -star_mapping[(50, False, True, False, Liquid.ETHANOL, True, True)] = ( - _250ul_Piercing_Tip_Ethanol_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={250.0: 270.2, 50.0: 59.2, 0.0: 0.0, 20.0: 27.3}, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=15.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -# - Volume 5 - 50ul -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing, in case of drops pre-rinsing 3x with Aspiratevolume, -# ( >100ul perhaps 2x or set mix speed to 100ul/s) -# - dispense mode surface empty tip -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 5 7.96 -0.03 -# 10 7.99 5.88 -# 20 0.95 2.97 -# 50 0.31 -0.10 -# -star_mapping[(50, False, True, False, Liquid.ETHANOL, False, True)] = ( - _250ul_Piercing_Tip_Ethanol_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 3.0: 5.0, - 5.0: 9.6, - 250.0: 270.5, - 50.0: 58.0, - 0.0: 0.0, - 10.0: 14.8, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=5.0, - dispense_mix_flow_rate=150.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing -# - dispense mode surface empty tip -# - Pipetting-Volumes jet-dispense between 5 - 300µl -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 5 3.28 0.86 -# 10 4.88 -0.29 -# 20 2.92 2.68 -# 50 2.44 1.18 -# 100 1.33 1.29 -# 300 1.08 -0.87 -# -# -star_mapping[(50, False, True, False, Liquid.GLYCERIN80, False, True)] = ( - _250ul_Piercing_Tip_Glycerin80_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 3.0: 4.5, - 5.0: 7.2, - 250.0: 289.0, - 50.0: 65.0, - 0.0: 0.0, - 1.0: 2.7, - 10.0: 13.9, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=5.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=10.0, - dispense_mode=5.0, - dispense_mix_flow_rate=50.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=5.0, - dispense_swap_speed=2.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# - without pre-rinsing -# - dispense mode jet empty tip -# - Pipetting-Volumes jet-dispense between 20 - 300µl -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 2.78 -0.05 -# 50 0.89 1.06 -# 100 0.81 0.99 -# 300 1.00 0.65 -# -star_mapping[(50, False, True, False, Liquid.SERUM, True, True)] = ( - _250ul_Piercing_Tip_Serum_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={250.0: 265.0, 50.0: 56.4, 0.0: 0.0, 20.0: 23.0}, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing -# - dispense mode surface empty tip -# - Pipetting-Volumes surface-dispense between 1 - 50µl -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 1 17.32 3.68 -# 2 16.68 0.24 -# 5 6.30 1.37 -# 10 2.03 5.71 -# 20 1.72 3.91 -# 50 1.39 -0.12 -# -# -star_mapping[(50, False, True, False, Liquid.SERUM, False, True)] = ( - _250ul_Piercing_Tip_Serum_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 3.0: 3.4, - 5.0: 5.9, - 250.0: 264.2, - 50.0: 56.2, - 0.0: 0.0, - 10.0: 11.6, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=5.0, - dispense_mix_flow_rate=150.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, False, Liquid.WATER, True, True)] = ( - _250ul_Piercing_Tip_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 6.6, - 250.0: 260.0, - 50.0: 55.0, - 0.0: 0.0, - 100.0: 107.0, - 1.0: 1.6, - 20.0: 22.5, - 10.0: 12.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=1.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=3.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, False, Liquid.WATER, False, True)] = ( - _250ul_Piercing_Tip_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 3.0: 4.0, - 5.0: 6.5, - 250.0: 259.0, - 50.0: 55.1, - 0.0: 0.0, - 1.0: 1.6, - 10.0: 12.6, - 2.0: 2.8, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=1.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -# - Volume 10 - 300ul -# - submerge depth: Asp. 0.5mm -# - without pre-rinsing, in case of drops pre-rinsing 1-3x with Aspiratevolume, -# ( >100ul perhaps less than 2x or set mix speed to 100ul/s) -# - dispense mode jet empty tip -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 10 7.29 0.79 -# 20 5.85 -0.66 -# 50 2.57 0.82 -# 100 1.04 0.05 -# 300 0.63 -0.07 -# -star_mapping[(300, False, False, False, Liquid.ACETONITRIL80WATER20, True, False)] = ( - _300ulNeedleAcetonitril80Water20DispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 310.0, - 50.0: 57.8, - 0.0: 0.0, - 100.0: 106.5, - 20.0: 26.8, - 10.0: 16.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=15.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=0.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, False, False, Liquid.WATER, True, True)] = ( - _300ulNeedleCRWater_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 313.0, - 50.0: 53.5, - 0.0: 0.0, - 100.0: 104.0, - 20.0: 22.3, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, False, False, Liquid.WATER, True, False)] = ( - _300ulNeedleCRWater_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 313.0, - 50.0: 59.5, - 0.0: 0.0, - 100.0: 109.0, - 20.0: 29.3, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, False, False, Liquid.WATER, False, True)] = ( - _300ulNeedleCRWater_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 308.4, - 5.0: 6.8, - 50.0: 52.3, - 0.0: 0.0, - 100.0: 102.9, - 20.0: 22.3, - 1.0: 2.3, - 200.0: 205.8, - 10.0: 11.7, - 2.0: 3.0, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=50.0, - dispense_mode=5.0, - dispense_mix_flow_rate=50.0, - dispense_air_transport_volume=1.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, False, False, Liquid.WATER, False, False)] = ( - _300ulNeedleCRWater_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 308.4, - 5.0: 6.8, - 50.0: 52.3, - 0.0: 0.0, - 100.0: 102.9, - 20.0: 22.3, - 1.0: 2.3, - 200.0: 205.8, - 10.0: 11.7, - 2.0: 3.0, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=50.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=1.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# - without pre-rinsing -# - dispense mode jet empty tip -# - Pipetting-Volumes jet-dispense between 20 - 300µl -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 2.21 0.57 -# 50 1.53 0.23 -# 100 0.55 -0.01 -# 300 0.71 0.39 -# -star_mapping[(300, False, False, False, Liquid.DIMETHYLSULFOXID, True, False)] = ( - _300ulNeedleDMSODispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 317.0, - 50.0: 53.5, - 0.0: 0.0, - 100.0: 106.5, - 20.0: 21.3, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing -# - dispense mode surface empty tip -# - Pipetting-Volumes surface-dispense between 1 - 50µl -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 5 5.97 1.26 -# 10 2.53 1.22 -# 20 3.67 2.60 -# 50 1.32 -1.05 -# -# -star_mapping[(300, False, False, False, Liquid.DIMETHYLSULFOXID, False, False)] = ( - _300ulNeedleDMSODispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 6.0, - 50.0: 52.3, - 0.0: 0.0, - 20.0: 22.3, - 10.0: 11.4, - 2.0: 2.5, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=1.0, - dispense_mix_flow_rate=150.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# - Volume 20 - 300ul -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing, in case of drops pre-rinsing 3x with Aspiratevolume, -# ( >100ul perhaps 2x or set mix speed to 100ul/s) -# - dispense mode jet empty tip -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 6.68 -2.95 -# 50 1.71 1.93 -# 100 1.67 -0.35 -# 300 0.46 -0.61 -# -star_mapping[(300, False, False, False, Liquid.ETHANOL, True, False)] = ( - _300ulNeedleEtOHDispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 317.0, - 50.0: 57.8, - 0.0: 0.0, - 100.0: 109.0, - 20.0: 25.3, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=15.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -# - Volume 5 - 50ul -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing, in case of drops pre-rinsing 3x with Aspiratevolume, -# ( >100ul perhaps 2x or set mix speed to 100ul/s) -# - dispense mode surface empty tip -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 5 7.96 -0.03 -# 10 7.99 5.88 -# 20 0.95 2.97 -# 50 0.31 -0.10 -# -star_mapping[(300, False, False, False, Liquid.ETHANOL, False, False)] = ( - _300ulNeedleEtOHDispenseSurface -) = HamiltonLiquidClass( - curve={5.0: 7.2, 50.0: 55.0, 0.0: 0.0, 20.0: 24.5, 10.0: 13.1}, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=1.0, - dispense_mix_flow_rate=150.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing -# - dispense mode surface empty tip -# - Pipetting-Volumes jet-dispense between 5 - 300µl -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 5 3.28 0.86 -# 10 4.88 -0.29 -# 20 2.92 2.68 -# 50 2.44 1.18 -# 100 1.33 1.29 -# 300 1.08 -0.87 -# -# -star_mapping[(300, False, False, False, Liquid.GLYCERIN80, False, False)] = ( - _300ulNeedleGlycerin80DispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 325.0, - 5.0: 8.0, - 50.0: 61.3, - 0.0: 0.0, - 100.0: 117.0, - 20.0: 26.0, - 1.0: 2.7, - 10.0: 13.9, - 2.0: 4.2, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=50.0, - dispense_mode=1.0, - dispense_mix_flow_rate=50.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# - without pre-rinsing -# - dispense mode jet empty tip -# - Pipetting-Volumes jet-dispense between 20 - 300µl -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 2.78 -0.05 -# 50 0.89 1.06 -# 100 0.81 0.99 -# 300 1.00 0.65 -# -star_mapping[(300, False, False, False, Liquid.SERUM, True, False)] = ( - _300ulNeedleSerumDispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 313.0, - 50.0: 53.5, - 0.0: 0.0, - 100.0: 105.0, - 20.0: 21.3, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing -# - dispense mode surface empty tip -# - Pipetting-Volumes surface-dispense between 1 - 50µl -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 1 17.32 3.68 -# 2 16.68 0.24 -# 5 6.30 1.37 -# 10 2.03 5.71 -# 20 1.72 3.91 -# 50 1.39 -0.12 -# -# -star_mapping[(300, False, False, False, Liquid.SERUM, False, False)] = ( - _300ulNeedleSerumDispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 6.0, - 50.0: 52.3, - 0.0: 0.0, - 20.0: 22.3, - 1.0: 2.2, - 10.0: 11.9, - 2.0: 3.2, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=1.0, - dispense_mix_flow_rate=150.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# - without pre-rinsing -# - dispense mode jet empty tip -# - Pipetting-Volumes jet-dispense between 20 - 300µl -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 2.78 -0.05 -# 50 0.89 1.06 -# 100 0.81 0.99 -# 300 1.00 0.65 -# -star_mapping[(300, False, False, False, Liquid.SERUM, True, False)] = ( - _300ulNeedle_Serum_DispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 313.0, - 50.0: 53.5, - 0.0: 0.0, - 100.0: 105.0, - 20.0: 21.3, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=0.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing -# - dispense mode surface empty tip -# - Pipetting-Volumes surface-dispense between 1 - 50µl -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 1 17.32 3.68 -# 2 16.68 0.24 -# 5 6.30 1.37 -# 10 2.03 5.71 -# 20 1.72 3.91 -# 50 1.39 -0.12 -# -# -star_mapping[(300, False, False, False, Liquid.SERUM, False, False)] = ( - _300ulNeedle_Serum_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 350.0, - 5.0: 6.0, - 50.0: 52.3, - 0.0: 0.0, - 20.0: 22.3, - 1.0: 2.2, - 10.0: 11.9, - 2.0: 3.2, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=1.0, - dispense_mix_flow_rate=150.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# - without pre-rinsing -# - dispense mode jet empty tip -# - Pipetting-Volumes jet-dispense between 20 - 300µl -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 0.50 2.26 -# 50 0.30 0.65 -# 100 0.22 1.15 -# 200 0.16 0.55 -# 300 0.17 0.35 -# -star_mapping[(300, False, False, False, Liquid.WATER, True, False)] = ( - _300ulNeedle_Water_DispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 313.0, - 50.0: 53.5, - 0.0: 0.0, - 100.0: 105.0, - 20.0: 22.3, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=0.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing -# - dispense mode surface empty tip -# - Pipetting-Volumes jet-dispense between 1 - 20µl -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 1 11.17 - 6.64 -# 2 4.50 1.95 -# 5 0.38 0.50 -# 10 0.94 0.73 -# 20 0.63 0.73 -# -# -star_mapping[(300, False, False, False, Liquid.WATER, False, False)] = ( - _300ulNeedle_Water_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 308.4, - 5.0: 6.5, - 50.0: 52.3, - 0.0: 0.0, - 100.0: 102.9, - 20.0: 22.3, - 1.0: 1.1, - 200.0: 205.8, - 10.0: 12.0, - 2.0: 2.1, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=3.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=1.0, - dispense_mix_flow_rate=150.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=0.4, - dispense_stop_back_volume=0.0, -) - - -# Liquid class for washing rocket tips with CO-RE 384 head in 96 DC wash station. -star_mapping[(300, True, True, False, Liquid.WATER, False, False)] = ( - _300ul_RocketTip_384COREHead_96Washer_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 330.0, - 5.0: 6.3, - 0.5: 0.9, - 50.0: 55.1, - 0.0: 0.0, - 1.0: 1.6, - 20.0: 23.2, - 100.0: 107.2, - 2.0: 2.8, - 10.0: 11.9, - 200.0: 211.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=150.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=150.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=5.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# Evaluation -star_mapping[(300, True, True, False, Liquid.DMSO, True, False)] = ( - _300ul_RocketTip_384COREHead_DMSO_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 300.0: 300.0, - 150.0: 150.0, - 50.0: 50.0, - 0.0: 0.0, - 100.0: 100.0, - 20.0: 20.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=7.5, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=120.0, - dispense_stop_back_volume=10.0, -) - - -# Evaluation -star_mapping[(300, True, True, False, Liquid.DMSO, True, True)] = ( - _300ul_RocketTip_384COREHead_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 303.5, - 0.0: 0.0, - 100.0: 105.8, - 200.0: 209.5, - 10.0: 11.4, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -# Evaluation -star_mapping[(300, True, True, False, Liquid.DMSO, False, True)] = ( - _300ul_RocketTip_384COREHead_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 308.0, - 0.0: 0.0, - 100.0: 105.5, - 200.0: 209.0, - 10.0: 12.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=80.0, - dispense_mode=5.0, - dispense_mix_flow_rate=80.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# Evaluation -star_mapping[(300, True, True, False, Liquid.WATER, True, False)] = ( - _300ul_RocketTip_384COREHead_Water_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 300.0: 309.0, - 0.0: 0.0, - 100.0: 106.5, - 20.0: 22.3, - 200.0: 207.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=7.5, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=20.0, -) - - -# Evaluation -star_mapping[(300, True, True, False, Liquid.WATER, True, True)] = ( - _300ul_RocketTip_384COREHead_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 309.0, - 0.0: 0.0, - 100.0: 106.5, - 20.0: 22.3, - 200.0: 207.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -# Evaluation -star_mapping[(300, True, True, False, Liquid.WATER, False, True)] = ( - _300ul_RocketTip_384COREHead_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 314.3, - 0.0: 0.0, - 100.0: 109.0, - 200.0: 214.7, - 10.0: 12.7, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=160.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(30, True, True, False, Liquid.DMSO, True, True)] = ( - _30ulTip_384COREHead_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={5.0: 5.0, 15.0: 15.3, 30.0: 30.7, 0.0: 0.0, 1.0: 1.0}, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=3.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(30, True, True, False, Liquid.DMSO, False, True)] = ( - _30ulTip_384COREHead_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={5.0: 4.9, 15.0: 15.1, 30.0: 30.0, 0.0: 0.0, 1.0: 0.9}, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=50.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(30, True, True, False, Liquid.ETHANOL, True, True)] = ( - _30ulTip_384COREHead_EtOH_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={5.0: 6.54, 15.0: 18.36, 30.0: 33.8, 0.0: 0.0, 1.0: 1.8}, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=3.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=3.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(30, True, True, False, Liquid.ETHANOL, False, True)] = ( - _30ulTip_384COREHead_EtOH_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={5.0: 6.2, 15.0: 16.9, 30.0: 33.1, 0.0: 0.0, 1.0: 1.5}, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=50.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(30, True, True, False, Liquid.GLYCERIN80, False, True)] = ( - _30ulTip_384COREHead_Glyzerin80_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 6.3, - 0.5: 0.9, - 40.0: 44.0, - 0.0: 0.0, - 20.0: 22.2, - 1.0: 1.6, - 10.0: 11.9, - 2.0: 2.8, - }, - aspiration_flow_rate=150.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=2.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=100.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=2.0, - dispense_swap_speed=2.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(30, True, True, False, Liquid.WATER, True, True)] = ( - _30ulTip_384COREHead_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={5.0: 6.0, 15.0: 16.5, 30.0: 32.3, 0.0: 0.0, 1.0: 1.6}, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=3.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(30, True, True, False, Liquid.WATER, False, True)] = ( - _30ulTip_384COREHead_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={5.0: 5.6, 15.0: 15.9, 30.0: 31.3, 0.0: 0.0, 1.0: 1.2}, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=50.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(30, True, True, False, Liquid.WATER, False, False)] = ( - _30ulTip_384COREWasher_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 6.3, - 0.5: 0.9, - 40.0: 44.0, - 0.0: 0.0, - 1.0: 1.6, - 20.0: 22.2, - 2.0: 2.8, - 10.0: 11.9, - }, - aspiration_flow_rate=10.0, - aspiration_mix_flow_rate=30.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=15.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=12.0, - dispense_mode=5.0, - dispense_mix_flow_rate=30.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=15.0, - dispense_swap_speed=100.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(4000, False, True, False, Liquid.DMSO, True, False)] = ( - _4mlTF_DMSO_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 3500.0: 3715.0, - 500.0: 631.0, - 2500.0: 2691.0, - 1500.0: 1667.0, - 4000.0: 4224.0, - 3000.0: 3202.0, - 0.0: 0.0, - 2000.0: 2179.0, - 100.0: 211.0, - 1000.0: 1151.0, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=500.0, - aspiration_air_transport_volume=20.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=1000.0, - dispense_mode=2.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=20.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=400.0, - dispense_stop_back_volume=20.0, -) - - -star_mapping[(4000, False, True, False, Liquid.DMSO, True, True)] = ( - _4mlTF_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 540.0, - 50.0: 61.5, - 4000.0: 4102.0, - 3000.0: 3083.0, - 0.0: 0.0, - 2000.0: 2070.0, - 100.0: 116.5, - 1000.0: 1060.0, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=500.0, - aspiration_air_transport_volume=20.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=1000.0, - dispense_mode=3.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=20.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=400.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(4000, False, True, False, Liquid.DMSO, False, True)] = ( - _4mlTF_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 536.5, - 50.0: 62.3, - 4000.0: 4128.0, - 3000.0: 3109.0, - 0.0: 0.0, - 2000.0: 2069.0, - 100.0: 116.6, - 1000.0: 1054.0, - 10.0: 15.5, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=500.0, - aspiration_air_transport_volume=20.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=500.0, - dispense_mode=5.0, - dispense_mix_flow_rate=500.0, - dispense_air_transport_volume=20.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=5.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=500.0, - dispense_stop_back_volume=0.0, -) - - -# First two times mixing with max volume. -star_mapping[(4000, False, True, False, Liquid.ETHANOL, True, False)] = ( - _4mlTF_EtOH_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 300.0: 300.0, - 3500.0: 3500.0, - 500.0: 500.0, - 2500.0: 2500.0, - 1500.0: 1500.0, - 4000.0: 4000.0, - 3000.0: 3000.0, - 0.0: 0.0, - 2000.0: 2000.0, - 100.0: 100.0, - 1000.0: 1000.0, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=2000.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=1000.0, - dispense_mode=2.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(4000, False, True, False, Liquid.ETHANOL, True, True)] = ( - _4mlTF_EtOH_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 563.0, - 50.0: 72.0, - 4000.0: 4215.0, - 3000.0: 3190.0, - 0.0: 0.0, - 2000.0: 2178.0, - 100.0: 127.5, - 1000.0: 1095.0, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=30.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=30.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=1000.0, - dispense_mode=3.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=30.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=30.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(4000, False, True, False, Liquid.ETHANOL, False, True)] = ( - _4mlTF_EtOH_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 555.0, - 50.0: 68.0, - 4000.0: 4177.0, - 3000.0: 3174.0, - 0.0: 0.0, - 2000.0: 2151.0, - 100.0: 123.5, - 1000.0: 1085.0, - 10.0: 18.6, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=30.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=30.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=1000.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=30.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=30.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=30.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(4000, False, True, False, Liquid.GLYCERIN80, True, True)] = ( - _4mlTF_Glycerin80_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 599.0, - 50.0: 89.0, - 4000.0: 4223.0, - 3000.0: 3211.0, - 0.0: 0.0, - 2000.0: 2195.0, - 100.0: 140.0, - 1000.0: 1159.0, - }, - aspiration_flow_rate=1200.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=30.0, - aspiration_blow_out_volume=100.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=500.0, - dispense_mode=3.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=50.0, - dispense_blow_out_volume=100.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(4000, False, True, False, Liquid.GLYCERIN80, False, True)] = ( - _4mlTF_Glycerin80_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 555.0, - 50.0: 71.0, - 4000.0: 4135.0, - 3000.0: 3122.0, - 0.0: 0.0, - 2000.0: 2101.0, - 100.0: 129.0, - 1000.0: 1083.0, - 10.0: 16.0, - }, - aspiration_flow_rate=1000.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=70.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=5.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=50.0, - dispense_blow_out_volume=70.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(4000, False, True, False, Liquid.WATER, True, False)] = ( - _4mlTF_Water_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 4000.0: 4160.0, - 3000.0: 3160.0, - 0.0: 0.0, - 2000.0: 2160.0, - 100.0: 214.0, - 1000.0: 1148.0, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=500.0, - aspiration_air_transport_volume=20.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=1000.0, - dispense_mode=2.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=20.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=400.0, - dispense_stop_back_volume=20.0, -) - - -star_mapping[(4000, False, True, False, Liquid.WATER, True, True)] = ( - _4mlTF_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 551.8, - 50.0: 66.4, - 4000.0: 4165.0, - 3000.0: 3148.0, - 0.0: 0.0, - 2000.0: 2128.0, - 100.0: 122.7, - 1000.0: 1082.0, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=500.0, - aspiration_air_transport_volume=20.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=1000.0, - dispense_mode=3.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=20.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=400.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(4000, False, True, False, Liquid.WATER, False, True)] = ( - _4mlTF_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 547.0, - 50.0: 65.5, - 4000.0: 4145.0, - 3000.0: 3135.0, - 0.0: 0.0, - 2000.0: 2125.0, - 100.0: 120.9, - 1000.0: 1075.0, - 10.0: 14.5, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=500.0, - aspiration_air_transport_volume=20.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=500.0, - dispense_mode=5.0, - dispense_mix_flow_rate=500.0, - dispense_air_transport_volume=20.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=5.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=500.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.DMSO, True, True)] = ( - _50ulTip_384COREHead_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 52.0, 0.0: 0.0, 20.0: 21.1, 10.0: 10.5}, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=3.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.DMSO, False, True)] = ( - _50ulTip_384COREHead_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.0, - 50.0: 51.1, - 30.0: 30.7, - 0.0: 0.0, - 1.0: 0.9, - 10.0: 10.1, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=50.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.ETHANOL, True, True)] = ( - _50ulTip_384COREHead_EtOH_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 6.54, - 15.0: 18.36, - 50.0: 53.0, - 30.0: 33.8, - 0.0: 0.0, - 1.0: 1.8, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=3.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=3.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.ETHANOL, False, True)] = ( - _50ulTip_384COREHead_EtOH_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 6.2, - 15.0: 16.9, - 0.5: 1.0, - 50.0: 54.0, - 30.0: 33.1, - 0.0: 0.0, - 1.0: 1.5, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=2.0, - aspiration_swap_speed=6.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=50.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=2.0, - dispense_swap_speed=6.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.GLYCERIN80, False, True)] = ( - _50ulTip_384COREHead_Glycerin80_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.6, - 0.5: 0.65, - 50.0: 55.0, - 0.0: 0.0, - 30.0: 31.5, - 1.0: 1.2, - 10.0: 10.9, - }, - aspiration_flow_rate=30.0, - aspiration_mix_flow_rate=30.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=20.0, - dispense_mode=5.0, - dispense_mix_flow_rate=20.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=2.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.WATER, True, True)] = ( - _50ulTip_384COREHead_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 53.6, 0.0: 0.0, 20.0: 22.4, 10.0: 11.9}, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.WATER, False, True)] = ( - _50ulTip_384COREHead_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.5, - 50.0: 52.2, - 30.0: 31.5, - 0.0: 0.0, - 1.0: 1.2, - 10.0: 11.3, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=2.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=20.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=2.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.WATER, False, False)] = ( - _50ulTip_384COREWasher_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 6.3, - 0.5: 0.9, - 50.0: 55.0, - 40.0: 44.0, - 0.0: 0.0, - 20.0: 22.2, - 1.0: 1.6, - 10.0: 11.9, - 2.0: 2.8, - }, - aspiration_flow_rate=20.0, - aspiration_mix_flow_rate=30.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=15.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=25.0, - dispense_mode=5.0, - dispense_mix_flow_rate=30.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=15.0, - dispense_swap_speed=100.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.DMSO, True, True)] = ( - _50ulTip_conductive_384COREHead_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.2, - 50.0: 50.6, - 30.0: 30.4, - 0.0: 0.0, - 1.0: 0.9, - 20.0: 21.1, - 10.0: 9.3, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=3.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.DMSO, True, True)] = ( - _50ulTip_conductive_384COREHead_DMSO_DispenseJet_Empty_below5ul -) = HamiltonLiquidClass( - curve={ - 5.0: 5.2, - 50.0: 50.6, - 30.0: 30.4, - 0.0: 0.0, - 1.0: 0.9, - 20.0: 21.1, - 10.0: 9.3, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=5.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=240.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=5.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.DMSO, True, False)] = ( - _50ulTip_conductive_384COREHead_DMSO_DispenseJet_Part -) = HamiltonLiquidClass( - curve={50.0: 50.0, 0.0: 0.0, 10.0: 10.0}, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=3.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=5.0, -) - - -star_mapping[(50, True, True, False, Liquid.DMSO, False, True)] = ( - _50ulTip_conductive_384COREHead_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 0.1: 0.05, - 0.25: 0.1, - 5.0: 4.95, - 0.5: 0.22, - 50.0: 50.0, - 30.0: 30.6, - 0.0: 0.0, - 1.0: 0.74, - 10.0: 9.95, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=50.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.ETHANOL, True, True)] = ( - _50ulTip_conductive_384COREHead_EtOH_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 6.85, - 15.0: 18.36, - 50.0: 54.3, - 30.0: 33.6, - 0.0: 0.0, - 1.0: 1.5, - 10.0: 12.1, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=3.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=3.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.ETHANOL, True, True)] = ( - _50ulTip_conductive_384COREHead_EtOH_DispenseJet_Empty_below5ul -) = HamiltonLiquidClass( - curve={ - 5.0: 6.85, - 15.0: 18.36, - 50.0: 54.3, - 30.0: 33.6, - 0.0: 0.0, - 1.0: 1.5, - 10.0: 12.1, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=5.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=240.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=3.0, - dispense_blow_out_volume=5.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.ETHANOL, True, False)] = ( - _50ulTip_conductive_384COREHead_EtOH_DispenseJet_Part -) = HamiltonLiquidClass( - curve={50.0: 50.0, 0.0: 0.0, 10.0: 10.0}, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=3.0, - aspiration_blow_out_volume=3.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=3.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=2.0, -) - - -star_mapping[(50, True, True, False, Liquid.ETHANOL, False, True)] = ( - _50ulTip_conductive_384COREHead_EtOH_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 0.25: 0.3, - 5.0: 6.1, - 0.5: 0.65, - 15.0: 16.9, - 50.0: 52.7, - 30.0: 32.1, - 0.0: 0.0, - 1.0: 1.35, - 10.0: 11.3, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=2.0, - aspiration_swap_speed=6.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=50.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=2.0, - dispense_swap_speed=6.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.GLYCERIN80, False, True)] = ( - _50ulTip_conductive_384COREHead_Glycerin80_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 0.25: 0.05, - 5.0: 5.5, - 0.5: 0.3, - 50.0: 51.9, - 30.0: 31.8, - 0.0: 0.0, - 1.0: 1.0, - 10.0: 10.9, - }, - aspiration_flow_rate=30.0, - aspiration_mix_flow_rate=30.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=20.0, - dispense_mode=5.0, - dispense_mix_flow_rate=20.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=2.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.WATER, True, True)] = ( - _50ulTip_conductive_384COREHead_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.67, - 0.5: 0.27, - 50.0: 51.9, - 30.0: 31.5, - 0.0: 0.0, - 1.0: 1.06, - 20.0: 20.0, - 10.0: 10.9, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=2.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=2.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.WATER, True, True)] = ( - _50ulTip_conductive_384COREHead_Water_DispenseJet_Empty_below5ul -) = HamiltonLiquidClass( - curve={ - 5.0: 5.67, - 0.5: 0.27, - 50.0: 51.9, - 30.0: 31.5, - 0.0: 0.0, - 1.0: 1.06, - 20.0: 20.0, - 10.0: 10.9, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=5.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=240.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=5.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.WATER, True, False)] = ( - _50ulTip_conductive_384COREHead_Water_DispenseJet_Part -) = HamiltonLiquidClass( - curve={50.0: 50.0, 0.0: 0.0, 10.0: 10.0}, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=2.0, -) - - -star_mapping[(50, True, True, False, Liquid.WATER, False, True)] = ( - _50ulTip_conductive_384COREHead_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 0.1: 0.1, - 0.25: 0.15, - 5.0: 5.6, - 0.5: 0.45, - 50.0: 51.0, - 30.0: 31.0, - 0.0: 0.0, - 1.0: 0.98, - 10.0: 10.7, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=2.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=20.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=2.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.WATER, False, False)] = ( - _50ulTip_conductive_384COREWasher_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 6.3, - 0.5: 0.9, - 50.0: 55.0, - 40.0: 44.0, - 0.0: 0.0, - 1.0: 1.6, - 20.0: 22.2, - 65.0: 65.0, - 10.0: 11.9, - 2.0: 2.8, - }, - aspiration_flow_rate=20.0, - aspiration_mix_flow_rate=30.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=15.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=25.0, - dispense_mode=5.0, - dispense_mix_flow_rate=30.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=15.0, - dispense_swap_speed=100.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(5000, False, True, False, Liquid.DMSO, True, False)] = ( - _5mlT_DMSO_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 4500.0: 4606.0, - 3500.0: 3591.0, - 500.0: 525.0, - 2500.0: 2576.0, - 1500.0: 1559.0, - 5000.0: 5114.0, - 4000.0: 4099.0, - 3000.0: 3083.0, - 0.0: 0.0, - 2000.0: 2068.0, - 100.0: 105.0, - 1000.0: 1044.0, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=20.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=1000.0, - dispense_mode=2.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=20.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=400.0, - dispense_stop_back_volume=20.0, -) - - -star_mapping[(5000, False, True, False, Liquid.DMSO, True, True)] = _5mlT_DMSO_DispenseJet_Empty = ( - HamiltonLiquidClass( - curve={ - 500.0: 540.0, - 50.0: 62.0, - 5000.0: 5095.0, - 4000.0: 4075.0, - 0.0: 0.0, - 3000.0: 3065.0, - 100.0: 117.0, - 2000.0: 2060.0, - 1000.0: 1060.0, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=500.0, - aspiration_air_transport_volume=20.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=1000.0, - dispense_mode=3.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=20.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=400.0, - dispense_stop_back_volume=0.0, - ) -) - - -star_mapping[(5000, False, True, False, Liquid.DMSO, False, True)] = ( - _5mlT_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 535.0, - 50.0: 60.3, - 5000.0: 5090.0, - 4000.0: 4078.0, - 0.0: 0.0, - 3000.0: 3066.0, - 100.0: 115.0, - 2000.0: 2057.0, - 10.0: 12.5, - 1000.0: 1054.0, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=500.0, - aspiration_air_transport_volume=20.0, - aspiration_blow_out_volume=20.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=500.0, - dispense_mode=5.0, - dispense_mix_flow_rate=500.0, - dispense_air_transport_volume=20.0, - dispense_blow_out_volume=20.0, - dispense_swap_speed=5.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=500.0, - dispense_stop_back_volume=0.0, -) - - -# First two times mixing with max volume. -star_mapping[(5000, False, True, False, Liquid.ETHANOL, True, False)] = ( - _5mlT_EtOH_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 300.0: 312.0, - 4500.0: 4573.0, - 3500.0: 3560.0, - 500.0: 519.0, - 2500.0: 2551.0, - 1500.0: 1542.0, - 5000.0: 5081.0, - 4000.0: 4066.0, - 3000.0: 3056.0, - 0.0: 0.0, - 2000.0: 2047.0, - 100.0: 104.0, - 1000.0: 1033.0, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=2000.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=1000.0, - dispense_mode=2.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(5000, False, True, False, Liquid.ETHANOL, True, True)] = ( - _5mlT_EtOH_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 563.0, - 50.0: 72.0, - 5000.0: 5230.0, - 4000.0: 4215.0, - 0.0: 0.0, - 3000.0: 3190.0, - 100.0: 129.5, - 2000.0: 2166.0, - 1000.0: 1095.0, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=30.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=30.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=1000.0, - dispense_mode=3.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=30.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=30.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(5000, False, True, False, Liquid.ETHANOL, False, True)] = ( - _5mlT_EtOH_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 555.0, - 50.0: 68.0, - 5000.0: 5204.0, - 4000.0: 4200.0, - 0.0: 0.0, - 3000.0: 3180.0, - 100.0: 123.5, - 2000.0: 2160.0, - 10.0: 22.0, - 1000.0: 1085.0, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=30.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=30.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=1000.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=30.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=30.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=30.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(5000, False, True, False, Liquid.GLYCERIN80, True, True)] = ( - _5mlT_Glycerin80_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 597.0, - 50.0: 89.0, - 5000.0: 5240.0, - 4000.0: 4220.0, - 0.0: 0.0, - 3000.0: 3203.0, - 100.0: 138.0, - 2000.0: 2195.0, - 1000.0: 1166.0, - }, - aspiration_flow_rate=1200.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=30.0, - aspiration_blow_out_volume=100.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=500.0, - dispense_mode=3.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=50.0, - dispense_blow_out_volume=100.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(5000, False, True, False, Liquid.GLYCERIN80, False, True)] = ( - _5mlT_Glycerin80_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 555.0, - 50.0: 71.0, - 5000.0: 5135.0, - 4000.0: 4115.0, - 0.0: 0.0, - 3000.0: 3127.0, - 100.0: 127.0, - 2000.0: 2115.0, - 10.0: 15.5, - 1000.0: 1075.0, - }, - aspiration_flow_rate=1000.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=70.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=5.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=50.0, - dispense_blow_out_volume=70.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(5000, False, True, False, Liquid.WATER, True, False)] = ( - _5mlT_Water_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 5000.0: 5030.0, - 4000.0: 4040.0, - 0.0: 0.0, - 3000.0: 3050.0, - 100.0: 104.0, - 2000.0: 2050.0, - 1000.0: 1040.0, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=500.0, - aspiration_air_transport_volume=20.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=1000.0, - dispense_mode=2.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=20.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=400.0, - dispense_stop_back_volume=20.0, -) - - -star_mapping[(5000, False, True, False, Liquid.WATER, True, True)] = ( - _5mlT_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 551.8, - 50.0: 66.4, - 5000.0: 5180.0, - 4000.0: 4165.0, - 0.0: 0.0, - 3000.0: 3148.0, - 100.0: 122.7, - 2000.0: 2128.0, - 1000.0: 1082.0, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=500.0, - aspiration_air_transport_volume=20.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=1000.0, - dispense_mode=3.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=20.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=400.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(5000, False, True, False, Liquid.WATER, False, True)] = ( - _5mlT_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 547.0, - 50.0: 65.5, - 5000.0: 5145.0, - 4000.0: 4145.0, - 0.0: 0.0, - 3000.0: 3130.0, - 100.0: 120.9, - 2000.0: 2125.0, - 10.0: 15.1, - 1000.0: 1075.0, - }, - aspiration_flow_rate=2000.0, - aspiration_mix_flow_rate=500.0, - aspiration_air_transport_volume=20.0, - aspiration_blow_out_volume=20.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=500.0, - dispense_mode=5.0, - dispense_mix_flow_rate=500.0, - dispense_air_transport_volume=20.0, - dispense_blow_out_volume=20.0, - dispense_swap_speed=5.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=500.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 250 -star_mapping[(1000, False, False, False, Liquid.WATER, True, False)] = ( - HighNeedle_Water_DispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 527.3, - 50.0: 56.8, - 0.0: 0.0, - 100.0: 110.4, - 20.0: 24.7, - 1000.0: 1046.5, - 200.0: 214.6, - 10.0: 13.2, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=500.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=350.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, False, False, Liquid.WATER, True, True)] = ( - HighNeedle_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 527.3, - 50.0: 56.8, - 0.0: 0.0, - 100.0: 110.4, - 20.0: 24.7, - 1000.0: 1046.5, - 200.0: 214.6, - 10.0: 13.2, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=500.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=350.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, False, False, Liquid.WATER, True, False)] = ( - HighNeedle_Water_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 527.3, - 50.0: 56.8, - 0.0: 0.0, - 100.0: 110.4, - 20.0: 24.7, - 1000.0: 1046.5, - 200.0: 214.6, - 10.0: 13.2, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=500.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=350.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 120 -star_mapping[(1000, False, False, False, Liquid.WATER, False, False)] = ( - HighNeedle_Water_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 50.0: 53.1, - 0.0: 0.0, - 20.0: 22.3, - 1000.0: 1000.0, - 10.0: 10.8, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=20.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=20.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, False, False, Liquid.WATER, False, True)] = ( - HighNeedle_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 50.0: 53.1, - 0.0: 0.0, - 20.0: 22.3, - 1000.0: 1000.0, - 10.0: 10.8, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=20.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=20.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, False, False, Liquid.WATER, False, False)] = ( - HighNeedle_Water_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 50.0: 53.1, - 0.0: 0.0, - 20.0: 22.3, - 1000.0: 1000.0, - 10.0: 10.8, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=20.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth Asp. 0.5mm -# - without pre-rinsing -# - Dispense: jet mode empty tip -# - Pipetting-Volumes jet-dispense between 20-1000µl -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 0.57 2.84 -# 50 0.30 0.27 -# 100 0.32 0.54 -# 500 0.13 -0.06 -# 1000 0.11 0.17 -star_mapping[(1000, False, True, False, Liquid.ACETONITRIL80WATER20, True, False)] = ( - HighVolumeAcetonitril80Water20DispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 514.5, - 50.0: 57.5, - 0.0: 0.0, - 20.0: 25.0, - 100.0: 110.5, - 1000.0: 1020.8, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=30.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=100.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth Asp. 2mm, without pre-rinsing -# - Disp.: jet mode empty tip -# - Pipetting-Volumes jet-dispense between 20-1000µl -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 1.04 - 2.68 -# 50 0.66 1.53 -# 100 0.20 0.09 -# 200 0.22 0.71 -# 500 0.14 0.01 -# 1000 0.17 0.02 -star_mapping[(1000, False, True, False, Liquid.ACETONITRILE, True, False)] = ( - HighVolumeAcetonitrilDispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 526.5, - 250.0: 269.0, - 50.0: 60.5, - 0.0: 0.0, - 20.0: 25.5, - 100.0: 112.7, - 1000.0: 1045.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=30.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=100.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.ACETONITRILE, True, True)] = ( - HighVolumeAcetonitrilDispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 526.5, - 250.0: 269.0, - 50.0: 60.5, - 0.0: 0.0, - 100.0: 112.7, - 20.0: 25.5, - 1000.0: 1045.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=30.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.ACETONITRILE, True, False)] = ( - HighVolumeAcetonitrilDispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 526.5, - 250.0: 269.0, - 50.0: 60.5, - 0.0: 0.0, - 100.0: 112.7, - 20.0: 25.5, - 1000.0: 1045.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=30.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=10.0, -) - - -# - submerge depth: Asp. 2mm -# Disp. 2mm -# - without pre-rinsing -# - dispense mode surface empty tip -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 10 2.06 0.63 -# 20 0.59 1.63 -# 50 0.41 2.27 -# 100 0.25 0.40 -# 200 0.18 0.69 -# 500 0.23 0.04 -# 1000 0.22 0.05 -star_mapping[(1000, False, True, False, Liquid.ACETONITRILE, False, False)] = ( - HighVolumeAcetonitrilDispenseSurface -) = HamiltonLiquidClass( - curve={ - 500.0: 525.4, - 250.0: 267.0, - 50.0: 57.6, - 0.0: 0.0, - 20.0: 23.8, - 100.0: 111.2, - 10.0: 12.1, - 1000.0: 1048.8, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=0.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.ACETONITRILE, False, True)] = ( - HighVolumeAcetonitrilDispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 525.4, - 250.0: 267.0, - 50.0: 57.6, - 0.0: 0.0, - 100.0: 111.2, - 20.0: 23.8, - 1000.0: 1048.8, - 10.0: 12.1, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=0.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.ACETONITRILE, False, False)] = ( - HighVolumeAcetonitrilDispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 525.4, - 250.0: 267.0, - 50.0: 57.6, - 0.0: 0.0, - 100.0: 111.2, - 20.0: 23.8, - 1000.0: 1048.8, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=0.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# - Submerge depth: Aspiration 2.0mm -# (bei Schaumbildung durch mischen/vorbenetzen evtl.5mm, LLD-Erkennung) -# - Mischen 3-5 x 950µl, mix position 0.5mm, je nach Volumen im Tube -star_mapping[(1000, False, True, False, Liquid.BLOOD, True, False)] = HighVolumeBloodDispenseJet = ( - HamiltonLiquidClass( - curve={ - 500.0: 536.3, - 250.0: 275.6, - 50.0: 59.8, - 0.0: 0.0, - 20.0: 26.2, - 100.0: 115.3, - 10.0: 12.2, - 1000.0: 1061.6, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=300.0, - dispense_stop_back_volume=0.0, - ) -) - - -# - submerge depth Asp. 5mm, (build airbubbles with mix) -# - 5 x pre-rinsing/mix, with 1000ul, mix position 1mm -# - Disp. mode jet empty tip -# - Pipettingvolume jet-dispense from 10µl - 200µl -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 10 2.95 0.35 -# 20 0.69 0.07 -# 50 0.40 0.46 -# 100 0.23 0.93 -# 200 0.15 0.41 -# -star_mapping[(1000, False, True, False, Liquid.BRAINHOMOGENATE, True, False)] = ( - HighVolumeBrainHomogenateDispenseJet -) = HamiltonLiquidClass( - curve={ - 50.0: 57.9, - 0.0: 0.0, - 20.0: 25.3, - 100.0: 111.3, - 10.0: 14.2, - 200.0: 214.5, - 1000.0: 1038.6, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=0.0, - dispense_mix_flow_rate=500.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth Asp. 1mm, pLLD very high -# - 3 x pre-rinsing, with probevolume or 1 x pre-rinsing with 1000ul, -# mix position 1mm (mix flow rate is intentional low) -# - Disp. mode jet empty tip -# - Pipettingvolume jet-dispense from 400µl - 1000µl, small volumes 20-100ul drops faster out, -# because the channel is not enough saturated -# - To protect, the distance from Asp. to Disp. should be as short as possible, -# because Chloroform could be drop out in a long way! -# - a break time after dispense with about 10s time counter, makes sure the drop which residue -# after dispense drops back into the probetube -# - some droplets on tip after dispense are also with more air transport volume not avoidable -# - sometimes it helps to use Filtertips -# - Correction Curve is taken from MeOH Liqiudclass -# -# -# -star_mapping[(1000, False, True, False, Liquid.CHLOROFORM, True, False)] = ( - HighVolumeChloroformDispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 520.5, - 250.0: 269.0, - 50.0: 62.9, - 0.0: 0.0, - 100.0: 116.3, - 1000.0: 1030.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=0.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=30.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=100.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -# - ohne vorbenetzen, gleicher Tip -# - Aspiration submerge depth 1.0mm -# - Prealiquot equal to Aliquotvolume, jet mode part volume -# - Aliquot, jet mode part volume -# - Postaliquot equal to Aliquotvolume, jet mode empty tip -# -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 50 (12 Aliquots) 0.22 -4.84 -# 100 ( 9 Aliquots) 0.25 -4.81 -# -# -star_mapping[(1000, False, True, False, Liquid.DMSO, True, False)] = HighVolumeDMSOAliquotJet = ( - HamiltonLiquidClass( - curve={ - 500.0: 500.0, - 250.0: 250.0, - 0.0: 0.0, - 30.0: 30.0, - 20.0: 20.0, - 100.0: 100.0, - 10.0: 10.0, - 750.0: 750.0, - 1000.0: 1000.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=10.0, - ) -) - - -star_mapping[(1000, True, True, True, Liquid.DMSO, True, True)] = ( - HighVolumeFilter_96COREHead1000ul_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 508.2, - 0.0: 0.0, - 20.0: 21.7, - 100.0: 101.7, - 1000.0: 1017.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, True, True, True, Liquid.DMSO, False, True)] = ( - HighVolumeFilter_96COREHead1000ul_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 512.5, - 0.0: 0.0, - 100.0: 105.8, - 10.0: 12.7, - 1000.0: 1024.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, True, True, True, Liquid.WATER, True, True)] = ( - HighVolumeFilter_96COREHead1000ul_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 524.0, - 0.0: 0.0, - 20.0: 24.0, - 100.0: 109.2, - 1000.0: 1040.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, True, True, True, Liquid.WATER, False, True)] = ( - HighVolumeFilter_96COREHead1000ul_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 522.0, - 0.0: 0.0, - 100.0: 108.3, - 1000.0: 1034.0, - 10.0: 12.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# - ohne vorbenetzen, gleicher Tip -# - Aspiration submerge depth 1.0mm -# - Prealiquot equal to Aliquotvolume, jet mode part volume -# - Aliquot, jet mode part volume -# - Postaliquot equal to Aliquotvolume, jet mode empty tip -# -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 50 (12 Aliquots) 0.22 -4.84 -# 100 ( 9 Aliquots) 0.25 -4.81 -# -# -star_mapping[(1000, False, True, True, Liquid.DMSO, True, False)] = ( - HighVolumeFilter_DMSO_AliquotDispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 500.0, - 250.0: 250.0, - 30.0: 30.0, - 0.0: 0.0, - 100.0: 100.0, - 20.0: 20.0, - 1000.0: 1000.0, - 750.0: 750.0, - 10.0: 10.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 250 -star_mapping[(1000, False, True, True, Liquid.DMSO, True, False)] = ( - HighVolumeFilter_DMSO_DispenseJet -) = HamiltonLiquidClass( - curve={ - 5.0: 5.1, - 500.0: 511.2, - 250.0: 256.2, - 50.0: 52.2, - 0.0: 0.0, - 20.0: 21.3, - 100.0: 103.4, - 10.0: 10.7, - 1000.0: 1021.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.DMSO, True, True)] = ( - HighVolumeFilter_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 511.2, - 5.0: 5.1, - 250.0: 256.2, - 50.0: 52.2, - 0.0: 0.0, - 100.0: 103.4, - 20.0: 21.3, - 1000.0: 1021.0, - 10.0: 10.7, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.DMSO, True, False)] = ( - HighVolumeFilter_DMSO_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 517.2, - 0.0: 0.0, - 100.0: 109.5, - 20.0: 27.0, - 1000.0: 1027.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 120 -star_mapping[(1000, False, True, True, Liquid.DMSO, False, False)] = ( - HighVolumeFilter_DMSO_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 500.0: 514.3, - 250.0: 259.0, - 50.0: 54.4, - 0.0: 0.0, - 20.0: 22.8, - 100.0: 105.8, - 10.0: 12.1, - 1000.0: 1024.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.DMSO, False, True)] = ( - HighVolumeFilter_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 514.3, - 250.0: 259.0, - 50.0: 54.4, - 0.0: 0.0, - 100.0: 105.8, - 20.0: 22.8, - 1000.0: 1024.5, - 10.0: 12.1, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# -star_mapping[(1000, False, True, True, Liquid.DMSO, False, False)] = ( - HighVolumeFilter_DMSO_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 514.3, - 250.0: 259.0, - 50.0: 54.4, - 0.0: 0.0, - 100.0: 105.8, - 20.0: 22.8, - 1000.0: 1024.5, - 10.0: 12.1, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 250, Stop back volume = 0 -star_mapping[(1000, False, True, True, Liquid.ETHANOL, True, False)] = ( - HighVolumeFilter_EtOH_DispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 534.8, - 250.0: 273.0, - 50.0: 62.9, - 0.0: 0.0, - 20.0: 27.8, - 100.0: 116.3, - 10.0: 15.8, - 1000.0: 1053.9, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.ETHANOL, True, True)] = ( - HighVolumeFilter_EtOH_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 534.8, - 250.0: 273.0, - 50.0: 62.9, - 0.0: 0.0, - 100.0: 116.3, - 20.0: 27.8, - 1000.0: 1053.9, - 10.0: 15.8, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.ETHANOL, True, False)] = ( - HighVolumeFilter_EtOH_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 534.8, - 250.0: 273.0, - 50.0: 62.9, - 0.0: 0.0, - 100.0: 116.3, - 20.0: 27.8, - 1000.0: 1053.9, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=5.0, -) - - -# V1.1: Set mix flow rate to 120 -star_mapping[(1000, False, True, True, Liquid.ETHANOL, False, False)] = ( - HighVolumeFilter_EtOH_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 500.0: 528.4, - 250.0: 269.2, - 50.0: 61.2, - 0.0: 0.0, - 20.0: 27.6, - 100.0: 114.0, - 10.0: 15.7, - 1000.0: 1044.3, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.ETHANOL, False, True)] = ( - HighVolumeFilter_EtOH_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 528.4, - 250.0: 269.2, - 50.0: 61.2, - 0.0: 0.0, - 100.0: 114.0, - 20.0: 27.6, - 1000.0: 1044.3, - 10.0: 15.7, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.ETHANOL, False, False)] = ( - HighVolumeFilter_EtOH_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 528.4, - 250.0: 269.2, - 50.0: 61.2, - 0.0: 0.0, - 100.0: 114.0, - 20.0: 27.6, - 1000.0: 1044.3, - 10.0: 15.7, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 200 -star_mapping[(1000, False, True, True, Liquid.GLYCERIN80, True, False)] = ( - HighVolumeFilter_Glycerin80_DispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 537.8, - 250.0: 277.0, - 50.0: 63.3, - 0.0: 0.0, - 20.0: 28.0, - 100.0: 118.8, - 10.0: 15.2, - 1000.0: 1060.0, - }, - aspiration_flow_rate=200.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=0.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 200 -star_mapping[(1000, False, True, True, Liquid.GLYCERIN80, True, True)] = ( - HighVolumeFilter_Glycerin80_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 537.8, - 250.0: 277.0, - 50.0: 63.3, - 0.0: 0.0, - 100.0: 118.8, - 20.0: 28.0, - 1000.0: 1060.0, - 10.0: 15.2, - }, - aspiration_flow_rate=200.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 120 -star_mapping[(1000, False, True, True, Liquid.GLYCERIN80, False, False)] = ( - HighVolumeFilter_Glycerin80_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 500.0: 513.5, - 250.0: 257.2, - 50.0: 55.0, - 0.0: 0.0, - 20.0: 22.7, - 100.0: 105.5, - 10.0: 12.2, - 1000.0: 1027.2, - }, - aspiration_flow_rate=150.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.5, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.GLYCERIN80, False, True)] = ( - HighVolumeFilter_Glycerin80_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 513.5, - 250.0: 257.2, - 50.0: 55.0, - 0.0: 0.0, - 100.0: 105.5, - 20.0: 22.7, - 1000.0: 1027.2, - 10.0: 12.2, - }, - aspiration_flow_rate=150.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.5, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.GLYCERIN80, False, False)] = ( - HighVolumeFilter_Glycerin80_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 513.5, - 250.0: 257.2, - 50.0: 55.0, - 0.0: 0.0, - 100.0: 105.5, - 20.0: 22.7, - 1000.0: 1027.2, - 10.0: 12.2, - }, - aspiration_flow_rate=150.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.5, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 250 -star_mapping[(1000, False, True, True, Liquid.SERUM, True, False)] = ( - HighVolumeFilter_Serum_AliquotDispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 500.0, - 250.0: 250.0, - 30.0: 30.0, - 0.0: 0.0, - 100.0: 100.0, - 20.0: 20.0, - 1000.0: 1000.0, - 750.0: 750.0, - 10.0: 10.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=300.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 250 -star_mapping[(1000, False, True, True, Liquid.SERUM, True, False)] = ( - HighVolumeFilter_Serum_AliquotJet -) = HamiltonLiquidClass( - curve={ - 500.0: 500.0, - 250.0: 250.0, - 0.0: 0.0, - 30.0: 30.0, - 20.0: 20.0, - 100.0: 100.0, - 10.0: 10.0, - 750.0: 750.0, - 1000.0: 1000.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=300.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 250, Settling time = 0 -star_mapping[(1000, False, True, True, Liquid.SERUM, True, False)] = ( - HighVolumeFilter_Serum_DispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 525.3, - 250.0: 266.6, - 50.0: 57.9, - 0.0: 0.0, - 20.0: 24.2, - 100.0: 111.3, - 10.0: 12.2, - 1000.0: 1038.6, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.SERUM, True, True)] = ( - HighVolumeFilter_Serum_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 525.3, - 250.0: 266.6, - 50.0: 57.9, - 0.0: 0.0, - 100.0: 111.3, - 20.0: 24.2, - 1000.0: 1038.6, - 10.0: 12.2, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.SERUM, True, False)] = ( - HighVolumeFilter_Serum_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 525.3, - 0.0: 0.0, - 100.0: 111.3, - 20.0: 27.3, - 1000.0: 1046.6, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 120 -star_mapping[(1000, False, True, True, Liquid.SERUM, False, False)] = ( - HighVolumeFilter_Serum_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 500.0: 517.5, - 250.0: 261.9, - 50.0: 55.9, - 0.0: 0.0, - 20.0: 23.2, - 100.0: 108.2, - 10.0: 11.8, - 1000.0: 1026.7, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.SERUM, False, True)] = ( - HighVolumeFilter_Serum_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 517.5, - 250.0: 261.9, - 50.0: 55.9, - 0.0: 0.0, - 100.0: 108.2, - 20.0: 23.2, - 1000.0: 1026.7, - 10.0: 11.8, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.SERUM, False, False)] = ( - HighVolumeFilter_Serum_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 523.5, - 0.0: 0.0, - 100.0: 111.2, - 20.0: 23.2, - 1000.0: 1038.7, - 10.0: 11.8, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 250 -star_mapping[(1000, False, True, True, Liquid.WATER, True, False)] = ( - HighVolumeFilter_Water_AliquotDispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 500.0, - 250.0: 250.0, - 30.0: 30.0, - 0.0: 0.0, - 100.0: 100.0, - 20.0: 20.0, - 1000.0: 1000.0, - 750.0: 750.0, - 10.0: 10.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 250 -star_mapping[(1000, False, True, True, Liquid.WATER, True, False)] = ( - HighVolumeFilter_Water_AliquotJet -) = HamiltonLiquidClass( - curve={ - 500.0: 500.0, - 250.0: 250.0, - 0.0: 0.0, - 30.0: 30.0, - 20.0: 20.0, - 100.0: 100.0, - 10.0: 10.0, - 750.0: 750.0, - 1000.0: 1000.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 250 -star_mapping[(1000, False, True, True, Liquid.WATER, True, False)] = ( - HighVolumeFilter_Water_DispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 521.7, - 50.0: 57.2, - 0.0: 0.0, - 20.0: 24.6, - 100.0: 109.6, - 10.0: 13.3, - 200.0: 212.9, - 1000.0: 1034.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.WATER, True, True)] = ( - HighVolumeFilter_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 521.7, - 50.0: 57.2, - 0.0: 0.0, - 100.0: 109.6, - 20.0: 24.6, - 1000.0: 1034.0, - 200.0: 212.9, - 10.0: 13.3, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.WATER, True, False)] = ( - HighVolumeFilter_Water_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 521.7, - 50.0: 57.2, - 0.0: 0.0, - 100.0: 109.6, - 20.0: 27.0, - 1000.0: 1034.0, - 200.0: 212.9, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=20.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 120, Clot retract height = 0 -star_mapping[(1000, False, True, True, Liquid.WATER, False, False)] = ( - HighVolumeFilter_Water_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 500.0: 518.3, - 50.0: 56.3, - 0.0: 0.0, - 20.0: 23.9, - 100.0: 108.3, - 10.0: 12.5, - 200.0: 211.0, - 1000.0: 1028.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.WATER, False, True)] = ( - HighVolumeFilter_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 518.3, - 50.0: 56.3, - 0.0: 0.0, - 20.0: 23.9, - 100.0: 108.3, - 10.0: 12.5, - 200.0: 211.0, - 1000.0: 1028.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, True, Liquid.WATER, False, False)] = ( - HighVolumeFilter_Water_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 518.3, - 50.0: 56.3, - 0.0: 0.0, - 100.0: 108.3, - 20.0: 23.9, - 1000.0: 1028.5, - 200.0: 211.0, - 10.0: 12.7, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=30.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth Asp. 2mm -# - 3 x pre-rinsing, with probevolume, mix position 1mm (mix flow rate is intentional low) -# - Disp. mode jet empty tip -# - Pipettingvolume jet-dispense from 50µl - 1000µl -# - To protect, the distance from Asp. to Disp. should be as short as possible, -# because MeOH could be drop out in a long way! -# - some droplets on tip after dispense are also with more air transport volume not avoidable -# - sometimes it helps to use Filtertips -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 50 0.61 - 1.88 -# 100 1.16 3.02 -# 200 0.55 1.87 -# 500 0.49 - 0.17 -# 1000 0.55 0.712 -# -star_mapping[(1000, False, True, False, Liquid.METHANOL, True, False)] = ( - HighVolumeMeOHDispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 520.5, - 250.0: 269.0, - 50.0: 62.9, - 0.0: 0.0, - 100.0: 116.3, - 1000.0: 1030.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=0.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=30.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=100.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth Asp. 2mm -# - 3 x pre-rinsing, with probevolume, mix position 1mm (mix flow rate is intentional low) -# 200 -1000µl 2x is enough -# - Disp. mode jet empty tip -# - Pipettingvolume jet-dispense from 50µl - 1000µl -# - To protect, the distance from Asp. to Disp. should be as short as possible, -# because MeOH could be drop out in a long way! -# - some droplets on tip after dispense are also with more air transport volume not avoidable -# - sometimes it helps to use Filtertips -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 10 3.71 - 5.23 -# 20 3.12 - 2.27 -# 50 3.97 1.85 -# 100 0.54 1.10 -# 200 0.48 0.18 -# 500 0.17 0.22 -# 1000 0.75 0.29 -star_mapping[(1000, False, True, False, Liquid.METHANOL, False, False)] = ( - HighVolumeMeOHDispenseSurface -) = HamiltonLiquidClass( - curve={ - 500.0: 518.0, - 50.0: 61.3, - 0.0: 0.0, - 20.0: 29.3, - 100.0: 111.0, - 10.0: 19.3, - 200.0: 215.0, - 1000.0: 1030.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=0.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=50.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth Asp. 2mm -# - without pre-rinsing -# - Disp. mode jet empty tip -# - Pipettingvolume jet-dispense from 20µl - 1000µl -# -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 1.45 - 4.76 -# 50 0.59 0.08 -# 100 0.24 0.85 -# 200 0.14 0.06 -# 500 0.12 - 0.07 -# 1000 0.16 0.08 -star_mapping[(1000, False, True, False, Liquid.METHANOL70WATER030, True, False)] = ( - HighVolumeMeOHH2ODispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 528.5, - 250.0: 269.0, - 50.0: 60.5, - 0.0: 0.0, - 100.0: 114.3, - 1000.0: 1050.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=0.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=50.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=100.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -# - use pLLD -# - submerge depth>: Asp. 0.5 mm -# Disp. 1.0 mm (surface) -# - without pre-rinsing -# - dispense mode jet empty tip -# -# -# Typical performance data under laboratory conditions: -# -# (Liquid adapting with parameters like DMSO, correctioncurve like Glycerin80%) -# tested two volumes -# -# Volume µl Precision % Trueness % -# 20 2.85 2.92 -# 200 0.14 0.59 -# -star_mapping[(1000, False, True, False, Liquid.OCTANOL, True, False)] = ( - HighVolumeOctanol100DispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 537.8, - 250.0: 277.0, - 50.0: 63.3, - 0.0: 0.0, - 20.0: 28.0, - 100.0: 118.8, - 10.0: 15.2, - 1000.0: 1060.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=350.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -# - use pLLD -# - submerge depth>: Asp. 0.5 mm -# Disp. 1.0 mm (surface) -# - without pre-rinsing -# - dispense mode surface empty tip -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 10 2.47 - 6.09 -# 20 0.90 1.77 -# 50 0.45 3.14 -# 100 1.07 1.23 -# 200 0.30 1.30 -# 500 0.31 0.01 -# 1000 0.33 0.01 -star_mapping[(1000, False, True, False, Liquid.OCTANOL, False, False)] = ( - HighVolumeOctanol100DispenseSurface -) = HamiltonLiquidClass( - curve={ - 500.0: 531.3, - 250.0: 265.0, - 50.0: 54.4, - 0.0: 0.0, - 20.0: 23.3, - 100.0: 108.8, - 10.0: 12.1, - 1000.0: 1058.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, True, True, False, Liquid.DMSO, True, False)] = ( - HighVolume_96COREHead1000ul_DMSO_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 500.0: 524.0, - 0.0: 0.0, - 100.0: 107.2, - 20.0: 24.0, - 1000.0: 1025.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=20.0, -) - - -star_mapping[(1000, True, True, False, Liquid.DMSO, True, True)] = ( - HighVolume_96COREHead1000ul_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 508.2, - 0.0: 0.0, - 100.0: 101.7, - 20.0: 21.7, - 1000.0: 1017.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, True, True, False, Liquid.DMSO, False, True)] = ( - HighVolume_96COREHead1000ul_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 512.5, - 0.0: 0.0, - 100.0: 105.8, - 1000.0: 1024.5, - 10.0: 12.7, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# to prevent drop's, mix 2x with e.g. 500ul -star_mapping[(1000, True, True, False, Liquid.ETHANOL, True, False)] = ( - HighVolume_96COREHead1000ul_EtOH_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 300.0: 300.0, - 500.0: 500.0, - 0.0: 0.0, - 100.0: 100.0, - 20.0: 20.0, - 1000.0: 1000.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=4.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=400.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(1000, True, True, False, Liquid.ETHANOL, True, True)] = ( - HighVolume_96COREHead1000ul_EtOH_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 516.5, - 0.0: 0.0, - 100.0: 108.3, - 20.0: 24.0, - 1000.0: 1027.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -# to prevent drop's, mix 2x with e.g. 500ul -star_mapping[(1000, True, True, False, Liquid.ETHANOL, False, True)] = ( - HighVolume_96COREHead1000ul_EtOH_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 516.5, - 0.0: 0.0, - 100.0: 107.0, - 1000.0: 1027.0, - 10.0: 14.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=150.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=5.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=5.0, - dispense_mix_flow_rate=150.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=5.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, True, True, False, Liquid.GLYCERIN80, False, True)] = ( - HighVolume_96COREHead1000ul_Glycerin80_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 522.0, - 0.0: 0.0, - 100.0: 115.3, - 1000.0: 1034.0, - 10.0: 12.5, - }, - aspiration_flow_rate=150.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.5, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, True, True, False, Liquid.WATER, True, False)] = ( - HighVolume_96COREHead1000ul_Water_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 500.0: 524.0, - 0.0: 0.0, - 100.0: 107.2, - 20.0: 24.0, - 1000.0: 1025.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(1000, True, True, False, Liquid.WATER, True, True)] = ( - HighVolume_96COREHead1000ul_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 524.0, - 0.0: 0.0, - 100.0: 107.2, - 20.0: 24.0, - 1000.0: 1025.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, True, True, False, Liquid.WATER, False, True)] = ( - HighVolume_96COREHead1000ul_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 522.0, - 0.0: 0.0, - 100.0: 108.3, - 1000.0: 1034.0, - 10.0: 12.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# Liquid class for wash high volume tips with CO-RE 96 Head in CO-RE 96 Head Washer. -star_mapping[(1000, True, True, False, Liquid.WATER, False, False)] = ( - HighVolume_Core96Washer_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 500.0: 520.0, - 50.0: 56.3, - 0.0: 0.0, - 100.0: 110.0, - 20.0: 23.9, - 1000.0: 1050.0, - 200.0: 212.0, - 10.0: 12.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=220.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=220.0, - dispense_mode=5.0, - dispense_mix_flow_rate=220.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=5.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# - ohne vorbenetzen, gleicher Tip -# - Aspiration submerge depth 1.0mm -# - Prealiquot equal to Aliquotvolume, jet mode part volume -# - Aliquot, jet mode part volume -# - Postaliquot equal to Aliquotvolume, jet mode empty tip -# -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 50 (12 Aliquots) 0.22 -4.84 -# 100 ( 9 Aliquots) 0.25 -4.81 -# -# -star_mapping[(1000, False, True, False, Liquid.DMSO, True, False)] = ( - HighVolume_DMSO_AliquotDispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 500.0, - 250.0: 250.0, - 30.0: 30.0, - 0.0: 0.0, - 100.0: 100.0, - 20.0: 20.0, - 1000.0: 1000.0, - 750.0: 750.0, - 10.0: 10.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 250 -star_mapping[(1000, False, True, False, Liquid.DMSO, True, False)] = HighVolume_DMSO_DispenseJet = ( - HamiltonLiquidClass( - curve={ - 5.0: 5.1, - 500.0: 511.2, - 250.0: 256.2, - 50.0: 52.2, - 0.0: 0.0, - 20.0: 21.3, - 100.0: 103.4, - 10.0: 10.7, - 1000.0: 1021.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, - ) -) - - -star_mapping[(1000, False, True, False, Liquid.DMSO, True, True)] = ( - HighVolume_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 511.2, - 5.0: 5.1, - 250.0: 256.2, - 50.0: 52.2, - 0.0: 0.0, - 100.0: 103.4, - 20.0: 21.3, - 1000.0: 1021.0, - 10.0: 10.7, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.DMSO, True, False)] = ( - HighVolume_DMSO_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 520.2, - 0.0: 0.0, - 100.0: 112.0, - 20.0: 27.0, - 1000.0: 1031.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=5.0, -) - - -# V1.1: Set mix flow rate to 120 -star_mapping[(1000, False, True, False, Liquid.DMSO, False, False)] = ( - HighVolume_DMSO_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 500.0: 514.3, - 250.0: 259.0, - 50.0: 54.4, - 0.0: 0.0, - 20.0: 22.8, - 100.0: 105.8, - 10.0: 12.1, - 1000.0: 1024.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.DMSO, False, True)] = ( - HighVolume_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 514.3, - 250.0: 259.0, - 50.0: 54.4, - 0.0: 0.0, - 100.0: 105.8, - 20.0: 22.8, - 1000.0: 1024.5, - 10.0: 12.1, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.DMSO, False, False)] = ( - HighVolume_DMSO_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 514.3, - 250.0: 259.0, - 50.0: 54.4, - 0.0: 0.0, - 100.0: 105.8, - 20.0: 22.8, - 1000.0: 1024.5, - 10.0: 12.4, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set Stop back volume to 0 -star_mapping[(1000, False, True, False, Liquid.ETHANOL, True, False)] = ( - HighVolume_EtOH_DispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 534.8, - 250.0: 273.0, - 50.0: 62.9, - 0.0: 0.0, - 20.0: 27.8, - 100.0: 116.3, - 10.0: 15.8, - 1000.0: 1053.9, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=0.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.ETHANOL, True, True)] = ( - HighVolume_EtOH_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 534.8, - 250.0: 273.0, - 50.0: 62.9, - 0.0: 0.0, - 100.0: 116.3, - 20.0: 27.8, - 1000.0: 1053.9, - 10.0: 15.8, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.ETHANOL, True, False)] = ( - HighVolume_EtOH_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 529.0, - 50.0: 62.9, - 0.0: 0.0, - 100.0: 114.5, - 20.0: 27.8, - 1000.0: 1053.9, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=5.0, -) - - -star_mapping[(1000, False, True, False, Liquid.ETHANOL, False, False)] = ( - HighVolume_EtOH_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 500.0: 528.4, - 250.0: 269.2, - 50.0: 61.2, - 0.0: 0.0, - 100.0: 114.0, - 20.0: 27.6, - 1000.0: 1044.3, - 10.0: 15.7, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.ETHANOL, False, True)] = ( - HighVolume_EtOH_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 528.4, - 250.0: 269.2, - 50.0: 61.2, - 0.0: 0.0, - 20.0: 27.6, - 100.0: 114.0, - 10.0: 15.7, - 1000.0: 1044.3, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.ETHANOL, False, False)] = ( - HighVolume_EtOH_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 528.4, - 250.0: 269.2, - 50.0: 61.2, - 0.0: 0.0, - 100.0: 114.0, - 20.0: 27.6, - 1000.0: 1044.3, - 10.0: 14.7, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 200 -star_mapping[(1000, False, True, False, Liquid.GLYCERIN80, True, False)] = ( - HighVolume_Glycerin80_DispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 537.8, - 250.0: 277.0, - 50.0: 63.3, - 0.0: 0.0, - 20.0: 28.0, - 100.0: 118.8, - 10.0: 15.2, - 1000.0: 1060.0, - }, - aspiration_flow_rate=200.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=0.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.GLYCERIN80, True, True)] = ( - HighVolume_Glycerin80_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 537.8, - 250.0: 277.0, - 50.0: 63.3, - 0.0: 0.0, - 100.0: 118.8, - 20.0: 28.0, - 1000.0: 1060.0, - 10.0: 15.2, - }, - aspiration_flow_rate=200.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 120 -star_mapping[(1000, False, True, False, Liquid.GLYCERIN80, False, False)] = ( - HighVolume_Glycerin80_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 500.0: 513.5, - 250.0: 257.2, - 50.0: 55.0, - 0.0: 0.0, - 20.0: 22.7, - 100.0: 105.5, - 10.0: 12.2, - 1000.0: 1027.2, - }, - aspiration_flow_rate=150.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.5, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.GLYCERIN80, False, True)] = ( - HighVolume_Glycerin80_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 513.5, - 250.0: 257.2, - 50.0: 55.0, - 0.0: 0.0, - 100.0: 105.5, - 20.0: 22.7, - 1000.0: 1027.2, - 10.0: 12.2, - }, - aspiration_flow_rate=150.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.5, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.GLYCERIN80, False, False)] = ( - HighVolume_Glycerin80_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 513.5, - 250.0: 257.2, - 50.0: 55.0, - 0.0: 0.0, - 100.0: 105.5, - 20.0: 22.7, - 1000.0: 1027.2, - 10.0: 12.2, - }, - aspiration_flow_rate=150.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.5, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 250 -star_mapping[(1000, False, True, False, Liquid.SERUM, True, False)] = ( - HighVolume_Serum_AliquotDispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 500.0, - 250.0: 250.0, - 30.0: 30.0, - 0.0: 0.0, - 100.0: 100.0, - 20.0: 20.0, - 1000.0: 1000.0, - 750.0: 750.0, - 10.0: 10.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=300.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 250 -star_mapping[(1000, False, True, False, Liquid.SERUM, True, False)] = ( - HighVolume_Serum_AliquotJet -) = HamiltonLiquidClass( - curve={ - 500.0: 500.0, - 250.0: 250.0, - 0.0: 0.0, - 30.0: 30.0, - 20.0: 20.0, - 100.0: 100.0, - 10.0: 10.0, - 750.0: 750.0, - 1000.0: 1000.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=300.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 250, settling time = 0 -star_mapping[(1000, False, True, False, Liquid.SERUM, True, False)] = ( - HighVolume_Serum_DispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 525.3, - 250.0: 266.6, - 50.0: 57.9, - 0.0: 0.0, - 20.0: 24.2, - 100.0: 111.3, - 10.0: 12.2, - 1000.0: 1038.6, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.SERUM, True, True)] = ( - HighVolume_Serum_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 525.3, - 250.0: 266.6, - 50.0: 57.9, - 0.0: 0.0, - 100.0: 111.3, - 20.0: 24.2, - 1000.0: 1038.6, - 10.0: 12.2, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.SERUM, True, False)] = ( - HighVolume_Serum_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 525.3, - 0.0: 0.0, - 100.0: 111.3, - 20.0: 27.3, - 1000.0: 1046.6, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 120 -star_mapping[(1000, False, True, False, Liquid.SERUM, False, False)] = ( - HighVolume_Serum_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 500.0: 517.5, - 250.0: 261.9, - 50.0: 55.9, - 0.0: 0.0, - 20.0: 23.2, - 100.0: 108.2, - 10.0: 11.8, - 1000.0: 1026.7, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.SERUM, False, True)] = ( - HighVolume_Serum_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 517.5, - 250.0: 261.9, - 50.0: 55.9, - 0.0: 0.0, - 100.0: 108.2, - 20.0: 23.2, - 1000.0: 1026.7, - 10.0: 11.8, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.SERUM, False, False)] = ( - HighVolume_Serum_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 50.0: 55.9, - 0.0: 0.0, - 100.0: 108.2, - 20.0: 23.2, - 1000.0: 1037.7, - 10.0: 11.8, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 250 -star_mapping[(1000, False, True, False, Liquid.WATER, True, False)] = ( - HighVolume_Water_AliquotDispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 500.0, - 250.0: 250.0, - 30.0: 30.0, - 0.0: 0.0, - 100.0: 100.0, - 20.0: 20.0, - 1000.0: 1000.0, - 750.0: 750.0, - 10.0: 10.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 250 -star_mapping[(1000, False, True, False, Liquid.WATER, True, False)] = ( - HighVolume_Water_AliquotJet -) = HamiltonLiquidClass( - curve={ - 500.0: 500.0, - 250.0: 250.0, - 0.0: 0.0, - 30.0: 30.0, - 20.0: 20.0, - 100.0: 100.0, - 10.0: 10.0, - 750.0: 750.0, - 1000.0: 1000.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 250 -star_mapping[(1000, False, True, False, Liquid.WATER, True, False)] = ( - HighVolume_Water_DispenseJet -) = HamiltonLiquidClass( - curve={ - 500.0: 521.7, - 50.0: 57.2, - 0.0: 0.0, - 20.0: 24.6, - 100.0: 109.6, - 10.0: 13.3, - 200.0: 212.9, - 1000.0: 1034.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=0.0, - dispense_mix_flow_rate=250.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.WATER, True, True)] = ( - HighVolume_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 521.7, - 50.0: 57.2, - 0.0: 0.0, - 100.0: 109.6, - 20.0: 24.6, - 1000.0: 1034.0, - 200.0: 212.9, - 10.0: 13.3, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=40.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=40.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.WATER, True, False)] = ( - HighVolume_Water_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 521.7, - 0.0: 0.0, - 100.0: 109.6, - 20.0: 26.9, - 1000.0: 1040.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=300.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=18.0, -) - - -# V1.1: Set mix flow rate to 120, clot retract height = 0 -star_mapping[(1000, False, True, False, Liquid.WATER, False, False)] = ( - HighVolume_Water_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 500.0: 518.3, - 50.0: 56.3, - 0.0: 0.0, - 20.0: 23.9, - 100.0: 108.3, - 10.0: 12.5, - 200.0: 211.0, - 1000.0: 1028.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.WATER, False, True)] = ( - HighVolume_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 500.0: 518.3, - 50.0: 56.3, - 0.0: 0.0, - 100.0: 108.3, - 20.0: 23.9, - 1000.0: 1028.5, - 200.0: 211.0, - 10.0: 12.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=120.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(1000, False, True, False, Liquid.WATER, False, False)] = ( - HighVolume_Water_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 500.0: 518.3, - 50.0: 56.3, - 0.0: 0.0, - 100.0: 108.3, - 20.0: 23.9, - 1000.0: 1036.5, - 200.0: 211.0, - 10.0: 12.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=120.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=50.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# - without pre-rinsing -# - submerge depth Asp. 1mm -# - for Disp. in empty PCR-Plate from 1µl up -# - fix height from bottom between 0.5-0.7mm -# - dispense mode jet empty tip -# - also with higher DNA concentration -star_mapping[(10, False, False, False, Liquid.DNA_TRIS_EDTA, True, False)] = ( - LowNeedleDNADispenseJet -) = HamiltonLiquidClass( - curve={ - 5.0: 5.7, - 0.5: 1.0, - 50.0: 53.0, - 0.0: 0.0, - 20.0: 22.1, - 1.0: 1.5, - 10.0: 10.8, - 2.0: 2.7, - }, - aspiration_flow_rate=80.0, - aspiration_mix_flow_rate=80.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=0.0, - dispense_mix_flow_rate=80.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.5, -) - - -# - without pre-rinsing -# - submerge depth Asp. 1mm -# - for Disp. in empty PCR-Plate/on empty Plate from 1µl up -# - fix height from bottom between 0.5-0.7mm -# - also with higher DNA concentration -star_mapping[(10, False, False, False, Liquid.DNA_TRIS_EDTA, False, False)] = ( - LowNeedleDNADispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 5.7, - 0.5: 1.0, - 50.0: 53.0, - 0.0: 0.0, - 20.0: 22.1, - 1.0: 1.5, - 10.0: 10.8, - 2.0: 2.7, - }, - aspiration_flow_rate=80.0, - aspiration_mix_flow_rate=80.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=1.0, - dispense_mix_flow_rate=80.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 60 -star_mapping[(10, False, False, False, Liquid.WATER, False, False)] = ( - LowNeedle_SysFlWater_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 35.0: 35.6, - 60.0: 62.7, - 50.0: 51.3, - 40.0: 40.9, - 30.0: 30.0, - 0.0: 0.0, - 31.0: 31.4, - 32.0: 32.7, - }, - aspiration_flow_rate=60.0, - aspiration_mix_flow_rate=60.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=100.0, - dispense_mode=1.0, - dispense_mix_flow_rate=60.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, False, False, Liquid.WATER, True, False)] = LowNeedle_Water_DispenseJet = ( - HamiltonLiquidClass( - curve={50.0: 52.7, 30.0: 31.7, 0.0: 0.0, 20.0: 20.5, 10.0: 10.3}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=15.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=0.0, - ) -) - - -star_mapping[(10, False, False, False, Liquid.WATER, True, True)] = ( - LowNeedle_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 70.0: 70.0, - 50.0: 52.7, - 30.0: 31.7, - 0.0: 0.0, - 20.0: 20.5, - 10.0: 10.3, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=15.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, False, False, Liquid.WATER, True, False)] = ( - LowNeedle_Water_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 70.0: 70.0, - 50.0: 52.7, - 30.0: 31.7, - 0.0: 0.0, - 20.0: 20.5, - 10.0: 10.3, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=15.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 60 -star_mapping[(10, False, False, False, Liquid.WATER, False, False)] = ( - LowNeedle_Water_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 5.0, - 0.5: 0.5, - 50.0: 50.0, - 0.0: 0.0, - 20.0: 20.5, - 1.0: 1.0, - 10.0: 10.0, - 2.0: 2.0, - }, - aspiration_flow_rate=60.0, - aspiration_mix_flow_rate=60.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=100.0, - dispense_mode=1.0, - dispense_mix_flow_rate=60.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, False, False, Liquid.WATER, False, True)] = ( - LowNeedle_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.0, - 0.5: 0.5, - 70.0: 70.0, - 50.0: 50.0, - 0.0: 0.0, - 20.0: 20.5, - 1.0: 1.0, - 10.0: 10.0, - 2.0: 2.0, - }, - aspiration_flow_rate=60.0, - aspiration_mix_flow_rate=60.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=100.0, - dispense_mode=5.0, - dispense_mix_flow_rate=60.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, False, False, Liquid.WATER, False, False)] = ( - LowNeedle_Water_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 5.0: 5.0, - 0.5: 0.5, - 70.0: 70.0, - 50.0: 50.0, - 0.0: 0.0, - 20.0: 20.5, - 1.0: 1.0, - 10.0: 10.0, - 2.0: 2.0, - }, - aspiration_flow_rate=60.0, - aspiration_mix_flow_rate=60.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=100.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, True, True, True, Liquid.DMSO, False, True)] = ( - LowVolumeFilter_96COREHead1000ul_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={5.0: 5.3, 0.0: 0.0, 1.0: 0.8, 10.0: 10.0}, - aspiration_flow_rate=25.0, - aspiration_mix_flow_rate=25.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=35.0, - dispense_mode=5.0, - dispense_mix_flow_rate=35.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=25.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, True, True, True, Liquid.WATER, False, True)] = ( - LowVolumeFilter_96COREHead1000ul_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={5.0: 5.8, 0.0: 0.0, 1.0: 1.0, 10.0: 10.0}, - aspiration_flow_rate=25.0, - aspiration_mix_flow_rate=25.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=35.0, - dispense_mode=5.0, - dispense_mix_flow_rate=35.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=25.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, True, True, True, Liquid.DMSO, False, True)] = ( - LowVolumeFilter_96COREHead_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={5.0: 5.1, 0.0: 0.0, 1.0: 0.8, 10.0: 10.0}, - aspiration_flow_rate=25.0, - aspiration_mix_flow_rate=25.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=35.0, - dispense_mode=5.0, - dispense_mix_flow_rate=35.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=25.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, True, True, True, Liquid.DMSO, False, False)] = ( - LowVolumeFilter_96COREHead_DMSO_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={5.0: 5.7, 0.0: 0.0, 1.0: 1.5, 10.0: 10.3}, - aspiration_flow_rate=25.0, - aspiration_mix_flow_rate=25.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=35.0, - dispense_mode=4.0, - dispense_mix_flow_rate=35.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=25.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, True, True, True, Liquid.WATER, False, True)] = ( - LowVolumeFilter_96COREHead_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={5.0: 5.6, 0.0: 0.0, 1.0: 1.2, 10.0: 10.0}, - aspiration_flow_rate=25.0, - aspiration_mix_flow_rate=25.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=35.0, - dispense_mode=5.0, - dispense_mix_flow_rate=35.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=25.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, True, True, True, Liquid.WATER, False, False)] = ( - LowVolumeFilter_96COREHead_Water_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={5.0: 5.8, 0.0: 0.0, 1.0: 1.5, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 75 -star_mapping[(10, False, True, True, Liquid.DMSO, False, False)] = ( - LowVolumeFilter_DMSO_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 5.9, - 0.5: 0.8, - 15.0: 16.4, - 0.0: 0.0, - 1.0: 1.4, - 2.0: 2.6, - 10.0: 11.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=1.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=56.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, True, Liquid.DMSO, False, True)] = ( - LowVolumeFilter_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.9, - 0.5: 0.8, - 0.0: 0.0, - 1.0: 1.4, - 10.0: 10.0, - 2.0: 2.6, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, True, Liquid.DMSO, False, False)] = ( - LowVolumeFilter_DMSO_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={5.0: 5.9, 0.0: 0.0, 1.0: 1.4, 10.0: 10.0, 2.0: 2.6}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 75 -star_mapping[(10, False, True, True, Liquid.ETHANOL, False, False)] = ( - LowVolumeFilter_EtOH_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 8.4, - 0.5: 1.9, - 0.0: 0.0, - 1.0: 2.7, - 2.0: 4.1, - 10.0: 13.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=3.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=1.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, True, Liquid.ETHANOL, False, True)] = ( - LowVolumeFilter_EtOH_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={5.0: 6.6, 0.0: 0.0, 1.0: 1.8, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=3.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, True, Liquid.ETHANOL, False, False)] = ( - LowVolumeFilter_EtOH_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={5.0: 6.4, 0.0: 0.0, 1.0: 1.8, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 10 -star_mapping[(10, False, True, True, Liquid.GLYCERIN, False, False)] = ( - LowVolumeFilter_Glycerin_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 6.5, - 0.5: 1.4, - 15.0: 17.0, - 0.0: 0.0, - 1.0: 2.0, - 2.0: 3.2, - 10.0: 11.8, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=10.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=10.0, - dispense_mode=1.0, - dispense_mix_flow_rate=10.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=5.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=2.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, True, Liquid.GLYCERIN80, False, True)] = ( - LowVolumeFilter_Glycerin_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={5.0: 6.5, 0.0: 0.0, 1.0: 0.6, 10.0: 10.0}, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=10.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=5.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=10.0, - dispense_mode=5.0, - dispense_mix_flow_rate=10.0, - dispense_air_transport_volume=1.0, - dispense_blow_out_volume=5.0, - dispense_swap_speed=2.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=2.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 75 -star_mapping[(10, False, True, True, Liquid.WATER, False, False)] = ( - LowVolumeFilter_Water_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 6.0, - 0.5: 0.8, - 15.0: 16.7, - 0.0: 0.0, - 1.0: 1.4, - 2.0: 2.6, - 10.0: 11.5, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=1.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=56.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, True, Liquid.WATER, False, True)] = ( - LowVolumeFilter_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 6.0, - 0.5: 0.8, - 0.0: 0.0, - 1.0: 1.4, - 10.0: 10.0, - 2.0: 2.6, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, True, Liquid.WATER, False, False)] = ( - LowVolumeFilter_Water_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={5.0: 5.9, 0.0: 0.0, 1.0: 1.2, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -# - Volume 0.5 - 10ul -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing -# - dispense mode surface empty tip -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 0.5 5.77 12.44 -# 1.0 3.65 4.27 -# 2.0 2.18 2.27 -# 5.0 1.08 -1.29 -# 10.0 0.62 0.53 -# -star_mapping[(10, False, True, False, Liquid.PLASMA, False, False)] = ( - LowVolumePlasmaDispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 5.9, - 0.5: 0.8, - 0.0: 0.0, - 1.0: 1.4, - 10.0: 11.5, - 2.0: 2.6, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.5, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=1.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.5, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, False, Liquid.PLASMA, False, True)] = ( - LowVolumePlasmaDispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.6, - 0.5: 0.2, - 0.0: 0.0, - 1.0: 0.9, - 10.0: 11.3, - 2.0: 2.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.5, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.5, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, False, Liquid.PLASMA, False, False)] = ( - LowVolumePlasmaDispenseSurface_Part -) = HamiltonLiquidClass( - curve={5.0: 5.9, 0.0: 0.0, 1.0: 1.3, 10.0: 11.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# - Volume 0.5 - 10ul -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing -# - dispense mode surface empty tip -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 0.5 5.77 12.44 -# 1.0 3.65 4.27 -# 2.0 2.18 2.27 -# 5.0 1.08 -1.29 -# 10.0 0.62 0.53 -# -star_mapping[(10, False, True, False, Liquid.SERUM, False, False)] = ( - LowVolumeSerumDispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 5.6, - 0.5: 0.2, - 0.0: 0.0, - 1.0: 0.9, - 10.0: 11.3, - 2.0: 2.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.5, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=1.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.5, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, False, Liquid.SERUM, False, True)] = ( - LowVolumeSerumDispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.6, - 0.5: 0.2, - 0.0: 0.0, - 1.0: 0.9, - 10.0: 11.3, - 2.0: 2.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.5, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.5, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, False, Liquid.SERUM, False, False)] = ( - LowVolumeSerumDispenseSurface_Part -) = HamiltonLiquidClass( - curve={5.0: 5.9, 0.0: 0.0, 1.0: 1.3, 10.0: 11.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, True, True, False, Liquid.DMSO, False, True)] = ( - LowVolume_96COREHead1000ul_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={5.0: 5.3, 0.0: 0.0, 1.0: 0.8, 10.0: 10.6}, - aspiration_flow_rate=25.0, - aspiration_mix_flow_rate=25.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=35.0, - dispense_mode=5.0, - dispense_mix_flow_rate=35.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=25.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, True, True, False, Liquid.WATER, False, True)] = ( - LowVolume_96COREHead1000ul_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={5.0: 5.8, 0.0: 0.0, 1.0: 1.0, 10.0: 11.2}, - aspiration_flow_rate=25.0, - aspiration_mix_flow_rate=25.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=35.0, - dispense_mode=5.0, - dispense_mix_flow_rate=35.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=25.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, True, True, False, Liquid.DMSO, False, True)] = ( - LowVolume_96COREHead_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={5.0: 5.1, 0.0: 0.0, 1.0: 0.8, 10.0: 10.3}, - aspiration_flow_rate=25.0, - aspiration_mix_flow_rate=25.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=35.0, - dispense_mode=5.0, - dispense_mix_flow_rate=35.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=25.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, True, True, False, Liquid.DMSO, False, False)] = ( - LowVolume_96COREHead_DMSO_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={5.0: 5.9, 0.0: 0.0, 1.0: 1.5, 10.0: 11.0}, - aspiration_flow_rate=25.0, - aspiration_mix_flow_rate=25.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=35.0, - dispense_mode=4.0, - dispense_mix_flow_rate=35.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=25.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, True, True, False, Liquid.WATER, False, True)] = ( - LowVolume_96COREHead_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={5.0: 5.8, 0.0: 0.0, 1.0: 1.3, 10.0: 11.1}, - aspiration_flow_rate=25.0, - aspiration_mix_flow_rate=25.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=35.0, - dispense_mode=5.0, - dispense_mix_flow_rate=35.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=25.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, True, True, False, Liquid.WATER, False, False)] = ( - LowVolume_96COREHead_Water_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={5.0: 5.7, 0.0: 0.0, 1.0: 1.4, 10.0: 10.8}, - aspiration_flow_rate=25.0, - aspiration_mix_flow_rate=25.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=35.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=25.0, - dispense_stop_back_volume=0.0, -) - - -# Liquid class for wash low volume tips with CO-RE 96 Head in CO-RE 96 Head Washer. -star_mapping[(10, True, True, False, Liquid.WATER, False, False)] = ( - LowVolume_Core96Washer_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 6.0, - 0.5: 0.8, - 0.0: 0.0, - 1.0: 1.4, - 10.0: 15.0, - 2.0: 2.6, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=150.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=1.0, - dispense_mix_flow_rate=150.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=5.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=56.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 75 -star_mapping[(10, False, True, False, Liquid.DMSO, False, False)] = ( - LowVolume_DMSO_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 5.9, - 15.0: 16.4, - 0.5: 0.8, - 0.0: 0.0, - 1.0: 1.4, - 10.0: 11.2, - 2.0: 2.6, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=1.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=56.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, False, Liquid.DMSO, False, True)] = ( - LowVolume_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.9, - 0.5: 0.8, - 0.0: 0.0, - 1.0: 1.4, - 10.0: 11.2, - 2.0: 2.6, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, False, Liquid.DMSO, False, False)] = ( - LowVolume_DMSO_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={5.0: 5.9, 0.0: 0.0, 1.0: 1.4, 10.0: 11.2, 2.0: 2.6}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 75 -star_mapping[(10, False, True, False, Liquid.ETHANOL, False, False)] = ( - LowVolume_EtOH_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 8.4, - 0.5: 1.9, - 0.0: 0.0, - 1.0: 2.7, - 10.0: 13.0, - 2.0: 4.1, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=3.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=1.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, False, Liquid.ETHANOL, False, True)] = ( - LowVolume_EtOH_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={5.0: 7.3, 0.0: 0.0, 1.0: 2.4, 10.0: 13.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=3.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, False, Liquid.ETHANOL, False, False)] = ( - LowVolume_EtOH_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={5.0: 7.0, 0.0: 0.0, 1.0: 2.4, 10.0: 13.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 10 -star_mapping[(10, False, True, False, Liquid.GLYCERIN, False, False)] = ( - LowVolume_Glycerin_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 6.5, - 15.0: 17.0, - 0.5: 1.4, - 0.0: 0.0, - 1.0: 2.0, - 10.0: 11.8, - 2.0: 3.2, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=10.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=10.0, - dispense_mode=1.0, - dispense_mix_flow_rate=10.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=5.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=2.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 75 -star_mapping[(10, False, True, False, Liquid.WATER, False, False)] = ( - LowVolume_Water_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 6.0, - 15.0: 16.7, - 0.5: 0.8, - 0.0: 0.0, - 1.0: 1.4, - 10.0: 11.5, - 2.0: 2.6, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=1.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=56.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, True, True, False, Liquid.WATER, False, False)] = ( - LowVolume_Water_DispenseSurface96Head -) = HamiltonLiquidClass( - curve={5.0: 6.0, 0.0: 0.0, 1.0: 1.0, 10.0: 11.5}, - aspiration_flow_rate=25.0, - aspiration_mix_flow_rate=25.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=35.0, - dispense_mode=1.0, - dispense_mix_flow_rate=35.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=25.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, True, True, False, Liquid.WATER, False, True)] = ( - LowVolume_Water_DispenseSurfaceEmpty96Head -) = HamiltonLiquidClass( - curve={5.0: 6.0, 0.0: 0.0, 1.0: 1.0, 10.0: 10.9}, - aspiration_flow_rate=25.0, - aspiration_mix_flow_rate=25.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=35.0, - dispense_mode=5.0, - dispense_mix_flow_rate=35.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=25.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, True, True, False, Liquid.WATER, False, False)] = ( - LowVolume_Water_DispenseSurfacePart96Head -) = HamiltonLiquidClass( - curve={5.0: 6.0, 0.0: 0.0, 1.0: 1.0, 10.0: 10.9}, - aspiration_flow_rate=25.0, - aspiration_mix_flow_rate=25.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=35.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=25.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, False, Liquid.WATER, False, True)] = ( - LowVolume_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 6.0, - 0.5: 0.8, - 0.0: 0.0, - 1.0: 1.4, - 10.0: 11.5, - 2.0: 2.6, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(10, False, True, False, Liquid.WATER, False, False)] = ( - LowVolume_Water_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={5.0: 5.9, 0.0: 0.0, 1.0: 1.2, 10.0: 11.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -# Under laboratory conditions: -# -# Settings for aliquots: -# -# Prealiquot: Postaliquot: Aliquots: -# 20ul 20ul 13 x 20ul -# 50ul 50ul 4 x 50ul -# 50ul 50ul 2 x 100 ul -# -# 12 x 20ul = approximately 19.2 ul -# 4 x 50 ul = approximately 48.1 ul -# 2 x 100 ul = approximately 95.3 ul -star_mapping[(300, True, True, True, Liquid.DMSO, True, False)] = ( - SlimTipFilter_96COREHead1000ul_DMSO_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={300.0: 300.0, 50.0: 50.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0}, - aspiration_flow_rate=200.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=18.0, -) - - -star_mapping[(300, True, True, True, Liquid.DMSO, True, True)] = ( - SlimTipFilter_96COREHead1000ul_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 312.3, - 50.0: 55.3, - 0.0: 0.0, - 100.0: 107.7, - 20.0: 22.4, - 200.0: 210.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=20.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=20.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.DMSO, False, True)] = ( - SlimTipFilter_96COREHead1000ul_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 311.9, - 50.0: 54.1, - 0.0: 0.0, - 100.0: 107.5, - 20.0: 22.5, - 10.0: 11.1, - 200.0: 209.4, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=5.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=1.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# Under laboratory conditions: -# -# Settings for aliquots: -# -# Prealiquot: Postaliquot: Aliquots: -# 20ul 20ul 13 x 20ul -# 50ul 50ul 4 x 50ul -# 50ul 50ul 2 x 100 ul -# -# 12 x 20ul = approximately 19.6 ul -# 4 x 50 ul = approximately 48.9 ul -# 2 x 100 ul = approximately 97.2 ul -# -star_mapping[(300, True, True, True, Liquid.WATER, True, False)] = ( - SlimTipFilter_96COREHead1000ul_Water_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 300.0: 300.0, - 50.0: 50.0, - 30.0: 30.0, - 0.0: 0.0, - 100.0: 100.0, - 20.0: 20.0, - }, - aspiration_flow_rate=200.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(300, True, True, True, Liquid.WATER, True, True)] = ( - SlimTipFilter_96COREHead1000ul_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 317.0, - 50.0: 55.8, - 0.0: 0.0, - 100.0: 109.4, - 20.0: 22.7, - 200.0: 213.7, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=20.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=230.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=20.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.WATER, False, True)] = ( - SlimTipFilter_96COREHead1000ul_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 318.7, - 50.0: 54.9, - 0.0: 0.0, - 100.0: 110.4, - 10.0: 11.7, - 200.0: 210.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=5.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# Under laboratory conditions: -# -# Settings for aliquots: -# -# Prealiquot: Postaliquot: Aliquots: -# 20ul 20ul 13 x 20ul -# 50ul 50ul 4 x 50ul -# 50ul 50ul 2 x 100 ul -# -# 12 x 20ul = approximately 19.1 ul -# 4 x 50 ul = approximately 48.3 ul -# 2 x 100 ul = approximately 95.7 ul -# -star_mapping[(300, True, True, True, Liquid.DMSO, True, False)] = ( - SlimTipFilter_DMSO_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={300.0: 300.0, 50.0: 50.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0}, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=18.0, -) - - -star_mapping[(300, True, True, True, Liquid.DMSO, True, True)] = ( - SlimTipFilter_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 309.5, - 50.0: 54.4, - 0.0: 0.0, - 100.0: 106.4, - 20.0: 22.1, - 200.0: 208.2, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.DMSO, False, True)] = ( - SlimTipFilter_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 309.7, - 5.0: 5.6, - 50.0: 53.8, - 0.0: 0.0, - 100.0: 105.4, - 20.0: 22.2, - 10.0: 11.3, - 200.0: 207.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=5.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=1.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# Under laboratory conditions: -# -# Settings for aliquots: -# -# Prealiquot: Postaliquot: Aliquots: -# 20ul 20ul 12 x 20ul -# 50ul 50ul 4 x 50ul -# 50ul 50ul 2 x 100 ul -# -# 12 x 20ul = approximately 21.3 ul -# 4 x 50 ul = approximately 54.3 ul -# 2 x 100 ul = approximately 105.2 ul -star_mapping[(300, True, True, True, Liquid.ETHANOL, True, False)] = ( - SlimTipFilter_EtOH_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 300.0: 300.0, - 50.0: 50.0, - 0.0: 0.0, - 100.0: 100.0, - 20.0: 20.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(300, True, True, True, Liquid.ETHANOL, True, True)] = ( - SlimTipFilter_EtOH_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 320.4, - 50.0: 57.2, - 0.0: 0.0, - 100.0: 110.5, - 20.0: 24.5, - 200.0: 215.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.ETHANOL, False, True)] = ( - SlimTipFilter_EtOH_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 313.9, - 50.0: 55.4, - 0.0: 0.0, - 100.0: 107.7, - 20.0: 23.2, - 10.0: 12.4, - 200.0: 210.6, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=2.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=5.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=2.0, - dispense_swap_speed=50.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.GLYCERIN80, False, True)] = ( - SlimTipFilter_Glycerin_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 312.0, - 50.0: 55.0, - 0.0: 0.0, - 100.0: 107.8, - 20.0: 22.9, - 10.0: 11.8, - 200.0: 210.0, - }, - aspiration_flow_rate=30.0, - aspiration_mix_flow_rate=30.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=30.0, - dispense_mode=5.0, - dispense_mix_flow_rate=30.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=2.0, - dispense_stop_back_volume=0.0, -) - - -# Under laboratory conditions: -# -# Settings for aliquots: -# -# Prealiquot: Postaliquot: Aliquots: -# 20ul 20ul 13 x 20ul -# 50ul 50ul 4 x 50ul -# 50ul 50ul 2 x 100 ul -# -# 12 x 20ul = approximately 19.6 ul -# 4 x 50 ul = approximately 49.2 ul -# 2 x 100 ul = approximately 97.5 ul -star_mapping[(300, True, True, True, Liquid.WATER, True, False)] = ( - SlimTipFilter_Water_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 300.0: 300.0, - 50.0: 50.0, - 30.0: 30.0, - 0.0: 0.0, - 100.0: 100.0, - 20.0: 20.0, - }, - aspiration_flow_rate=200.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=3.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=3.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(300, True, True, True, Liquid.WATER, True, True)] = ( - SlimTipFilter_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 317.2, - 50.0: 55.6, - 0.0: 0.0, - 100.0: 108.6, - 20.0: 22.6, - 200.0: 212.8, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.WATER, False, True)] = ( - SlimTipFilter_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 314.1, - 5.0: 6.2, - 50.0: 54.7, - 0.0: 0.0, - 100.0: 108.0, - 20.0: 22.7, - 10.0: 11.9, - 200.0: 211.3, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=5.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# Under laboratory conditions: -# -# Settings for aliquots: -# -# Prealiquot: Postaliquot: Aliquots: -# 20ul 20ul 13 x 20ul -# 50ul 50ul 4 x 50ul -# 50ul 50ul 2 x 100 ul -# -# 12 x 20ul = approximately 19.2 ul -# 4 x 50 ul = approximately 48.1 ul -# 2 x 100 ul = approximately 95.3 ul -star_mapping[(300, True, True, False, Liquid.DMSO, True, False)] = ( - SlimTip_96COREHead1000ul_DMSO_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={300.0: 300.0, 50.0: 50.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0}, - aspiration_flow_rate=200.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=18.0, -) - - -star_mapping[(300, True, True, False, Liquid.DMSO, True, True)] = ( - SlimTip_96COREHead1000ul_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 313.8, - 50.0: 55.8, - 0.0: 0.0, - 100.0: 109.2, - 20.0: 23.1, - 200.0: 212.7, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.DMSO, False, True)] = ( - SlimTip_96COREHead1000ul_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 312.9, - 50.0: 54.1, - 0.0: 0.0, - 20.0: 22.5, - 100.0: 108.8, - 200.0: 210.9, - 10.0: 11.1, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=5.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=1.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# Under laboratory conditions: -# -# Settings for aliquots: -# -# Prealiquot: Postaliquot: Aliquots: -# 20ul 20ul 10 x 20ul -# 50ul 50ul 4 x 50ul -# 50ul 50ul 2 x 100 ul -# -# 12 x 20ul = approximately 21.8 ul -# 4 x 50 ul = approximately 53.6 ul -# 2 x 100 ul = approximately 105.2 ul -star_mapping[(300, True, True, False, Liquid.ETHANOL, True, False)] = ( - SlimTip_96COREHead1000ul_EtOH_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 300.0: 300.0, - 50.0: 50.0, - 0.0: 0.0, - 100.0: 100.0, - 20.0: 20.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=80.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(300, True, True, False, Liquid.ETHANOL, True, True)] = ( - SlimTip_96COREHead1000ul_EtOH_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 326.2, - 50.0: 58.8, - 0.0: 0.0, - 100.0: 112.7, - 20.0: 25.0, - 200.0: 218.2, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.ETHANOL, False, True)] = ( - SlimTip_96COREHead1000ul_EtOH_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 320.3, - 50.0: 56.7, - 0.0: 0.0, - 100.0: 109.5, - 10.0: 12.4, - 200.0: 213.9, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=2.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=5.0, - dispense_mix_flow_rate=150.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=2.0, - dispense_swap_speed=50.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.GLYCERIN80, False, True)] = ( - SlimTip_96COREHead1000ul_Glycerin80_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 319.3, - 50.0: 58.2, - 0.0: 0.0, - 100.0: 112.1, - 20.0: 23.9, - 10.0: 12.1, - 200.0: 216.9, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=50.0, - dispense_mode=5.0, - dispense_mix_flow_rate=50.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=2.0, - dispense_stop_back_volume=0.0, -) - - -# Under laboratory conditions: -# -# Settings for aliquots: -# -# Prealiquot: Postaliquot: Aliquots: -# 20ul 20ul 13 x 20ul -# 50ul 50ul 4 x 50ul -# 50ul 50ul 2 x 100 ul -# -# 12 x 20ul = approximately 19.6 ul -# 4 x 50 ul = approximately 48.9 ul -# 2 x 100 ul = approximately 97.2 ul -# -star_mapping[(300, True, True, False, Liquid.WATER, True, False)] = ( - SlimTip_96COREHead1000ul_Water_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 300.0: 300.0, - 50.0: 50.0, - 30.0: 30.0, - 0.0: 0.0, - 100.0: 100.0, - 20.0: 20.0, - }, - aspiration_flow_rate=200.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, True, True)] = ( - SlimTip_96COREHead1000ul_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 315.0, - 50.0: 55.5, - 0.0: 0.0, - 100.0: 107.2, - 20.0: 22.8, - 200.0: 211.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, False, True)] = ( - SlimTip_96COREHead1000ul_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 322.7, - 50.0: 56.4, - 0.0: 0.0, - 100.0: 110.4, - 10.0: 11.9, - 200.0: 215.5, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=5.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# Under laboratory conditions: -# -# Settings for aliquots: -# -# Prealiquot: Postaliquot: Aliquots: -# 20ul 20ul 13 x 20ul -# 50ul 50ul 4 x 50ul -# 50ul 50ul 2 x 100 ul -# -# 12 x 20ul = approximately 19.1 ul -# 4 x 50 ul = approximately 48.3 ul -# 2 x 100 ul = approximately 95.7 ul -# -star_mapping[(300, True, True, False, Liquid.DMSO, True, False)] = ( - SlimTip_DMSO_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={300.0: 300.0, 50.0: 50.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0}, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=18.0, -) - - -star_mapping[(300, True, True, False, Liquid.DMSO, True, True)] = SlimTip_DMSO_DispenseJet_Empty = ( - HamiltonLiquidClass( - curve={ - 300.0: 309.5, - 50.0: 54.7, - 0.0: 0.0, - 100.0: 107.2, - 20.0: 22.5, - 200.0: 209.7, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, - ) -) - - -star_mapping[(300, True, True, False, Liquid.DMSO, False, True)] = ( - SlimTip_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 310.2, - 5.0: 5.6, - 50.0: 54.1, - 0.0: 0.0, - 100.0: 106.2, - 20.0: 22.5, - 10.0: 11.3, - 200.0: 208.7, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=5.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=1.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# Under laboratory conditions: -# -# Settings for aliquots: -# -# Prealiquot: Postaliquot: Aliquots: -# 20ul 20ul 12 x 20ul -# 50ul 50ul 4 x 50ul -# 50ul 50ul 2 x 100 ul -# -# 12 x 20ul = approximately 21.3 ul -# 4 x 50 ul = approximately 54.3 ul -# 2 x 100 ul = approximately 105.2 ul -star_mapping[(300, True, True, False, Liquid.ETHANOL, True, False)] = ( - SlimTip_EtOH_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 300.0: 300.0, - 50.0: 50.0, - 0.0: 0.0, - 100.0: 100.0, - 20.0: 20.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(300, True, True, False, Liquid.ETHANOL, True, True)] = ( - SlimTip_EtOH_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 323.4, - 50.0: 57.2, - 0.0: 0.0, - 100.0: 110.5, - 20.0: 24.7, - 200.0: 211.9, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.ETHANOL, False, True)] = ( - SlimTip_EtOH_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 312.9, - 5.0: 6.2, - 50.0: 55.4, - 0.0: 0.0, - 100.0: 107.7, - 20.0: 23.2, - 10.0: 11.9, - 200.0: 210.6, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=2.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=5.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=2.0, - dispense_swap_speed=50.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.GLYCERIN80, False, True)] = ( - SlimTip_Glycerin_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 313.3, - 5.0: 6.0, - 50.0: 55.7, - 0.0: 0.0, - 100.0: 107.8, - 20.0: 22.9, - 10.0: 11.5, - 200.0: 210.0, - }, - aspiration_flow_rate=30.0, - aspiration_mix_flow_rate=30.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=30.0, - dispense_mode=5.0, - dispense_mix_flow_rate=30.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=2.0, - dispense_stop_back_volume=0.0, -) - - -# Under laboratory conditions: -# -# Settings for aliquots: -# -# Prealiquot: Postaliquot: Aliquots: -# 20ul 20ul 13 x 20ul -# 50ul 50ul 4 x 50ul -# 50ul 50ul 2 x 100 ul -# -# 12 x 20ul = approximately 19.6 ul -# 4 x 50 ul = approximately 50.0 ul -# 2 x 100 ul = approximately 98.4 ul -star_mapping[(300, True, True, False, Liquid.SERUM, True, False)] = ( - SlimTip_Serum_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={300.0: 300.0, 50.0: 50.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0}, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=3.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(300, True, True, False, Liquid.SERUM, True, True)] = ( - SlimTip_Serum_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 321.5, - 50.0: 56.0, - 0.0: 0.0, - 100.0: 109.7, - 20.0: 22.8, - 200.0: 215.7, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.SERUM, False, True)] = ( - SlimTip_Serum_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 320.2, - 5.0: 5.5, - 50.0: 55.4, - 0.0: 0.0, - 20.0: 22.6, - 100.0: 109.7, - 200.0: 214.9, - 10.0: 11.3, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=1.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=5.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=1.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# Under laboratory conditions: -# -# Settings for aliquots: -# -# Prealiquot: Postaliquot: Aliquots: -# 20ul 20ul 13 x 20ul -# 50ul 50ul 4 x 50ul -# 50ul 50ul 2 x 100 ul -# -# 12 x 20ul = approximately 19.6 ul -# 4 x 50 ul = approximately 49.2 ul -# 2 x 100 ul = approximately 97.5 ul -star_mapping[(300, True, True, False, Liquid.WATER, True, False)] = ( - SlimTip_Water_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 300.0: 300.0, - 50.0: 50.0, - 30.0: 30.0, - 0.0: 0.0, - 100.0: 100.0, - 20.0: 20.0, - }, - aspiration_flow_rate=200.0, - aspiration_mix_flow_rate=200.0, - aspiration_air_transport_volume=3.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=3.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, True, True)] = ( - SlimTip_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 317.2, - 50.0: 55.6, - 0.0: 0.0, - 20.0: 22.6, - 100.0: 108.6, - 200.0: 212.8, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, False, True)] = ( - SlimTip_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 317.1, - 5.0: 6.2, - 50.0: 55.1, - 0.0: 0.0, - 100.0: 108.0, - 20.0: 22.9, - 10.0: 11.9, - 200.0: 213.0, - }, - aspiration_flow_rate=250.0, - aspiration_mix_flow_rate=250.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=5.0, - dispense_mix_flow_rate=200.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 80 -# V1.2: Stop back volume = 0 (previous value: 15) -star_mapping[(300, False, False, False, Liquid.WATER, True, False)] = ( - StandardNeedle_Water_DispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 311.2, - 50.0: 51.3, - 0.0: 0.0, - 100.0: 103.4, - 20.0: 19.5, - }, - aspiration_flow_rate=80.0, - aspiration_mix_flow_rate=80.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=0.0, - dispense_mix_flow_rate=80.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, False, False, Liquid.WATER, True, True)] = ( - StandardNeedle_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 311.2, - 50.0: 51.3, - 0.0: 0.0, - 100.0: 103.4, - 20.0: 19.5, - }, - aspiration_flow_rate=80.0, - aspiration_mix_flow_rate=80.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, False, False, Liquid.WATER, True, False)] = ( - StandardNeedle_Water_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 311.2, - 50.0: 51.3, - 0.0: 0.0, - 100.0: 103.4, - 20.0: 19.5, - }, - aspiration_flow_rate=80.0, - aspiration_mix_flow_rate=80.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, False, False, Liquid.WATER, False, False)] = ( - StandardNeedle_Water_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 308.4, - 5.0: 6.5, - 50.0: 52.3, - 0.0: 0.0, - 100.0: 102.9, - 20.0: 22.3, - 1.0: 1.1, - 200.0: 205.8, - 10.0: 12.0, - 2.0: 2.1, - }, - aspiration_flow_rate=80.0, - aspiration_mix_flow_rate=80.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=1.0, - dispense_mix_flow_rate=80.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, False, False, Liquid.WATER, False, True)] = ( - StandardNeedle_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 308.4, - 5.0: 6.5, - 50.0: 52.3, - 0.0: 0.0, - 100.0: 102.9, - 20.0: 22.3, - 1.0: 1.1, - 200.0: 205.8, - 10.0: 12.0, - 2.0: 2.1, - }, - aspiration_flow_rate=80.0, - aspiration_mix_flow_rate=80.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=5.0, - dispense_mix_flow_rate=80.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, False, False, Liquid.WATER, False, False)] = ( - StandardNeedle_Water_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 308.4, - 5.0: 6.5, - 50.0: 52.3, - 0.0: 0.0, - 100.0: 102.9, - 20.0: 22.3, - 1.0: 1.1, - 200.0: 205.8, - 10.0: 12.0, - 2.0: 2.1, - }, - aspiration_flow_rate=80.0, - aspiration_mix_flow_rate=80.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# - set Air transport volume to 25ul -# - set Correction 200.0, from 220.0 back to 217.0 (V 1.0) -# -# - submerge depth: Asp. 1mm -# - without pre-rinsing -# - dispense mode jet empty tip -# -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 0.50 2.26 -# 50 0.30 0.65 -# 100 0.22 1.15 -# 200 0.16 0.55 -# 300 0.17 0.35 -# -star_mapping[(300, False, True, False, Liquid.ACETONITRILE, True, False)] = ( - StandardVolumeAcetonitrilDispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 326.2, - 50.0: 57.3, - 0.0: 0.0, - 100.0: 111.5, - 20.0: 24.6, - 200.0: 217.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=25.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.ACETONITRILE, True, True)] = ( - StandardVolumeAcetonitrilDispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 326.2, - 50.0: 57.3, - 0.0: 0.0, - 100.0: 111.5, - 20.0: 24.6, - 200.0: 217.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=25.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.ACETONITRILE, True, False)] = ( - StandardVolumeAcetonitrilDispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 321.2, 50.0: 57.3, 0.0: 0.0, 100.0: 110.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=10.0, -) - - -# - submerge depth: Asp. 2mm -# Disp. 2mm -# - without pre-rinsing -# - dispense mode surface empty tip -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 1 11.17 - 6.64 -# 2 4.50 1.95 -# 5 0.38 0.50 -# 10 0.94 0.73 -# 20 0.63 0.73 -# 50 0.39 1.28 -# 100 0.28 0.94 -# 200 0.65 0.65 -# 300 0.21 0.88 -# -star_mapping[(300, False, True, False, Liquid.ACETONITRILE, False, False)] = ( - StandardVolumeAcetonitrilDispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 328.0, - 5.0: 6.8, - 50.0: 58.5, - 0.0: 0.0, - 100.0: 112.7, - 20.0: 24.8, - 1.0: 1.3, - 200.0: 220.0, - 10.0: 13.0, - 2.0: 3.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=1.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=50.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.ACETONITRILE, False, True)] = ( - StandardVolumeAcetonitrilDispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 328.0, - 5.0: 6.8, - 50.0: 58.5, - 0.0: 0.0, - 100.0: 112.7, - 20.0: 24.8, - 1.0: 1.3, - 200.0: 220.0, - 10.0: 13.0, - 2.0: 3.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=1.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=50.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.ACETONITRILE, False, False)] = ( - StandardVolumeAcetonitrilDispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 328.0, - 5.0: 7.3, - 0.0: 0.0, - 100.0: 112.7, - 10.0: 13.5, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=1.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=20.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=50.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# - ohne vorbenetzen, gleicher Tip -# - Aspiration submerge depth 1.0mm -# - Prealiquot equal to Aliquotvolume, jet mode part volume -# - Aliquot, jet mode part volume -# - Postaliquot equal to Aliquotvolume, jet mode empty tip -# -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 (12 Aliquots) 2.53 -2.97 -# 50 ( 4 Aliquots) 0.84 -2.57 -# -star_mapping[(300, False, True, False, Liquid.DMSO, True, False)] = StandardVolumeDMSOAliquotJet = ( - HamiltonLiquidClass( - curve={350.0: 350.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=0.3, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=10.0, - ) -) - - -# - Volume 5 - 300ul -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - pre-rinsing 3x with Aspiratevolume, ( >100ul perhaps 2x or set mix speed to 100ul/s) -# - dispense mode surface empty tip -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 3.51 3.16 -# 50 1.19 1.09 -# 100 0.76 0.42 -# 200 0.53 0.08 -# 300 0.54 0.22 -# -star_mapping[(300, False, True, False, Liquid.ETHANOL, False, False)] = ( - StandardVolumeEtOHDispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 309.2, - 50.0: 54.8, - 0.0: 0.0, - 100.0: 106.5, - 20.0: 23.7, - 200.0: 208.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=3.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=1.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=0.4, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.ETHANOL, False, True)] = ( - StandardVolumeEtOHDispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 309.2, - 50.0: 54.8, - 0.0: 0.0, - 100.0: 106.5, - 20.0: 23.7, - 200.0: 208.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=3.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.ETHANOL, False, False)] = ( - StandardVolumeEtOHDispenseSurface_Part -) = HamiltonLiquidClass( - curve={300.0: 315.2, 0.0: 0.0, 100.0: 108.5, 20.0: 23.7}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=3.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.DMSO, True, True)] = ( - StandardVolumeFilter_96COREHead1000ul_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 302.5, - 0.0: 0.0, - 100.0: 101.0, - 20.0: 20.4, - 200.0: 201.5, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.DMSO, False, True)] = ( - StandardVolumeFilter_96COREHead1000ul_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 306.0, - 0.0: 0.0, - 100.0: 104.3, - 200.0: 205.0, - 10.0: 12.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.WATER, True, True)] = ( - StandardVolumeFilter_96COREHead1000ul_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 0.0: 0.0, - 100.0: 107.2, - 20.0: 23.2, - 200.0: 211.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.WATER, False, True)] = ( - StandardVolumeFilter_96COREHead1000ul_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 0.0: 0.0, - 100.0: 107.2, - 200.0: 210.0, - 10.0: 11.9, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.DMSO, True, True)] = ( - StandardVolumeFilter_96COREHead_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 303.5, - 0.0: 0.0, - 100.0: 101.8, - 10.0: 10.2, - 200.0: 200.5, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.DMSO, True, False)] = ( - StandardVolumeFilter_96COREHead_DMSO_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 305.0, - 0.0: 0.0, - 100.0: 103.6, - 10.0: 11.5, - 200.0: 206.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(300, True, True, True, Liquid.DMSO, False, True)] = ( - StandardVolumeFilter_96COREHead_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 303.0, - 0.0: 0.0, - 100.0: 101.3, - 10.0: 10.6, - 200.0: 202.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.DMSO, False, False)] = ( - StandardVolumeFilter_96COREHead_DMSO_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 303.0, - 0.0: 0.0, - 100.0: 101.3, - 10.0: 10.1, - 200.0: 202.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.WATER, True, True)] = ( - StandardVolumeFilter_96COREHead_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 309.0, - 0.0: 0.0, - 20.0: 22.3, - 100.0: 104.2, - 10.0: 11.9, - 200.0: 207.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.WATER, True, False)] = ( - StandardVolumeFilter_96COREHead_Water_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 309.0, - 0.0: 0.0, - 20.0: 22.3, - 100.0: 104.2, - 10.0: 11.9, - 200.0: 207.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(300, True, True, True, Liquid.WATER, False, True)] = ( - StandardVolumeFilter_96COREHead_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 306.3, - 0.0: 0.0, - 100.0: 104.5, - 10.0: 11.9, - 200.0: 205.7, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, True, Liquid.WATER, False, False)] = ( - StandardVolumeFilter_96COREHead_Water_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 304.0, - 0.0: 0.0, - 100.0: 105.3, - 10.0: 11.9, - 200.0: 205.7, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# - ohne vorbenetzen, gleicher Tip -# - Aspiration submerge depth 1.0mm -# - Prealiquot equal to Aliquotvolume, jet mode part volume -# - Aliquot, jet mode part volume -# - Postaliquot equal to Aliquotvolume, jet mode empty tip -# -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 (12 Aliquots) 2.53 -2.97 -# 50 ( 4 Aliquots) 0.84 -2.57 -# -star_mapping[(300, False, True, True, Liquid.DMSO, True, False)] = ( - StandardVolumeFilter_DMSO_AliquotDispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 300.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, True, Liquid.DMSO, True, False)] = ( - StandardVolumeFilter_DMSO_DispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 304.6, - 50.0: 51.1, - 0.0: 0.0, - 20.0: 20.7, - 100.0: 101.8, - 200.0: 203.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, True, Liquid.DMSO, True, True)] = ( - StandardVolumeFilter_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 304.6, - 50.0: 51.1, - 0.0: 0.0, - 100.0: 101.8, - 20.0: 20.7, - 200.0: 203.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, True, Liquid.DMSO, True, False)] = ( - StandardVolumeFilter_DMSO_DispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 315.6, 0.0: 0.0, 100.0: 112.8, 20.0: 29.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(300, False, True, True, Liquid.DMSO, False, False)] = ( - StandardVolumeFilter_DMSO_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 308.8, - 5.0: 6.6, - 50.0: 52.9, - 0.0: 0.0, - 1.0: 1.8, - 20.0: 22.1, - 100.0: 103.8, - 2.0: 3.0, - 10.0: 11.9, - 200.0: 205.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=1.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, True, Liquid.DMSO, False, True)] = ( - StandardVolumeFilter_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 308.8, - 5.0: 6.6, - 50.0: 52.9, - 0.0: 0.0, - 1.0: 1.8, - 20.0: 22.1, - 100.0: 103.8, - 2.0: 3.0, - 10.0: 11.9, - 200.0: 205.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, True, Liquid.DMSO, False, False)] = ( - StandardVolumeFilter_DMSO_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 306.8, - 5.0: 6.4, - 50.0: 52.9, - 0.0: 0.0, - 100.0: 103.8, - 20.0: 22.1, - 10.0: 11.9, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 100, Stop back volume=0 -star_mapping[(300, False, True, True, Liquid.ETHANOL, True, False)] = ( - StandardVolumeFilter_EtOH_DispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 310.2, - 50.0: 55.8, - 0.0: 0.0, - 20.0: 24.6, - 100.0: 107.5, - 200.0: 209.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, True, Liquid.ETHANOL, True, True)] = ( - StandardVolumeFilter_EtOH_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 310.2, - 50.0: 55.8, - 0.0: 0.0, - 100.0: 107.5, - 20.0: 24.6, - 200.0: 209.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, True, Liquid.ETHANOL, True, False)] = ( - StandardVolumeFilter_EtOH_DispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 317.2, 0.0: 0.0, 100.0: 110.5, 20.0: 25.6}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=5.0, -) - - -# V1.1: Set mix flow rate to 20, dispense settling time=0, Stop back volume=0 -star_mapping[(300, False, True, True, Liquid.GLYCERIN, True, False)] = ( - StandardVolumeFilter_Glycerin_DispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 309.0, - 50.0: 53.6, - 0.0: 0.0, - 20.0: 22.3, - 100.0: 104.9, - 200.0: 207.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=20.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=20.0, - dispense_mode=0.0, - dispense_mix_flow_rate=20.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 20, dispense settling time=0, Stop back volume=0 -star_mapping[(300, False, True, True, Liquid.GLYCERIN80, True, True)] = ( - StandardVolumeFilter_Glycerin_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 309.0, - 50.0: 53.6, - 0.0: 0.0, - 100.0: 104.9, - 20.0: 22.3, - 200.0: 207.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=20.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=20.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 10 -star_mapping[(300, False, True, True, Liquid.GLYCERIN, False, False)] = ( - StandardVolumeFilter_Glycerin_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 307.9, - 5.0: 6.5, - 50.0: 53.6, - 0.0: 0.0, - 20.0: 22.5, - 100.0: 105.7, - 2.0: 3.2, - 10.0: 12.0, - 200.0: 207.0, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=10.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=10.0, - dispense_mode=1.0, - dispense_mix_flow_rate=10.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=2.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, True, Liquid.GLYCERIN80, False, True)] = ( - StandardVolumeFilter_Glycerin_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 307.9, - 5.0: 6.5, - 50.0: 53.6, - 0.0: 0.0, - 100.0: 105.7, - 20.0: 22.5, - 200.0: 207.0, - 10.0: 12.0, - 2.0: 3.2, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=10.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=10.0, - dispense_mode=5.0, - dispense_mix_flow_rate=10.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=2.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, True, Liquid.GLYCERIN80, False, False)] = ( - StandardVolumeFilter_Glycerin_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 307.9, - 5.0: 6.1, - 0.0: 0.0, - 100.0: 104.7, - 200.0: 207.0, - 10.0: 11.5, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=10.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=10.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=2.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, True, Liquid.SERUM, True, False)] = ( - StandardVolumeFilter_Serum_AliquotDispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 300.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, True, Liquid.SERUM, True, False)] = ( - StandardVolumeFilter_Serum_AliquotJet -) = HamiltonLiquidClass( - curve={300.0: 300.0, 0.0: 0.0, 30.0: 30.0, 20.0: 20.0, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, True, Liquid.SERUM, True, False)] = ( - StandardVolumeFilter_Serum_DispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 315.2, - 50.0: 55.6, - 0.0: 0.0, - 20.0: 23.2, - 100.0: 108.1, - 200.0: 212.1, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, True, Liquid.SERUM, True, True)] = ( - StandardVolumeFilter_Serum_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 315.2, - 50.0: 55.6, - 0.0: 0.0, - 100.0: 108.1, - 20.0: 23.2, - 200.0: 212.1, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, True, Liquid.SERUM, True, False)] = ( - StandardVolumeFilter_Serum_DispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 315.2, 0.0: 0.0, 100.0: 111.5, 20.0: 29.2}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=5.0, -) - - -star_mapping[(300, False, True, True, Liquid.SERUM, False, False)] = ( - StandardVolumeFilter_Serum_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 313.4, - 5.0: 6.3, - 50.0: 54.9, - 0.0: 0.0, - 20.0: 23.0, - 100.0: 107.1, - 2.0: 2.6, - 10.0: 12.0, - 200.0: 210.5, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=1.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, True, Liquid.SERUM, False, False)] = ( - StandardVolumeFilter_Serum_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={300.0: 313.4, 0.0: 0.0, 100.0: 109.1, 10.0: 12.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, True, Liquid.WATER, True, False)] = ( - StandardVolumeFilter_Water_AliquotDispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 300.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, True, Liquid.WATER, True, False)] = ( - StandardVolumeFilter_Water_AliquotJet -) = HamiltonLiquidClass( - curve={300.0: 300.0, 0.0: 0.0, 30.0: 30.0, 20.0: 20.0, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=0.3, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, True, Liquid.WATER, True, False)] = ( - StandardVolumeFilter_Water_DispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 50.0: 55.1, - 0.0: 0.0, - 20.0: 23.2, - 100.0: 107.2, - 200.0: 211.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, True, Liquid.WATER, True, True)] = ( - StandardVolumeFilter_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 50.0: 55.1, - 0.0: 0.0, - 100.0: 107.2, - 20.0: 23.2, - 200.0: 211.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, True, Liquid.WATER, True, False)] = ( - StandardVolumeFilter_Water_DispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 313.5, 0.0: 0.0, 100.0: 110.2, 20.0: 27.2}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, True, Liquid.WATER, False, False)] = ( - StandardVolumeFilter_Water_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 5.0: 6.3, - 0.5: 0.9, - 50.0: 55.1, - 0.0: 0.0, - 1.0: 1.6, - 20.0: 23.2, - 100.0: 107.2, - 2.0: 2.8, - 10.0: 11.9, - 200.0: 211.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, True, Liquid.WATER, False, True)] = ( - StandardVolumeFilter_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 5.0: 6.3, - 0.5: 0.9, - 50.0: 55.1, - 0.0: 0.0, - 100.0: 107.2, - 20.0: 23.2, - 1.0: 1.6, - 200.0: 211.0, - 10.0: 11.9, - 2.0: 2.8, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, True, Liquid.WATER, False, False)] = ( - StandardVolumeFilter_Water_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 5.0: 6.5, - 50.0: 55.1, - 0.0: 0.0, - 20.0: 23.2, - 100.0: 107.2, - 10.0: 11.9, - 200.0: 211.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth Asp. 1mm -# - 3x pre-rinsing with probevolume -# mix position 0mm (mix flow rate is intentional low) -# - Disp. mode jet empty tip -# - Pipettingvolume jet-dispense from 20µl - 300µl -# - To protect, the distance from Asp. to Disp. should be as short as possible( about 12slot), -# because MeOH could drop out in a long way! -# - some droplets on tip after dispense are also with more air transport volume not avoidable -# - sometimes it helps to use Filtertips -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 0.61 0.57 -# 50 1.21 0.87 -# 100 0.63 0.47 -# 200 0.56 0.07 -# 300 0.54 1.12 -# -star_mapping[(300, False, True, False, Liquid.METHANOL, True, False)] = ( - StandardVolumeMeOHDispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 336.0, - 50.0: 63.0, - 0.0: 0.0, - 100.0: 119.5, - 20.0: 28.3, - 200.0: 230.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=30.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=0.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=0.0, - dispense_mix_flow_rate=30.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth Asp. 2mm -# - 5x pre-rinsing with probevolume 5-50µl, 3x pre-rinsing with probevolume >100µl, -# mix position 1mm (mix flow rate is intentional low) -# - Disp. mode jet empty tip -# - Pipettingvolume surface-dispense from 5µl - 300µl -# - To protect, the distance from Asp. to Disp. should be as short as possible( about 12slot), -# because MeOH could drop out in a long way! -# - some droplets on tip after dispense are also with more air transport volume not avoidable -# - sometimes it helps to use Filtertips -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 5 13.22 5.95 -# 10 2.08 1.00 -# 20 1.52 0.58 -# 50 0.63 0.51 -# 100 0.66 0.26 -# 200 0.51 0.59 -# 300 0.81 0.22 -# -star_mapping[(300, False, True, False, Liquid.METHANOL, False, False)] = ( - StandardVolumeMeOHDispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 310.2, - 5.0: 8.0, - 50.0: 55.8, - 0.0: 0.0, - 100.0: 107.5, - 20.0: 24.6, - 200.0: 209.2, - 10.0: 14.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=30.0, - aspiration_air_transport_volume=10.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=0.1, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=30.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=50.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# - use pLLD -# - submerge depth>: Asp. 0.5 mm -# Disp. 1.0 mm (surface) -# - without pre-rinsing -# - dispense mode jet empty tip -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 0.94 0.94 -# 50 0.74 1.20 -# 100 1.39 1.37 -# 200 0.29 0.17 -# 300 0.16 0.80 -# -star_mapping[(300, False, True, False, Liquid.OCTANOL, True, False)] = ( - StandardVolumeOctanol100DispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 319.3, - 50.0: 56.6, - 0.0: 0.0, - 100.0: 109.9, - 20.0: 23.8, - 200.0: 216.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.5, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -# - use pLLD -# - submerge depth>: Asp. 0.5 mm -# Disp. 1.0 mm (surface) -# - without pre-rinsing -# - dispense mode surface empty tip -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 1 7.45 9.13 -# 2 3.99 1.51 -# 5 1.95 1.64 -# 10 0.51 3.81 -# 20 0.34 - 3.95 -# 50 2.74 1.38 -# 100 0.29 1.04 -# 200 0.02 0.12 -# 300 0.11 0.29 -# -star_mapping[(300, False, True, False, Liquid.OCTANOL, False, False)] = ( - StandardVolumeOctanol100DispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 315.0, - 5.0: 6.6, - 50.0: 55.9, - 0.0: 0.0, - 100.0: 106.8, - 20.0: 22.1, - 1.0: 0.8, - 200.0: 212.0, - 10.0: 12.6, - 2.0: 3.7, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.5, - aspiration_over_aspirate_volume=1.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=1.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 2mm -# Disp. 2mm -# - without pre-rinsing -# - dispense mode surface empty tip -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 1 4.67 0.55 -# 5 3.98 2.77 -# 10 1.99 4.39 -# -# -star_mapping[(300, False, True, False, Liquid.PBS_BUFFER, False, False)] = ( - StandardVolumePBSDispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 5.0: 7.5, - 50.0: 55.1, - 0.0: 0.0, - 100.0: 107.2, - 20.0: 23.2, - 1.0: 2.6, - 200.0: 211.0, - 10.0: 12.8, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=5.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=5.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# - submerge depth: Asp. 0.5 mm -# - without pre-rinsing -# - dispense mode jet empty tip -# -# -# -# -# LC-Plasma is a copy from Serumclass, Plasma has the same Parameters and Correctioncurve! -# -# Typical performance data under laboratory conditions: -# (2 Volumes measured as control) -# -# Volume µl Precision % Trueness % -# 100 0.08 1.09 -# 200 0.09 0.91 -# -star_mapping[(300, False, True, False, Liquid.PLASMA, True, False)] = ( - StandardVolumePlasmaDispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 315.2, - 50.0: 55.6, - 0.0: 0.0, - 100.0: 108.1, - 20.0: 23.2, - 200.0: 212.1, - 10.0: 12.3, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=0.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.PLASMA, True, True)] = ( - StandardVolumePlasmaDispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 315.2, - 50.0: 55.6, - 0.0: 0.0, - 20.0: 23.2, - 100.0: 108.1, - 200.0: 212.1, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.PLASMA, True, False)] = ( - StandardVolumePlasmaDispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 315.2, 0.0: 0.0, 20.0: 29.2, 100.0: 111.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=5.0, -) - - -# - submerge depth: Asp. 0.5mm -# Disp. 0.5mm -# - without pre-rinsing -# - dispense mode surface empty tip -# -# -# LC-Plasma is a copy from Serumclass, Plasma has the same Parameters and Correctioncurve! -# -# Typical performance data under laboratory conditions: -# (3 Volumes measured as control) -# -# Volume µl Precision % Trueness % -# 10 2.09 4.37 -# 20 1.16 3.52 -# 60 0.55 2.06 -# -# -star_mapping[(300, False, True, False, Liquid.PLASMA, False, False)] = ( - StandardVolumePlasmaDispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 313.4, - 5.0: 6.3, - 50.0: 54.9, - 0.0: 0.0, - 100.0: 107.1, - 20.0: 23.0, - 200.0: 210.5, - 10.0: 12.0, - 2.0: 2.6, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=1.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.PLASMA, False, True)] = ( - StandardVolumePlasmaDispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 313.4, - 5.0: 6.3, - 50.0: 54.9, - 0.0: 0.0, - 20.0: 23.0, - 100.0: 107.1, - 2.0: 2.6, - 10.0: 12.0, - 200.0: 210.5, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.PLASMA, False, False)] = ( - StandardVolumePlasmaDispenseSurface_Part -) = HamiltonLiquidClass( - curve={300.0: 313.4, 5.0: 6.8, 0.0: 0.0, 100.0: 109.1, 10.0: 12.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.DMSO, True, False)] = ( - StandardVolume_96COREHead1000ul_DMSO_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 300.0: 300.0, - 150.0: 150.0, - 50.0: 50.0, - 0.0: 0.0, - 20.0: 20.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=20.0, -) - - -star_mapping[(300, True, True, False, Liquid.DMSO, True, True)] = ( - StandardVolume_96COREHead1000ul_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 302.5, - 0.0: 0.0, - 100.0: 101.0, - 20.0: 20.4, - 200.0: 201.5, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.DMSO, False, True)] = ( - StandardVolume_96COREHead1000ul_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 306.0, - 0.0: 0.0, - 100.0: 104.3, - 200.0: 205.0, - 10.0: 12.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, True, False)] = ( - StandardVolume_96COREHead1000ul_Water_DispenseJet_Aliquot -) = HamiltonLiquidClass( - curve={ - 300.0: 300.0, - 150.0: 150.0, - 50.0: 50.0, - 0.0: 0.0, - 20.0: 20.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, True, True)] = ( - StandardVolume_96COREHead1000ul_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 0.0: 0.0, - 100.0: 107.2, - 20.0: 23.2, - 200.0: 207.5, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, False, True)] = ( - StandardVolume_96COREHead1000ul_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 0.0: 0.0, - 100.0: 107.2, - 200.0: 210.0, - 10.0: 11.9, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.DMSO, True, True)] = ( - StandardVolume_96COREHead_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 303.5, - 0.0: 0.0, - 100.0: 101.8, - 10.0: 10.2, - 200.0: 200.5, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.DMSO, True, False)] = ( - StandardVolume_96COREHead_DMSO_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 306.0, - 0.0: 0.0, - 100.0: 105.6, - 10.0: 12.2, - 200.0: 207.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(300, True, True, False, Liquid.DMSO, False, True)] = ( - StandardVolume_96COREHead_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 303.0, - 0.0: 0.0, - 100.0: 101.3, - 10.0: 10.6, - 200.0: 202.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.DMSO, False, False)] = ( - StandardVolume_96COREHead_DMSO_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 303.0, - 0.0: 0.0, - 100.0: 101.3, - 10.0: 10.1, - 200.0: 202.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, True, True)] = ( - StandardVolume_96COREHead_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 309.0, - 0.0: 0.0, - 20.0: 22.3, - 100.0: 104.2, - 10.0: 11.9, - 200.0: 207.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, True, False)] = ( - StandardVolume_96COREHead_Water_DispenseJet_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 309.0, - 0.0: 0.0, - 20.0: 22.3, - 100.0: 104.2, - 10.0: 11.9, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, False, True)] = ( - StandardVolume_96COREHead_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 306.3, - 0.0: 0.0, - 100.0: 104.5, - 10.0: 11.9, - 200.0: 205.7, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, False, False)] = ( - StandardVolume_96COREHead_Water_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 304.0, - 0.0: 0.0, - 100.0: 105.3, - 10.0: 11.9, - 200.0: 205.7, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# Liquid class for wash standard volume tips with CO-RE 96 Head in CO-RE 96 Head Washer. -star_mapping[(300, True, True, False, Liquid.WATER, False, False)] = ( - StandardVolume_Core96Washer_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 330.0, - 5.0: 6.3, - 0.5: 0.9, - 50.0: 55.1, - 0.0: 0.0, - 100.0: 107.2, - 20.0: 23.2, - 1.0: 1.6, - 200.0: 211.0, - 10.0: 11.9, - 2.0: 2.8, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=150.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=100.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=150.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=5.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -# - ohne vorbenetzen, gleicher Tip -# - Aspiration submerge depth 1.0mm -# - Prealiquot equal to Aliquotvolume, jet mode part volume -# - Aliquot, jet mode part volume -# - Postaliquot equal to Aliquotvolume, jet mode empty tip -# -# -# -# -# -# Typical performance data under laboratory conditions: -# -# Volume µl Precision % Trueness % -# 20 (12 Aliquots) 2.53 -2.97 -# 50 ( 4 Aliquots) 0.84 -2.57 -# -star_mapping[(300, False, True, False, Liquid.DMSO, True, False)] = ( - StandardVolume_DMSO_AliquotDispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 300.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=250.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, False, Liquid.DMSO, True, False)] = ( - StandardVolume_DMSO_DispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 304.6, - 350.0: 355.2, - 50.0: 51.1, - 0.0: 0.0, - 100.0: 101.8, - 20.0: 20.7, - 200.0: 203.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.DMSO, True, True)] = ( - StandardVolume_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 304.6, - 50.0: 51.1, - 0.0: 0.0, - 20.0: 20.7, - 100.0: 101.8, - 200.0: 203.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.DMSO, True, False)] = ( - StandardVolume_DMSO_DispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 320.0, 0.0: 0.0, 20.0: 30.5, 100.0: 116.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=10.0, -) - - -star_mapping[(300, False, True, False, Liquid.DMSO, False, False)] = ( - StandardVolume_DMSO_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 308.8, - 5.0: 6.6, - 50.0: 52.9, - 350.0: 360.5, - 0.0: 0.0, - 1.0: 1.8, - 20.0: 22.1, - 100.0: 103.8, - 2.0: 3.0, - 10.0: 11.9, - 200.0: 205.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=1.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.DMSO, False, True)] = ( - StandardVolume_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 308.8, - 5.0: 6.6, - 50.0: 52.9, - 0.0: 0.0, - 1.0: 1.8, - 20.0: 22.1, - 100.0: 103.8, - 2.0: 3.0, - 10.0: 11.9, - 200.0: 205.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.DMSO, False, False)] = ( - StandardVolume_DMSO_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 308.8, - 5.0: 6.4, - 50.0: 52.9, - 0.0: 0.0, - 20.0: 22.1, - 100.0: 103.8, - 10.0: 11.9, - 200.0: 205.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 100, stop back volume = 0 -star_mapping[(300, False, True, False, Liquid.ETHANOL, True, False)] = ( - StandardVolume_EtOH_DispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 310.2, - 350.0: 360.5, - 50.0: 55.8, - 0.0: 0.0, - 100.0: 107.5, - 20.0: 24.6, - 200.0: 209.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.ETHANOL, True, True)] = ( - StandardVolume_EtOH_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 310.2, - 50.0: 55.8, - 0.0: 0.0, - 20.0: 24.6, - 100.0: 107.5, - 200.0: 209.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.ETHANOL, True, False)] = ( - StandardVolume_EtOH_DispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 317.2, 0.0: 0.0, 20.0: 25.6, 100.0: 110.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=5.0, -) - - -# V1.1: Set mix flow rate to 20, dispense settling time=0, stop back volume = 0 -star_mapping[(300, False, True, False, Liquid.GLYCERIN, True, False)] = ( - StandardVolume_Glycerin_DispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 309.0, - 350.0: 360.0, - 50.0: 53.6, - 0.0: 0.0, - 100.0: 104.9, - 20.0: 22.3, - 200.0: 207.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=20.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=20.0, - dispense_mode=0.0, - dispense_mix_flow_rate=20.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 20, dispense settling time=0, stop back volume = 0 -star_mapping[(300, False, True, False, Liquid.GLYCERIN80, True, True)] = ( - StandardVolume_Glycerin_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 309.0, - 50.0: 53.6, - 0.0: 0.0, - 100.0: 104.9, - 20.0: 22.3, - 200.0: 207.2, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=20.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=20.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=20.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 10 -star_mapping[(300, False, True, False, Liquid.GLYCERIN, False, False)] = ( - StandardVolume_Glycerin_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 307.9, - 5.0: 6.5, - 350.0: 358.4, - 50.0: 53.6, - 0.0: 0.0, - 100.0: 105.7, - 20.0: 22.5, - 200.0: 207.0, - 10.0: 12.0, - 2.0: 3.2, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=10.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=10.0, - dispense_mode=1.0, - dispense_mix_flow_rate=10.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=2.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.GLYCERIN80, False, True)] = ( - StandardVolume_Glycerin_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 307.9, - 5.0: 6.5, - 50.0: 53.6, - 0.0: 0.0, - 100.0: 105.7, - 20.0: 22.5, - 200.0: 207.0, - 10.0: 12.0, - 2.0: 3.2, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=10.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=10.0, - dispense_mode=5.0, - dispense_mix_flow_rate=10.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=2.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.GLYCERIN80, False, False)] = ( - StandardVolume_Glycerin_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 307.9, - 5.0: 6.2, - 50.0: 53.6, - 0.0: 0.0, - 100.0: 105.7, - 20.0: 22.5, - 200.0: 207.0, - 10.0: 12.0, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=10.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=2.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=10.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=2.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, False, Liquid.SERUM, True, False)] = ( - StandardVolume_Serum_AliquotDispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 300.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, False, Liquid.SERUM, True, False)] = ( - StandardVolume_Serum_AliquotJet -) = HamiltonLiquidClass( - curve={350.0: 350.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=250.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, False, Liquid.SERUM, True, False)] = ( - StandardVolume_Serum_DispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 315.2, - 50.0: 55.6, - 0.0: 0.0, - 100.0: 108.1, - 20.0: 23.2, - 200.0: 212.1, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.SERUM, True, True)] = ( - StandardVolume_Serum_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 315.2, - 50.0: 55.6, - 0.0: 0.0, - 20.0: 23.2, - 100.0: 108.1, - 200.0: 212.1, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.SERUM, True, False)] = ( - StandardVolume_Serum_DispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 315.2, 0.0: 0.0, 20.0: 29.2, 100.0: 111.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=10.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=5.0, -) - - -star_mapping[(300, False, True, False, Liquid.SERUM, False, False)] = ( - StandardVolume_Serum_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 313.4, - 5.0: 6.3, - 50.0: 54.9, - 0.0: 0.0, - 20.0: 23.0, - 100.0: 107.1, - 2.0: 2.6, - 10.0: 12.0, - 200.0: 210.5, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=1.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.SERUM, False, True)] = ( - StandardVolume_Serum_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 313.4, - 5.0: 6.3, - 50.0: 54.9, - 0.0: 0.0, - 20.0: 23.0, - 100.0: 107.1, - 2.0: 2.6, - 10.0: 12.0, - 200.0: 210.5, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.SERUM, False, False)] = ( - StandardVolume_Serum_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={300.0: 313.4, 5.0: 6.8, 0.0: 0.0, 100.0: 109.1, 10.0: 12.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=15.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=10.0, - dispense_stop_back_volume=0.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, False, Liquid.WATER, True, False)] = ( - StandardVolume_Water_AliquotDispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 300.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, False, Liquid.WATER, True, False)] = ( - StandardVolume_Water_AliquotJet -) = HamiltonLiquidClass( - curve={350.0: 350.0, 30.0: 30.0, 0.0: 0.0, 20.0: 20.0, 10.0: 10.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=200.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=0.3, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, False, Liquid.WATER, True, False)] = ( - StandardVolume_Water_DispenseJet -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 350.0: 364.3, - 50.0: 55.1, - 0.0: 0.0, - 100.0: 107.2, - 20.0: 23.2, - 200.0: 211.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=0.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, True, True)] = ( - StandardVolume_Water_DispenseJetEmpty96Head -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 0.0: 0.0, - 100.0: 107.2, - 200.0: 205.3, - 10.0: 11.9, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, True, False)] = ( - StandardVolume_Water_DispenseJetPart96Head -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 0.0: 0.0, - 100.0: 107.2, - 200.0: 205.3, - 10.0: 11.9, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.WATER, True, True)] = ( - StandardVolume_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 50.0: 55.1, - 0.0: 0.0, - 20.0: 23.2, - 100.0: 107.2, - 200.0: 211.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.WATER, True, False)] = ( - StandardVolume_Water_DispenseJet_Part -) = HamiltonLiquidClass( - curve={300.0: 313.5, 0.0: 0.0, 20.0: 28.2, 100.0: 111.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=2.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=150.0, - dispense_stop_back_volume=10.0, -) - - -# V1.1: Set mix flow rate to 100 -star_mapping[(300, False, True, False, Liquid.WATER, False, False)] = ( - StandardVolume_Water_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 5.0: 6.3, - 0.5: 0.9, - 350.0: 364.3, - 50.0: 55.1, - 0.0: 0.0, - 100.0: 107.2, - 20.0: 23.2, - 1.0: 1.6, - 200.0: 211.0, - 10.0: 11.9, - 2.0: 2.8, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, False, False)] = ( - StandardVolume_Water_DispenseSurface96Head -) = HamiltonLiquidClass( - curve={300.0: 313.5, 0.0: 0.0, 100.0: 107.2, 10.0: 11.9}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=1.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, False, True)] = ( - StandardVolume_Water_DispenseSurfaceEmpty96Head -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 0.0: 0.0, - 100.0: 107.2, - 200.0: 205.7, - 10.0: 11.9, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, True, True, False, Liquid.WATER, False, False)] = ( - StandardVolume_Water_DispenseSurfacePart96Head -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 0.0: 0.0, - 100.0: 107.2, - 200.0: 205.7, - 10.0: 11.9, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.WATER, False, True)] = ( - StandardVolume_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 5.0: 6.3, - 0.5: 0.9, - 50.0: 55.1, - 0.0: 0.0, - 1.0: 1.6, - 20.0: 23.2, - 100.0: 107.2, - 2.0: 2.8, - 10.0: 11.9, - 200.0: 211.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=100.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(300, False, True, False, Liquid.WATER, False, False)] = ( - StandardVolume_Water_DispenseSurface_Part -) = HamiltonLiquidClass( - curve={ - 300.0: 313.5, - 5.0: 6.8, - 50.0: 55.1, - 0.0: 0.0, - 20.0: 23.2, - 100.0: 107.2, - 10.0: 12.3, - 200.0: 211.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=5.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=4.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=1.0, - dispense_stop_flow_rate=5.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, True, Liquid.DMSO, True, True)] = ( - Tip_50ulFilter_96COREHead1000ul_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 52.1, 30.0: 31.7, 0.0: 0.0, 20.0: 21.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=1.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, True, Liquid.DMSO, False, True)] = ( - Tip_50ulFilter_96COREHead1000ul_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.4, - 50.0: 52.1, - 30.0: 31.5, - 0.0: 0.0, - 1.0: 0.7, - 10.0: 10.8, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, True, Liquid.WATER, True, True)] = ( - Tip_50ulFilter_96COREHead1000ul_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 54.2, 30.0: 33.2, 0.0: 0.0, 20.0: 22.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, True, Liquid.WATER, False, True)] = ( - Tip_50ulFilter_96COREHead1000ul_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.6, - 50.0: 53.6, - 30.0: 32.6, - 0.0: 0.0, - 1.0: 0.8, - 10.0: 11.3, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, True, Liquid.DMSO, True, True)] = ( - Tip_50ulFilter_96COREHead_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 51.4, 0.0: 0.0, 30.0: 31.3, 20.0: 21.0}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, True, Liquid.DMSO, False, True)] = ( - Tip_50ulFilter_96COREHead_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.6, - 50.0: 51.1, - 0.0: 0.0, - 30.0: 31.0, - 1.0: 0.8, - 10.0: 10.7, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, True, Liquid.WATER, True, True)] = ( - Tip_50ulFilter_96COREHead_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 54.0, 30.0: 33.0, 0.0: 0.0, 20.0: 22.4}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, True, Liquid.WATER, False, True)] = ( - Tip_50ulFilter_96COREHead_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.6, - 50.0: 53.5, - 30.0: 32.9, - 0.0: 0.0, - 1.0: 0.8, - 10.0: 11.4, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, True, Liquid.DMSO, True, True)] = ( - Tip_50ulFilter_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 52.5, 0.0: 0.0, 30.0: 31.4, 20.0: 21.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, True, Liquid.DMSO, False, True)] = ( - Tip_50ulFilter_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.5, - 50.0: 52.6, - 0.0: 0.0, - 30.0: 32.0, - 1.0: 0.7, - 10.0: 11.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, True, Liquid.ETHANOL, True, True)] = ( - Tip_50ulFilter_EtOH_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 57.5, 0.0: 0.0, 30.0: 35.8, 20.0: 24.4}, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=3.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, True, Liquid.ETHANOL, False, True)] = ( - Tip_50ulFilter_EtOH_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 6.5, - 50.0: 54.1, - 0.0: 0.0, - 30.0: 33.8, - 1.0: 1.9, - 10.0: 12.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=2.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=2.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, True, Liquid.GLYCERIN80, False, True)] = ( - Tip_50ulFilter_Glycerin80_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.5, - 50.0: 57.0, - 0.0: 0.0, - 30.0: 35.9, - 1.0: 0.6, - 10.0: 12.0, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=3.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=50.0, - dispense_mode=5.0, - dispense_mix_flow_rate=10.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=3.0, - dispense_swap_speed=2.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, True, Liquid.SERUM, True, True)] = ( - Tip_50ulFilter_Serum_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 54.6, 30.0: 33.5, 0.0: 0.0, 20.0: 22.6}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, True, Liquid.SERUM, False, True)] = ( - Tip_50ulFilter_Serum_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.7, - 50.0: 54.9, - 30.0: 33.0, - 0.0: 0.0, - 1.0: 0.7, - 10.0: 11.3, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=100.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, True, Liquid.WATER, True, True)] = ( - Tip_50ulFilter_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 54.0, 30.0: 33.6, 0.0: 0.0, 20.0: 22.7}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, True, Liquid.WATER, False, True)] = ( - Tip_50ulFilter_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.7, - 50.0: 54.2, - 30.0: 33.1, - 0.0: 0.0, - 1.0: 0.65, - 10.0: 11.4, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.DMSO, True, True)] = ( - Tip_50ul_96COREHead1000ul_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 52.1, 30.0: 31.7, 0.0: 0.0, 20.0: 21.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=1.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.DMSO, False, True)] = ( - Tip_50ul_96COREHead1000ul_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.4, - 50.0: 52.1, - 30.0: 31.5, - 0.0: 0.0, - 1.0: 0.7, - 10.0: 10.8, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.WATER, True, True)] = ( - Tip_50ul_96COREHead1000ul_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 52.8, 0.0: 0.0, 30.0: 33.2, 20.0: 22.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.WATER, False, True)] = ( - Tip_50ul_96COREHead1000ul_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.8, - 50.0: 53.6, - 30.0: 32.6, - 0.0: 0.0, - 1.0: 0.8, - 10.0: 11.3, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=5.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=5.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=3.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.DMSO, True, True)] = ( - Tip_50ul_96COREHead_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 51.4, 30.0: 31.3, 0.0: 0.0, 20.0: 21.1}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.DMSO, False, True)] = ( - Tip_50ul_96COREHead_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.6, - 50.0: 52.1, - 30.0: 31.6, - 0.0: 0.0, - 1.0: 0.8, - 10.0: 11.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.WATER, True, True)] = ( - Tip_50ul_96COREHead_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 54.1, 0.0: 0.0, 30.0: 33.0, 20.0: 22.4}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, True, True, False, Liquid.WATER, False, True)] = ( - Tip_50ul_96COREHead_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.6, - 50.0: 53.6, - 30.0: 32.9, - 0.0: 0.0, - 1.0: 0.7, - 10.0: 11.4, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -# Liquid class for wash 50ul tips with CO-RE 96 Head in CO-RE 96 Head Washer. -star_mapping[(50, True, True, False, Liquid.WATER, False, False)] = ( - Tip_50ul_Core96Washer_DispenseSurface -) = HamiltonLiquidClass( - curve={ - 5.0: 5.7, - 50.0: 54.2, - 30.0: 33.2, - 0.0: 0.0, - 1.0: 0.5, - 10.0: 11.4, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=0.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=0.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, False, Liquid.DMSO, True, True)] = ( - Tip_50ul_DMSO_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 52.5, 30.0: 32.2, 0.0: 0.0, 20.0: 21.4}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=10.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=1.0, - dispense_blow_out_volume=10.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=200.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, False, Liquid.DMSO, False, True)] = ( - Tip_50ul_DMSO_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.6, - 50.0: 52.6, - 30.0: 32.1, - 0.0: 0.0, - 1.0: 0.7, - 10.0: 11.0, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, False, Liquid.ETHANOL, True, True)] = ( - Tip_50ul_EtOH_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 58.4, 0.0: 0.0, 30.0: 36.0, 20.0: 24.2}, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=50.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=400.0, - dispense_mode=3.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=3.0, - dispense_blow_out_volume=50.0, - dispense_swap_speed=4.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, False, Liquid.ETHANOL, False, True)] = ( - Tip_50ul_EtOH_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 6.7, - 50.0: 54.1, - 0.0: 0.0, - 30.0: 33.7, - 1.0: 2.1, - 10.0: 12.1, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=2.0, - aspiration_blow_out_volume=2.0, - aspiration_swap_speed=50.0, - aspiration_settling_time=0.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=75.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=2.0, - dispense_blow_out_volume=2.0, - dispense_swap_speed=50.0, - dispense_settling_time=0.5, - dispense_stop_flow_rate=50.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, False, Liquid.GLYCERIN80, False, True)] = ( - Tip_50ul_Glycerin80_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.7, - 50.0: 59.4, - 0.0: 0.0, - 30.0: 36.0, - 1.0: 0.3, - 10.0: 11.8, - }, - aspiration_flow_rate=50.0, - aspiration_mix_flow_rate=50.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=2.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=50.0, - dispense_mode=5.0, - dispense_mix_flow_rate=10.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=2.0, - dispense_swap_speed=2.0, - dispense_settling_time=2.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, False, Liquid.SERUM, True, True)] = ( - Tip_50ul_Serum_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 54.6, 30.0: 33.5, 0.0: 0.0, 20.0: 22.6}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=150.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, False, Liquid.SERUM, False, True)] = ( - Tip_50ul_Serum_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.7, - 50.0: 54.9, - 30.0: 33.0, - 0.0: 0.0, - 1.0: 0.7, - 10.0: 11.3, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=100.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, False, Liquid.WATER, True, True)] = ( - Tip_50ul_Water_DispenseJet_Empty -) = HamiltonLiquidClass( - curve={50.0: 54.0, 30.0: 33.5, 0.0: 0.0, 20.0: 22.5}, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=100.0, - aspiration_air_transport_volume=5.0, - aspiration_blow_out_volume=30.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=0.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=180.0, - dispense_mode=3.0, - dispense_mix_flow_rate=1.0, - dispense_air_transport_volume=5.0, - dispense_blow_out_volume=30.0, - dispense_swap_speed=1.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=100.0, - dispense_stop_back_volume=0.0, -) - - -star_mapping[(50, False, True, False, Liquid.WATER, False, True)] = ( - Tip_50ul_Water_DispenseSurface_Empty -) = HamiltonLiquidClass( - curve={ - 5.0: 5.7, - 50.0: 54.2, - 30.0: 33.2, - 0.0: 0.0, - 1.0: 0.7, - 10.0: 11.4, - }, - aspiration_flow_rate=100.0, - aspiration_mix_flow_rate=75.0, - aspiration_air_transport_volume=0.0, - aspiration_blow_out_volume=1.0, - aspiration_swap_speed=2.0, - aspiration_settling_time=1.0, - aspiration_over_aspirate_volume=2.0, - aspiration_clot_retract_height=0.0, - dispense_flow_rate=120.0, - dispense_mode=5.0, - dispense_mix_flow_rate=75.0, - dispense_air_transport_volume=0.0, - dispense_blow_out_volume=1.0, - dispense_swap_speed=2.0, - dispense_settling_time=0.0, - dispense_stop_flow_rate=1.0, - dispense_stop_back_volume=0.0, -) +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.star import * # noqa: F401,F403,E402 diff --git a/pylabrobot/liquid_handling/liquid_classes/tecan.py b/pylabrobot/liquid_handling/liquid_classes/tecan.py index 3cdc0751b2a..754bb2ae4b7 100644 --- a/pylabrobot/liquid_handling/liquid_classes/tecan.py +++ b/pylabrobot/liquid_handling/liquid_classes/tecan.py @@ -1,2794 +1,10 @@ -import re -from typing import Dict, Optional, Tuple +import warnings -from pylabrobot.resources.liquid import Liquid -from pylabrobot.resources.tecan import TipType - - -def from_str(s: str) -> Optional[Liquid]: - """Parses a Tecan liquid class name and creates a Liquid object.""" - - m = re.match(r"(\w+) free dispense$", s) - if m is None: - return None - - lc = m.group(1) - if lc in {"Ethanol", "Serum"}: - lc += " 100%" - return Liquid(lc) - - -class TecanLiquidClass: - """A liquid class like used in EVOware.""" - - def __init__( - self, - lld_mode: int, - lld_conductivity: int, - lld_speed: float, - lld_distance: float, - clot_speed: float, - clot_limit: float, - pmp_sensitivity: int, - pmp_viscosity: float, - pmp_character: int, - density: float, - calibration_factor: float, - calibration_offset: float, - aspirate_speed: float, - aspirate_delay: float, - aspirate_stag_volume: float, - aspirate_stag_speed: float, - aspirate_lag_volume: float, - aspirate_lag_speed: float, - aspirate_tag_volume: float, - aspirate_tag_speed: float, - aspirate_excess: float, - aspirate_conditioning: float, - aspirate_pinch_valve: bool, - aspirate_lld: bool, - aspirate_lld_position: int, - aspirate_lld_offset: float, - aspirate_mix: bool, - aspirate_mix_volume: float, - aspirate_mix_cycles: int, - aspirate_retract_position: int, - aspirate_retract_speed: float, - aspirate_retract_offset: float, - dispense_speed: float, - dispense_breakoff: float, - dispense_delay: float, - dispense_tag: bool, - dispense_pinch_valve: bool, - dispense_lld: bool, - dispense_lld_position: int, - dispense_lld_offset: float, - dispense_touching_direction: int, - dispense_touching_speed: float, - dispense_touching_delay: float, - dispense_mix: bool, - dispense_mix_volume: float, - dispense_mix_cycles: int, - dispense_retract_position: int, - dispense_retract_speed: float, - dispense_retract_offset: float, - ): - self.lld_mode = lld_mode - self.lld_conductivity = lld_conductivity - self.lld_speed = lld_speed - self.lld_distance = lld_distance - self.clot_speed = clot_speed - self.clot_limit = clot_limit - self.pmp_sensitivity = pmp_sensitivity - self.pmp_viscosity = pmp_viscosity - self.pmp_character = pmp_character - self.density = density - - self.calibration_factor = calibration_factor - self.calibration_offset = calibration_offset - - self.aspirate_speed = aspirate_speed - self.aspirate_delay = aspirate_delay - self.aspirate_stag_volume = aspirate_stag_volume - self.aspirate_stag_speed = aspirate_stag_speed - self.aspirate_lag_volume = aspirate_lag_volume - self.aspirate_lag_speed = aspirate_lag_speed - self.aspirate_tag_volume = aspirate_tag_volume - self.aspirate_tag_speed = aspirate_tag_speed - self.aspirate_excess = aspirate_excess - self.aspirate_conditioning = aspirate_conditioning - self.aspirate_pinch_valve = aspirate_pinch_valve - self.aspirate_lld = aspirate_lld - self.aspirate_lld_position = aspirate_lld_position - self.aspirate_lld_offset = aspirate_lld_offset - self.aspirate_mix = aspirate_mix - self.aspirate_mix_volume = aspirate_mix_volume - self.aspirate_mix_cycles = aspirate_mix_cycles - self.aspirate_retract_position = aspirate_retract_position - self.aspirate_retract_speed = aspirate_retract_speed - self.aspirate_retract_offset = aspirate_retract_offset - - self.dispense_speed = dispense_speed - self.dispense_breakoff = dispense_breakoff - self.dispense_delay = dispense_delay - self.dispense_tag = dispense_tag - self.dispense_pinch_valve = dispense_pinch_valve - self.dispense_lld = dispense_lld - self.dispense_lld_position = dispense_lld_position - self.dispense_lld_offset = dispense_lld_offset - self.dispense_touching_direction = dispense_touching_direction - self.dispense_touching_speed = dispense_touching_speed - self.dispense_touching_delay = dispense_touching_delay - self.dispense_mix = dispense_mix - self.dispense_mix_volume = dispense_mix_volume - self.dispense_mix_cycles = dispense_mix_cycles - self.dispense_retract_position = dispense_retract_position - self.dispense_retract_speed = dispense_retract_speed - self.dispense_retract_offset = dispense_retract_offset - - def compute_corrected_volume(self, target_volume: float) -> float: - return self.calibration_factor * target_volume + self.calibration_offset - - -mapping: Dict[Tuple[float, float, Liquid, TipType], TecanLiquidClass] = {} - - -def get_liquid_class( - target_volume: float, - liquid_class: Liquid, - tip_type: TipType, -) -> Optional[TecanLiquidClass]: - for (mnv, mxv, lc, tt), tlc in mapping.items(): - if mnv <= target_volume < mxv and lc == liquid_class and tt == tip_type: - return tlc - return None - - -mapping[(3, 15.01, Liquid.DMSO, TipType.STANDARD)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1.1, - calibration_factor=1.063, - calibration_offset=0.1, - aspirate_speed=20, - aspirate_delay=200, - aspirate_stag_volume=10, - aspirate_stag_speed=5, - aspirate_lag_volume=10, - aspirate_lag_speed=50, - aspirate_tag_volume=5, - aspirate_tag_speed=5, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=150, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(15.01, 200.01, Liquid.DMSO, TipType.STANDARD)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1.1, - calibration_factor=1.1, - calibration_offset=-0.3, - aspirate_speed=100, - aspirate_delay=200, - aspirate_stag_volume=20, - aspirate_stag_speed=5, - aspirate_lag_volume=0, - aspirate_lag_speed=50, - aspirate_tag_volume=10, - aspirate_tag_speed=5, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=150, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(200.01, 1000.01, Liquid.DMSO, TipType.STANDARD)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1.1, - calibration_factor=1.002, - calibration_offset=19.3, - aspirate_speed=150, - aspirate_delay=300, - aspirate_stag_volume=20, - aspirate_stag_speed=5, - aspirate_lag_volume=0, - aspirate_lag_speed=50, - aspirate_tag_volume=10, - aspirate_tag_speed=5, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=150, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(3, 15.01, Liquid.DMSO, TipType.DITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1.1, - calibration_factor=1.026, - calibration_offset=0.1, - aspirate_speed=20, - aspirate_delay=200, - aspirate_stag_volume=20, - aspirate_stag_speed=5, - aspirate_lag_volume=5, - aspirate_lag_speed=50, - aspirate_tag_volume=10, - aspirate_tag_speed=5, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(15.01, 200.01, Liquid.DMSO, TipType.DITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1.1, - calibration_factor=1.007, - calibration_offset=0.8, - aspirate_speed=100, - aspirate_delay=200, - aspirate_stag_volume=20, - aspirate_stag_speed=5, - aspirate_lag_volume=20, - aspirate_lag_speed=50, - aspirate_tag_volume=10, - aspirate_tag_speed=5, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(200.01, 1000.01, Liquid.DMSO, TipType.DITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1.1, - calibration_factor=1.004, - calibration_offset=3.9, - aspirate_speed=150, - aspirate_delay=400, - aspirate_stag_volume=20, - aspirate_stag_speed=5, - aspirate_lag_volume=20, - aspirate_lag_speed=50, - aspirate_tag_volume=10, - aspirate_tag_speed=5, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(0.5, 3.01, Liquid.DMSO, TipType.STDLOWVOL)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1.1, - calibration_factor=1.311, - calibration_offset=0, - aspirate_speed=10, - aspirate_delay=200, - aspirate_stag_volume=5, - aspirate_stag_speed=5, - aspirate_lag_volume=0, - aspirate_lag_speed=50, - aspirate_tag_volume=0.25, - aspirate_tag_speed=5, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=1, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=10, - dispense_breakoff=10, - dispense_delay=400, - dispense_tag=False, - dispense_pinch_valve=True, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(3.01, 15.01, Liquid.DMSO, TipType.STDLOWVOL)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1.1, - calibration_factor=1.215, - calibration_offset=1.5, - aspirate_speed=10, - aspirate_delay=400, - aspirate_stag_volume=2, - aspirate_stag_speed=5, - aspirate_lag_volume=7, - aspirate_lag_speed=50, - aspirate_tag_volume=5, - aspirate_tag_speed=5, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=1, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=110, - dispense_breakoff=110, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(15.01, 300.01, Liquid.DMSO, TipType.STDLOWVOL)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1.1, - calibration_factor=1.122, - calibration_offset=2.4, - aspirate_speed=45, - aspirate_delay=500, - aspirate_stag_volume=10, - aspirate_stag_speed=5, - aspirate_lag_volume=0, - aspirate_lag_speed=50, - aspirate_tag_volume=5, - aspirate_tag_speed=5, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=1, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=110, - dispense_breakoff=110, - dispense_delay=100, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, +warnings.warn( + "Importing from pylabrobot.liquid_handling.liquid_classes.tecan is deprecated. " + "Use pylabrobot.legacy.liquid_handling.liquid_classes.tecan instead.", + DeprecationWarning, + stacklevel=2, ) - -mapping[(1, 7.51, Liquid.DMSO, TipType.MCADITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1.1, - calibration_factor=1.35, - calibration_offset=0, - aspirate_speed=5, - aspirate_delay=200, - aspirate_stag_volume=0, - aspirate_stag_speed=5, - aspirate_lag_volume=7, - aspirate_lag_speed=50, - aspirate_tag_volume=2, - aspirate_tag_speed=5, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=False, - aspirate_lld_position=0, - aspirate_lld_offset=0, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=2, - aspirate_retract_speed=5, - aspirate_retract_offset=0, - dispense_speed=500, - dispense_breakoff=150, - dispense_delay=200, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=3, - dispense_retract_speed=42, - dispense_retract_offset=0, -) - - -mapping[(7.51, 20.01, Liquid.DMSO, TipType.MCADITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1.1, - calibration_factor=1.04, - calibration_offset=0, - aspirate_speed=10, - aspirate_delay=200, - aspirate_stag_volume=0, - aspirate_stag_speed=5, - aspirate_lag_volume=7, - aspirate_lag_speed=50, - aspirate_tag_volume=3, - aspirate_tag_speed=5, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=False, - aspirate_lld_position=0, - aspirate_lld_offset=0, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=2, - aspirate_retract_speed=5, - aspirate_retract_offset=0, - dispense_speed=500, - dispense_breakoff=150, - dispense_delay=200, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=3, - dispense_retract_speed=42, - dispense_retract_offset=0, -) - - -mapping[(20.01, 200.01, Liquid.DMSO, TipType.MCADITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1.1, - calibration_factor=1.04, - calibration_offset=0, - aspirate_speed=10, - aspirate_delay=200, - aspirate_stag_volume=0, - aspirate_stag_speed=5, - aspirate_lag_volume=10, - aspirate_lag_speed=50, - aspirate_tag_volume=3, - aspirate_tag_speed=5, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=False, - aspirate_lld_position=0, - aspirate_lld_offset=0, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=2, - aspirate_retract_speed=5, - aspirate_retract_offset=0, - dispense_speed=400, - dispense_breakoff=300, - dispense_delay=200, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=3, - dispense_retract_speed=42, - dispense_retract_offset=0, -) - - -mapping[(3, 15.01, Liquid.DMSO, TipType.AIRDITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1.1, - calibration_factor=1.08, - calibration_offset=-0.086, - aspirate_speed=20, - aspirate_delay=200, - aspirate_stag_volume=0, - aspirate_stag_speed=50, - aspirate_lag_volume=10, - aspirate_lag_speed=70, - aspirate_tag_volume=5, - aspirate_tag_speed=50, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=1, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(15.01, 200.01, Liquid.DMSO, TipType.AIRDITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1.1, - calibration_factor=1.024, - calibration_offset=0.866, - aspirate_speed=100, - aspirate_delay=200, - aspirate_stag_volume=0, - aspirate_stag_speed=70, - aspirate_lag_volume=10, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=1, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(200.01, 1000.01, Liquid.DMSO, TipType.AIRDITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1.1, - calibration_factor=1.028, - calibration_offset=-0.258, - aspirate_speed=150, - aspirate_delay=400, - aspirate_stag_volume=0, - aspirate_stag_speed=70, - aspirate_lag_volume=10, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=True, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(3, 1000.01, Liquid.ETHANOL, TipType.STANDARD)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=0.8, - calibration_factor=1.063, - calibration_offset=3.4, - aspirate_speed=100, - aspirate_delay=200, - aspirate_stag_volume=20, - aspirate_stag_speed=70, - aspirate_lag_volume=0, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=150, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(3, 1000.01, Liquid.ETHANOL, TipType.DITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=0.8, - calibration_factor=1.09, - calibration_offset=4.3, - aspirate_speed=100, - aspirate_delay=200, - aspirate_stag_volume=20, - aspirate_stag_speed=70, - aspirate_lag_volume=20, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(0.5, 3.01, Liquid.ETHANOL, TipType.STDLOWVOL)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=0.8, - calibration_factor=1.279, - calibration_offset=0.2, - aspirate_speed=10, - aspirate_delay=200, - aspirate_stag_volume=5, - aspirate_stag_speed=10, - aspirate_lag_volume=0, - aspirate_lag_speed=10, - aspirate_tag_volume=0.25, - aspirate_tag_speed=10, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=1, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=10, - aspirate_retract_offset=-5, - dispense_speed=10, - dispense_breakoff=10, - dispense_delay=400, - dispense_tag=False, - dispense_pinch_valve=True, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(3.01, 15.01, Liquid.ETHANOL, TipType.STDLOWVOL)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=0.8, - calibration_factor=1.199, - calibration_offset=1.3, - aspirate_speed=10, - aspirate_delay=400, - aspirate_stag_volume=2, - aspirate_stag_speed=10, - aspirate_lag_volume=7, - aspirate_lag_speed=10, - aspirate_tag_volume=5, - aspirate_tag_speed=10, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=1, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=110, - dispense_breakoff=110, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(15.01, 300.01, Liquid.ETHANOL, TipType.STDLOWVOL)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=0.8, - calibration_factor=1.079, - calibration_offset=2.8, - aspirate_speed=45, - aspirate_delay=500, - aspirate_stag_volume=10, - aspirate_stag_speed=10, - aspirate_lag_volume=0, - aspirate_lag_speed=10, - aspirate_tag_volume=5, - aspirate_tag_speed=10, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=1, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=110, - dispense_breakoff=110, - dispense_delay=100, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(3, 5, Liquid.ETHANOL, TipType.AIRDITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=0.8, - calibration_factor=1.072, - calibration_offset=0.22, - aspirate_speed=20, - aspirate_delay=400, - aspirate_stag_volume=0, - aspirate_stag_speed=50, - aspirate_lag_volume=5, - aspirate_lag_speed=70, - aspirate_tag_volume=5, - aspirate_tag_speed=50, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=True, - aspirate_mix_volume=200, - aspirate_mix_cycles=2, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=400, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(5, 15.01, Liquid.ETHANOL, TipType.AIRDITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=0.8, - calibration_factor=1.072, - calibration_offset=0.42, - aspirate_speed=20, - aspirate_delay=400, - aspirate_stag_volume=0, - aspirate_stag_speed=50, - aspirate_lag_volume=5, - aspirate_lag_speed=70, - aspirate_tag_volume=5, - aspirate_tag_speed=50, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=True, - aspirate_mix_volume=200, - aspirate_mix_cycles=2, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=400, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(15.01, 200.01, Liquid.ETHANOL, TipType.AIRDITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=0.8, - calibration_factor=1.011, - calibration_offset=1.802, - aspirate_speed=150, - aspirate_delay=400, - aspirate_stag_volume=0, - aspirate_stag_speed=70, - aspirate_lag_volume=5, - aspirate_lag_speed=70, - aspirate_tag_volume=5, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=True, - aspirate_mix_volume=200, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=400, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(200.01, 1000.01, Liquid.ETHANOL, TipType.AIRDITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=2, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=0.8, - calibration_factor=1, - calibration_offset=0, - aspirate_speed=150, - aspirate_delay=400, - aspirate_stag_volume=0, - aspirate_stag_speed=70, - aspirate_lag_volume=10, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=True, - aspirate_mix_volume=1000, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=400, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(3, 15.01, Liquid.SERUM, TipType.STANDARD)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=2.1, - pmp_character=0, - density=1, - calibration_factor=1.074, - calibration_offset=0.3, - aspirate_speed=20, - aspirate_delay=200, - aspirate_stag_volume=10, - aspirate_stag_speed=20, - aspirate_lag_volume=20, - aspirate_lag_speed=20, - aspirate_tag_volume=10, - aspirate_tag_speed=20, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=150, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(15.01, 300.01, Liquid.SERUM, TipType.STANDARD)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=2.1, - pmp_character=0, - density=1, - calibration_factor=1.06, - calibration_offset=0.43, - aspirate_speed=100, - aspirate_delay=200, - aspirate_stag_volume=20, - aspirate_stag_speed=70, - aspirate_lag_volume=0, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=150, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(300.01, 1000.01, Liquid.SERUM, TipType.STANDARD)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=2.1, - pmp_character=0, - density=1, - calibration_factor=1.007, - calibration_offset=16.63, - aspirate_speed=100, - aspirate_delay=200, - aspirate_stag_volume=20, - aspirate_stag_speed=70, - aspirate_lag_volume=0, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=150, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(3, 15.01, Liquid.SERUM, TipType.DITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=2.1, - pmp_character=0, - density=1, - calibration_factor=1.138, - calibration_offset=1, - aspirate_speed=20, - aspirate_delay=200, - aspirate_stag_volume=20, - aspirate_stag_speed=20, - aspirate_lag_volume=5, - aspirate_lag_speed=20, - aspirate_tag_volume=10, - aspirate_tag_speed=20, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(15.01, 200.01, Liquid.SERUM, TipType.DITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=2.1, - pmp_character=0, - density=1, - calibration_factor=1.059, - calibration_offset=2.09, - aspirate_speed=100, - aspirate_delay=200, - aspirate_stag_volume=20, - aspirate_stag_speed=70, - aspirate_lag_volume=20, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(200.01, 1000.01, Liquid.SERUM, TipType.DITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=2.1, - pmp_character=0, - density=1, - calibration_factor=1.046, - calibration_offset=8.55, - aspirate_speed=100, - aspirate_delay=400, - aspirate_stag_volume=20, - aspirate_stag_speed=70, - aspirate_lag_volume=20, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(3, 15.01, Liquid.SERUM, TipType.AIRDITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=2.1, - pmp_character=0, - density=1, - calibration_factor=1.115, - calibration_offset=1.146, - aspirate_speed=50, - aspirate_delay=600, - aspirate_stag_volume=0, - aspirate_stag_speed=50, - aspirate_lag_volume=15, - aspirate_lag_speed=70, - aspirate_tag_volume=5, - aspirate_tag_speed=50, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=1, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-10, - dispense_speed=400, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=True, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(15.01, 200.01, Liquid.SERUM, TipType.AIRDITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=2.1, - pmp_character=0, - density=1, - calibration_factor=1.043, - calibration_offset=2.671, - aspirate_speed=100, - aspirate_delay=600, - aspirate_stag_volume=0, - aspirate_stag_speed=70, - aspirate_lag_volume=20, - aspirate_lag_speed=70, - aspirate_tag_volume=6, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=1, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-10, - dispense_speed=400, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=True, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(200.01, 1000.01, Liquid.SERUM, TipType.AIRDITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=2.1, - pmp_character=0, - density=1, - calibration_factor=1.021, - calibration_offset=10.966, - aspirate_speed=100, - aspirate_delay=600, - aspirate_stag_volume=0, - aspirate_stag_speed=70, - aspirate_lag_volume=20, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=400, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=True, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(3, 15.01, Liquid.WATER, TipType.STANDARD)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1.045, - calibration_offset=0.2, - aspirate_speed=20, - aspirate_delay=200, - aspirate_stag_volume=10, - aspirate_stag_speed=20, - aspirate_lag_volume=10, - aspirate_lag_speed=20, - aspirate_tag_volume=5, - aspirate_tag_speed=20, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=150, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(15.01, 500.01, Liquid.WATER, TipType.STANDARD)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1.042, - calibration_offset=-0.04, - aspirate_speed=150, - aspirate_delay=200, - aspirate_stag_volume=20, - aspirate_stag_speed=70, - aspirate_lag_volume=0, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=150, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(500.01, 1000.01, Liquid.WATER, TipType.STANDARD)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1, - calibration_offset=20.26, - aspirate_speed=150, - aspirate_delay=200, - aspirate_stag_volume=20, - aspirate_stag_speed=70, - aspirate_lag_volume=0, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=150, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(3, 15.01, Liquid.WATER, TipType.DITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1.042, - calibration_offset=1.1, - aspirate_speed=20, - aspirate_delay=200, - aspirate_stag_volume=20, - aspirate_stag_speed=20, - aspirate_lag_volume=5, - aspirate_lag_speed=20, - aspirate_tag_volume=10, - aspirate_tag_speed=20, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(15.01, 200.01, Liquid.WATER, TipType.DITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1.034, - calibration_offset=0.9, - aspirate_speed=100, - aspirate_delay=200, - aspirate_stag_volume=20, - aspirate_stag_speed=70, - aspirate_lag_volume=5, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(200.01, 1000.01, Liquid.WATER, TipType.DITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1.031, - calibration_offset=6.12, - aspirate_speed=150, - aspirate_delay=300, - aspirate_stag_volume=20, - aspirate_stag_speed=70, - aspirate_lag_volume=10, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(0.5, 3.01, Liquid.WATER, TipType.STDLOWVOL)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1.1, - calibration_offset=0.15, - aspirate_speed=10, - aspirate_delay=200, - aspirate_stag_volume=5, - aspirate_stag_speed=10, - aspirate_lag_volume=0, - aspirate_lag_speed=10, - aspirate_tag_volume=0.25, - aspirate_tag_speed=10, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=1, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=10, - dispense_breakoff=10, - dispense_delay=400, - dispense_tag=False, - dispense_pinch_valve=True, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(3.01, 15.01, Liquid.WATER, TipType.STDLOWVOL)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1.045, - calibration_offset=0.2, - aspirate_speed=10, - aspirate_delay=400, - aspirate_stag_volume=2, - aspirate_stag_speed=10, - aspirate_lag_volume=7, - aspirate_lag_speed=10, - aspirate_tag_volume=5, - aspirate_tag_speed=10, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=1, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=110, - dispense_breakoff=110, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(15.01, 300.01, Liquid.WATER, TipType.STDLOWVOL)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1.06, - calibration_offset=0.5, - aspirate_speed=45, - aspirate_delay=500, - aspirate_stag_volume=10, - aspirate_stag_speed=10, - aspirate_lag_volume=0, - aspirate_lag_speed=10, - aspirate_tag_volume=5, - aspirate_tag_speed=10, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=1, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=110, - dispense_breakoff=110, - dispense_delay=100, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(1, 3.01, Liquid.WATER, TipType.DITILOWVOL)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1, - calibration_offset=0.25, - aspirate_speed=10, - aspirate_delay=500, - aspirate_stag_volume=5, - aspirate_stag_speed=10, - aspirate_lag_volume=0, - aspirate_lag_speed=10, - aspirate_tag_volume=0.25, - aspirate_tag_speed=10, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=1, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=10, - dispense_breakoff=10, - dispense_delay=100, - dispense_tag=False, - dispense_pinch_valve=True, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(3.01, 15.01, Liquid.WATER, TipType.DITILOWVOL)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1.03, - calibration_offset=0.25, - aspirate_speed=10, - aspirate_delay=500, - aspirate_stag_volume=10, - aspirate_stag_speed=10, - aspirate_lag_volume=10, - aspirate_lag_speed=10, - aspirate_tag_volume=3, - aspirate_tag_speed=10, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=1, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=240, - dispense_breakoff=110, - dispense_delay=200, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(1, 7.51, Liquid.WATER, TipType.MCADITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1, - calibration_offset=0.45, - aspirate_speed=5, - aspirate_delay=200, - aspirate_stag_volume=0, - aspirate_stag_speed=5, - aspirate_lag_volume=20, - aspirate_lag_speed=50, - aspirate_tag_volume=3, - aspirate_tag_speed=5, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=False, - aspirate_lld_position=0, - aspirate_lld_offset=0, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=2, - aspirate_retract_speed=5, - aspirate_retract_offset=0, - dispense_speed=500, - dispense_breakoff=150, - dispense_delay=200, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=3, - dispense_retract_speed=42, - dispense_retract_offset=0, -) - - -mapping[(7.51, 20.01, Liquid.WATER, TipType.MCADITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1, - calibration_offset=0.45, - aspirate_speed=10, - aspirate_delay=200, - aspirate_stag_volume=0, - aspirate_stag_speed=5, - aspirate_lag_volume=10, - aspirate_lag_speed=50, - aspirate_tag_volume=5, - aspirate_tag_speed=5, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=False, - aspirate_lld_position=0, - aspirate_lld_offset=0, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=2, - aspirate_retract_speed=5, - aspirate_retract_offset=0, - dispense_speed=500, - dispense_breakoff=150, - dispense_delay=200, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=3, - dispense_retract_speed=42, - dispense_retract_offset=0, -) - - -mapping[(20.01, 200.01, Liquid.WATER, TipType.MCADITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1.05, - calibration_offset=0.4, - aspirate_speed=50, - aspirate_delay=500, - aspirate_stag_volume=0, - aspirate_stag_speed=5, - aspirate_lag_volume=20, - aspirate_lag_speed=50, - aspirate_tag_volume=10, - aspirate_tag_speed=5, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=False, - aspirate_lld_position=0, - aspirate_lld_offset=0, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=2, - aspirate_retract_speed=5, - aspirate_retract_offset=0, - dispense_speed=400, - dispense_breakoff=300, - dispense_delay=200, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=3, - dispense_retract_speed=42, - dispense_retract_offset=0, -) - - -mapping[(3, 15.01, Liquid.WATER, TipType.AIRDITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1.05, - calibration_offset=1.5, - aspirate_speed=20, - aspirate_delay=400, - aspirate_stag_volume=0, - aspirate_stag_speed=20, - aspirate_lag_volume=10, - aspirate_lag_speed=70, - aspirate_tag_volume=5, - aspirate_tag_speed=20, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(15.01, 200.01, Liquid.WATER, TipType.AIRDITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1.03, - calibration_offset=1.5, - aspirate_speed=100, - aspirate_delay=400, - aspirate_stag_volume=0, - aspirate_stag_speed=70, - aspirate_lag_volume=5, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) - - -mapping[(200.01, 1000.01, Liquid.WATER, TipType.AIRDITI)] = TecanLiquidClass( - lld_mode=7, - lld_conductivity=1, - lld_speed=60, - lld_distance=4, - clot_speed=50, - clot_limit=4, - pmp_sensitivity=1, - pmp_viscosity=1, - pmp_character=0, - density=1, - calibration_factor=1.025, - calibration_offset=4, - aspirate_speed=150, - aspirate_delay=400, - aspirate_stag_volume=0, - aspirate_stag_speed=70, - aspirate_lag_volume=10, - aspirate_lag_speed=70, - aspirate_tag_volume=10, - aspirate_tag_speed=70, - aspirate_excess=0, - aspirate_conditioning=0, - aspirate_pinch_valve=False, - aspirate_lld=True, - aspirate_lld_position=3, - aspirate_lld_offset=2, - aspirate_mix=False, - aspirate_mix_volume=100, - aspirate_mix_cycles=1, - aspirate_retract_position=4, - aspirate_retract_speed=20, - aspirate_retract_offset=-5, - dispense_speed=600, - dispense_breakoff=400, - dispense_delay=0, - dispense_tag=False, - dispense_pinch_valve=False, - dispense_lld=False, - dispense_lld_position=0, - dispense_lld_offset=0, - dispense_touching_direction=0, - dispense_touching_speed=10, - dispense_touching_delay=100, - dispense_mix=False, - dispense_mix_volume=100, - dispense_mix_cycles=1, - dispense_retract_position=1, - dispense_retract_speed=50, - dispense_retract_offset=0, -) +from pylabrobot.legacy.liquid_handling.liquid_classes.tecan import * # noqa: F401,F403,E402 diff --git a/pylabrobot/liquid_handling/standard.py b/pylabrobot/liquid_handling/standard.py index fdc057b7c94..e0b4b950990 100644 --- a/pylabrobot/liquid_handling/standard.py +++ b/pylabrobot/liquid_handling/standard.py @@ -1,180 +1,10 @@ -"""Data structures for the standard form of liquid handling.""" +import warnings -from __future__ import annotations +warnings.warn( + "Importing from pylabrobot.liquid_handling.standard is deprecated. " + "Use pylabrobot.legacy.liquid_handling.standard instead.", + DeprecationWarning, + stacklevel=2, +) -import enum -from dataclasses import dataclass -from typing import TYPE_CHECKING, List, Optional, Sequence, Union - -from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.resources.rotation import Rotation - -if TYPE_CHECKING: - from pylabrobot.resources import ( - Container, - Resource, - TipRack, - Trash, - Well, - ) - from pylabrobot.resources.tip import Tip - from pylabrobot.resources.tip_rack import TipSpot - - -@dataclass(frozen=True) -class Pickup: - resource: TipSpot - offset: Coordinate - tip: Tip # TODO: perhaps we can remove this, because the tip spot has the tip? - - -@dataclass(frozen=True) -class Drop: - resource: Resource - offset: Coordinate - tip: Tip - - -@dataclass(frozen=True) -class PickupTipRack: - resource: TipRack - offset: Coordinate - tips: Sequence[Optional[Tip]] - - -@dataclass(frozen=True) -class DropTipRack: - resource: Union[TipRack, Trash] - offset: Coordinate - - -@dataclass(frozen=True) -class SingleChannelAspiration: - resource: Container - offset: Coordinate - tip: Tip - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] - - -@dataclass(frozen=True) -class SingleChannelDispense: - resource: Container - offset: Coordinate - tip: Tip - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] - - -@dataclass(frozen=True) -class Mix: - """Repeated aspirate/dispense cycles to mix the liquid during a transfer. - - Args: - volume: The volume drawn then expelled each cycle. - repetitions: The number of aspirate/dispense cycles. - flow_rate: The flow rate of the mix. - surface_following_distance: The distance (mm) the tip follows the liquid surface each cycle on - backends that support it (e.g. Hamilton STAR); others ignore it. - """ - - volume: float - repetitions: int - flow_rate: float - surface_following_distance: Optional[float] = None - - -@dataclass(frozen=True) -class MultiHeadAspirationPlate: - wells: List[Well] - offset: Coordinate - tips: Sequence[Optional[Tip]] - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] - - -@dataclass(frozen=True) -class MultiHeadDispensePlate: - wells: List[Well] - offset: Coordinate - tips: Sequence[Optional[Tip]] - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] - - -@dataclass(frozen=True) -class MultiHeadAspirationContainer: - container: Container - offset: Coordinate - tips: Sequence[Optional[Tip]] - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] - - -@dataclass(frozen=True) -class MultiHeadDispenseContainer: - container: Container - offset: Coordinate - tips: Sequence[Optional[Tip]] - volume: float - flow_rate: Optional[float] - liquid_height: Optional[float] - blow_out_air_volume: Optional[float] - mix: Optional[Mix] - - -class GripDirection(enum.Enum): - FRONT = enum.auto() - BACK = enum.auto() - LEFT = enum.auto() - RIGHT = enum.auto() - - -@dataclass(frozen=True) -class ResourcePickup: - resource: Resource - offset: Coordinate - pickup_distance_from_top: float - direction: GripDirection - - -@dataclass(frozen=True) -class ResourceMove: - """Moving a resource that was already picked up.""" - - resource: Resource - location: Coordinate - gripped_direction: GripDirection - pickup_distance_from_top: float - offset: Coordinate - - -@dataclass(frozen=True) -class ResourceDrop: - resource: Resource - # Destination is the location of the lfb of `resource` - destination: Coordinate - destination_absolute_rotation: Rotation - offset: Coordinate - pickup_distance_from_top: float - pickup_direction: GripDirection - direction: GripDirection - rotation: float - - -PipettingOp = Union[Pickup, Drop, SingleChannelAspiration, SingleChannelDispense] +from pylabrobot.legacy.liquid_handling.standard import * # noqa: F401,F403,E402 diff --git a/pylabrobot/machines/__init__.py b/pylabrobot/machines/__init__.py index cbfbf4e4c50..6af7e4db105 100644 --- a/pylabrobot/machines/__init__.py +++ b/pylabrobot/machines/__init__.py @@ -1 +1,9 @@ -from .machine import Machine, need_setup_finished +import warnings + +warnings.warn( + "Importing from pylabrobot.machines is deprecated. Use pylabrobot.legacy.machines instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.machines import * # noqa: F401,F403,E402 diff --git a/pylabrobot/mettler_toledo/__init__.py b/pylabrobot/mettler_toledo/__init__.py new file mode 100644 index 00000000000..f1935d0ea7b --- /dev/null +++ b/pylabrobot/mettler_toledo/__init__.py @@ -0,0 +1 @@ +from .mettler_toledo import MettlerToledoError, MettlerToledoWXS205SDU diff --git a/pylabrobot/mettler_toledo/mettler_toledo.py b/pylabrobot/mettler_toledo/mettler_toledo.py new file mode 100644 index 00000000000..2fe5a8b430d --- /dev/null +++ b/pylabrobot/mettler_toledo/mettler_toledo.py @@ -0,0 +1,469 @@ +# similar library: https://github.com/janelia-pypi/mettler_toledo_device_python + +import asyncio +import logging +import time +from typing import List, Literal, Optional, Union + +from pylabrobot.io.serial import Serial + +logger = logging.getLogger(__name__) + + +class MettlerToledoError(Exception): + """Exceptions raised by a Mettler Toledo scale.""" + + def __init__(self, title: str, message: Optional[str] = None) -> None: + self.title = title + self.message = message + + def __str__(self) -> str: + return f"{self.title}: {self.message}" + + @staticmethod + def unknown_error() -> "MettlerToledoError": + return MettlerToledoError(title="Unknown error", message="An unknown error occurred") + + @staticmethod + def executing_another_command() -> "MettlerToledoError": + return MettlerToledoError( + title="Command not understood, not executable at present", + message=( + "Command understood but currently not executable (balance is " + "currently executing another command)." + ), + ) + + @staticmethod + def incorrect_parameter() -> "MettlerToledoError": + return MettlerToledoError( + title="Command understood but not executable", + message="(incorrect parameter).", + ) + + @staticmethod + def overload() -> "MettlerToledoError": + return MettlerToledoError(title="Balance in overload range.", message=None) + + @staticmethod + def underload() -> "MettlerToledoError": + return MettlerToledoError(title="Balance in underload range.", message=None) + + @staticmethod + def syntax_error() -> "MettlerToledoError": + return MettlerToledoError( + title="Syntax error", + message="The weigh module/balance has not recognized the received command or the command is " + "not allowed", + ) + + @staticmethod + def transmission_error() -> "MettlerToledoError": + return MettlerToledoError( + title="Transmission error", + message="The weigh module/balance has received a 'faulty' command, e.g. owing to a parity " + "error or interface break", + ) + + @staticmethod + def logical_error() -> "MettlerToledoError": + return MettlerToledoError( + title="Logical error", + message="The weigh module/balance can not execute the received command", + ) + + @staticmethod + def boot_error(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="Boot error", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def brand_error(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="Brand error", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def checksum_error(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="Checksum error", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def option_fail(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="Option fail", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def eeprom_error(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="EEPROM error", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def device_mismatch(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="Device mismatch", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def hot_plug_out(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="Hot plug out", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def weight_module_electronic_mismatch( + from_terminal: bool, + ) -> "MettlerToledoError": + return MettlerToledoError( + title="Weight module / electronic mismatch", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def adjustment_needed(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="Adjustment needed", + message="from terminal" if from_terminal else "from electronics", + ) + + +MettlerToledoResponse = List[str] + + +class MettlerToledoWXS205SDU: + """Driver for the Mettler Toledo WXS205SDU scale. + + Owns the serial connection and provides a generic send_command method. + Device-level operations (display) live here. + + Documentation: https://web.archive.org/web/20240208213802/https://www.mt.com/dam/ + product_organizations/industry/apw/generic/11781363_N_MAN_RM_MT-SICS_APW_en.pdf + + From the docs: + + "If several commands are sent in succession without waiting for the corresponding + responses, it is possible that the weigh module/balance confuses the sequence of + command processing or ignores entire commands." + """ + + def __init__(self, port: Optional[str] = None, vid: int = 0x0403, pid: int = 0x6001): + super().__init__() + self.io = Serial( + human_readable_device_name="Mettler Toledo WXS205SDU", + port=port, + vid=vid, + pid=pid, + baudrate=9600, + timeout=1, + ) + + async def setup(self) -> None: + await self.io.setup() + logger.info("[MettlerToledo %s] connected", self.io.port) + + await self.send_command("M21 0 0") + self.serial_number = await self.request_serial_number() + logger.info( + "[MettlerToledo %s] initialized: serial_number=%s", self.io.port, self.serial_number + ) + + async def stop(self) -> None: + await self.io.stop() + + # === Response parsing === + + def _parse_basic_errors(self, response: List[str]) -> None: + """Helper function for parsing basic errors that are common to many commands. If an error is + detected, a 'MettlerToledoError' exception is raised. + + These are in the first place of the response: + - ES: syntax error: The weigh module/balance has not recognized the received command or the + command is not allowed + - ET: transmission error: The weigh module/balance has received a "faulty" command, e.g. owing + to a parity error or interface break + - EL: logical error: The weigh module/balance can not execute the received command + + These are in the second place of the response (MT-SICS spec p.10, sec 2.1.3.1): + - A: Command executed successfully + - B: Command not yet terminated, additional responses following + - I: Internal error (e.g. balance not ready yet) + - L: Logical error (e.g. parameter not allowed) + - +: Balance in overload range + - -: Balance in underload range + + TODO: handle 'B' status — multi-response commands (e.g. C1 adjustment) send 'B' first, + then additional responses, then 'A' on completion. Currently send_command returns after + the first response, so 'B' responses are not followed up. + """ + + if response[0] == "ES": + raise MettlerToledoError.syntax_error() + if response[0] == "ET": + raise MettlerToledoError.transmission_error() + if response[0] == "EL": + raise MettlerToledoError.logical_error() + + if response[1] == "I": + raise MettlerToledoError.executing_another_command() + if response[1] == "L": + raise MettlerToledoError.incorrect_parameter() + if response[1] == "+": + raise MettlerToledoError.overload() + if response[1] == "-": + raise MettlerToledoError.underload() + + if response[0] == "S" and response[1] == "S" and response[2] == "Error": + error_code = response[3] + code, source = error_code[:-1], error_code[-1] + from_terminal = source == "t" + if code == "1": + raise MettlerToledoError.boot_error(from_terminal=from_terminal) + if code == "2": + raise MettlerToledoError.brand_error(from_terminal=from_terminal) + if code == "3": + raise MettlerToledoError.checksum_error(from_terminal=from_terminal) + if code == "9": + raise MettlerToledoError.option_fail(from_terminal=from_terminal) + if code == "10": + raise MettlerToledoError.eeprom_error(from_terminal=from_terminal) + if code == "11": + raise MettlerToledoError.device_mismatch(from_terminal=from_terminal) + if code == "12": + raise MettlerToledoError.hot_plug_out(from_terminal=from_terminal) + if code == "14": + raise MettlerToledoError.weight_module_electronic_mismatch(from_terminal=from_terminal) + if code == "15": + raise MettlerToledoError.adjustment_needed(from_terminal=from_terminal) + + # === Command Layer === + + async def send_command(self, command: str, timeout: int = 60) -> MettlerToledoResponse: + """Send a command to the scale and receive the response. + + Args: + timeout: The timeout in seconds. + """ + + await self.io.write(command.encode() + b"\r\n") + + raw_response = b"" + timeout_time = time.time() + timeout + while True: + raw_response = await self.io.readline() + await asyncio.sleep(0.001) + if time.time() > timeout_time: + raise TimeoutError("Timeout while waiting for response from scale.") + if raw_response != b"": + break + logger.debug("[scale] Received response: %s", raw_response) + response = raw_response.decode("utf-8").strip().split() + + # parse basic errors + self._parse_basic_errors(response) + + # mypy doesn't understand this + return response # type: ignore + + # === Device-level operations === + + async def set_display_text(self, text: str) -> MettlerToledoResponse: + """Set the display text of the scale. Return to the normal weight display with + self.set_weight_display().""" + return await self.send_command(f'D "{text}"') + + async def set_weight_display(self) -> MettlerToledoResponse: + """Return the display to the normal weight display.""" + return await self.send_command("DW") + + # === Public high-level API === + + async def request_serial_number(self) -> str: + """Get the serial number of the scale. (MEM-READ command)""" + response = await self.send_command("I4") + serial_number = response[2] + serial_number = serial_number.replace('"', "") + return serial_number + + # # Zero commands # # + + async def zero_immediately(self) -> MettlerToledoResponse: + """Zero the scale immediately. (ACTION command)""" + return await self.send_command("ZI") + + async def zero_stable(self) -> MettlerToledoResponse: + """Zero the scale when the weight is stable. (ACTION command)""" + return await self.send_command("Z") + + async def zero_timeout(self, timeout: float) -> MettlerToledoResponse: + """Zero the scale after a given timeout. (ACTION command)""" + # For some reason, this will always return a syntax error (ES), even though it should be allowed + # according to the docs. + timeout = int(timeout * 1000) + return await self.send_command(f"ZC {timeout}") + + async def zero( + self, timeout: Union[Literal["stable"], float, int] = "stable" + ) -> MettlerToledoResponse: + """High level function to zero the scale. (ACTION command) + + Args: + timeout: The timeout in seconds. If "stable", the scale will zero when the weight is stable. + If 0, the scale will zero immediately. If a float/int, the scale will zero after the given + timeout (in seconds). + """ + + if timeout == "stable": + result = await self.zero_stable() + elif not isinstance(timeout, (float, int)): + raise TypeError("timeout must be a float or 'stable'") + elif timeout < 0: + raise ValueError("timeout must be greater than or equal to 0") + elif timeout == 0: + result = await self.zero_immediately() + else: + result = await self.zero_timeout(timeout) + + logger.info("[MettlerToledo %s] zeroed: timeout=%s", self.serial_number, timeout) + return result + + # # Tare commands # # + + async def tare_stable(self) -> MettlerToledoResponse: + """Tare the scale when the weight is stable. (ACTION command)""" + return await self.send_command("T") + + async def tare_immediately(self) -> MettlerToledoResponse: + """Tare the scale immediately. (ACTION command)""" + return await self.send_command("TI") + + async def tare_timeout(self, timeout: float) -> MettlerToledoResponse: + """Tare the scale after a given timeout. (ACTION command)""" + # For some reason, this will always return a syntax error (ES), even though it should be allowed + # according to the docs. + timeout = int(timeout * 1000) # convert to milliseconds + return await self.send_command(f"TC {timeout}") + + async def tare( + self, timeout: Union[Literal["stable"], float, int] = "stable" + ) -> MettlerToledoResponse: + """High level function to tare the scale. (ACTION command) + + Args: + timeout: The timeout in seconds. If "stable", the scale will tare when the weight is stable. + If 0, the scale will tare immediately. If a float/int, the scale will tare after the given + timeout (in seconds). + """ + + if timeout == "stable": + # "Use T to tare the balance. The next stable weight value will be saved in the tare memory." + result = await self.tare_stable() + elif not isinstance(timeout, (float, int)): + raise TypeError("timeout must be a float or 'stable'") + elif timeout < 0: + raise ValueError("timeout must be greater than or equal to 0") + elif timeout == 0: + result = await self.tare_immediately() + else: + result = await self.tare_timeout(timeout) + + logger.info("[MettlerToledo %s] tared: timeout=%s", self.serial_number, timeout) + return result + + # # Weight reading commands # # + + async def request_tare_weight(self) -> float: + """Request tare weight value from scale's memory. (MEM-READ command) + "Use TA to query the current tare value or preset a known tare value." + """ + + response = await self.send_command("TA") + tare = float(response[2]) + unit = response[3] + assert unit == "g" # this is the format we expect + return tare + + async def clear_tare(self) -> MettlerToledoResponse: + """TAC - Clear tare weight value (MEM-WRITE command)""" + return await self.send_command("TAC") + + async def read_stable_weight(self) -> float: + """Read a stable weight value from the scale. (MEASUREMENT command) + + from the docs: + + "Use S to send a stable weight value, along with the host unit, from the balance to + the connected communication partner via the interface. If the automatic door function + is enabled and a stable weight is requested the balance will open and close the balance's + doors to achieve a stable weight." + """ + + response = await self.send_command("S") + weight = float(response[2]) + unit = response[3] + assert unit == "g" # this is the format we expect + logger.info("[MettlerToledo %s] stable weight read: weight_g=%s", self.serial_number, weight) + return weight + + async def read_dynamic_weight(self, timeout: float) -> float: + """Read a stable weight value from the machine within a given timeout, or + return the current weight value if not possible. (MEASUREMENT command) + + Args: + timeout: The timeout in seconds. + """ + + timeout = int(timeout * 1000) # convert to milliseconds + + response = await self.send_command(f"SC {timeout}") + weight = float(response[2]) + unit = response[3] + assert unit == "g" # this is the format we expect + logger.info("[MettlerToledo %s] dynamic weight read: weight_g=%s", self.serial_number, weight) + return weight + + async def read_weight_value_immediately(self) -> float: + """Read a weight value immediately from the scale. (MEASUREMENT command) + + "Use SI to immediately send the current weight value, along with the host unit, from the + balance to the connected communication partner via the interface." + """ + + response = await self.send_command("SI") + weight = float(response[2]) + assert response[3] == "g" # this is the format we expect + logger.info("[MettlerToledo %s] immediate weight read: weight_g=%s", self.serial_number, weight) + return weight + + async def read_weight(self, timeout: Union[Literal["stable"], float, int] = "stable") -> float: + """High level function to read a weight value from the scale. (MEASUREMENT command) + + Args: + timeout: The timeout in seconds. If "stable", the scale will return a weight value when the + weight is stable. If 0, the scale will return a weight value immediately. If a float/int, + the scale will return a weight value after the given timeout (in seconds). + """ + + if timeout == "stable": + return await self.read_stable_weight() + + if not isinstance(timeout, (float, int)): + raise TypeError("timeout must be a float or 'stable'") + + if timeout < 0: + raise ValueError("timeout must be greater than or equal to 0") + + if timeout == 0: + return await self.read_weight_value_immediately() + + return await self.read_dynamic_weight(timeout) diff --git a/pylabrobot/microscopy/__init__.py b/pylabrobot/microscopy/__init__.py new file mode 100644 index 00000000000..026cf5740cb --- /dev/null +++ b/pylabrobot/microscopy/__init__.py @@ -0,0 +1,9 @@ +import warnings + +warnings.warn( + "Importing from pylabrobot.microscopy is deprecated. Use pylabrobot.legacy.microscopes instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.microscopes import * # noqa: F401,F403,E402 diff --git a/pylabrobot/molecular_devices/__init__.py b/pylabrobot/molecular_devices/__init__.py new file mode 100644 index 00000000000..c778ba07175 --- /dev/null +++ b/pylabrobot/molecular_devices/__init__.py @@ -0,0 +1,6 @@ +from .imageXpress.pico.pico import Pico +from .spectramax import ( + MolecularDevicesPlateReader, + SpectraMax384Plus, + SpectraMaxM5, +) diff --git a/pylabrobot/molecular_devices/imageXpress/pico/__init__.py b/pylabrobot/molecular_devices/imageXpress/pico/__init__.py new file mode 100644 index 00000000000..ec3397874d2 --- /dev/null +++ b/pylabrobot/molecular_devices/imageXpress/pico/__init__.py @@ -0,0 +1,2 @@ +from .models import * +from .pico import Pico diff --git a/pylabrobot/molecular_devices/imageXpress/pico/models.py b/pylabrobot/molecular_devices/imageXpress/pico/models.py new file mode 100644 index 00000000000..803dc0004fa --- /dev/null +++ b/pylabrobot/molecular_devices/imageXpress/pico/models.py @@ -0,0 +1,115 @@ +import enum +import sys +from dataclasses import dataclass +from typing import List, Literal, Union + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + from typing_extensions import TypeAlias + +try: + import numpy.typing as npt # type: ignore + + Image: TypeAlias = npt.NDArray +except ImportError: + Image: TypeAlias = object # type: ignore + + +class Objective(enum.Enum): + """Objectives available on the ImageXpress Pico.""" + + O_2_5X_N_PLAN = enum.auto() + O_4X_PL_FL = enum.auto() + O_10X_PL_FL = enum.auto() + O_20X_PL_FL = enum.auto() + O_40X_PL_FL = enum.auto() + + @property + def magnification(self) -> float: + return { + Objective.O_2_5X_N_PLAN: 2.5, + Objective.O_4X_PL_FL: 4, + Objective.O_10X_PL_FL: 10, + Objective.O_20X_PL_FL: 20, + Objective.O_40X_PL_FL: 40, + }[self] + + @property + def objective_id(self) -> str: + """The identifier the instrument uses for this objective.""" + return { + Objective.O_2_5X_N_PLAN: "N PLAN 2.5x/0.07", + Objective.O_4X_PL_FL: "PL FLUOTAR 4x/0.13", + Objective.O_10X_PL_FL: "PL FLUOTAR 10x/0.30", + Objective.O_20X_PL_FL: "PL FLUOTAR 20x/0.40", + Objective.O_40X_PL_FL: "PL FLUOTAR 40x/0.60", + }[self] + + +class ImagingMode(enum.Enum): + """Imaging modes available on the ImageXpress Pico.""" + + BRIGHTFIELD = enum.auto() + + CY5 = enum.auto() + DAPI = enum.auto() + GFP = enum.auto() + RFP = enum.auto() + TEXAS_RED = enum.auto() + + @property + def light_channel(self) -> int: + return { + ImagingMode.BRIGHTFIELD: 5, + ImagingMode.CY5: 0, + ImagingMode.DAPI: 0, + ImagingMode.GFP: 0, + ImagingMode.RFP: 0, + ImagingMode.TEXAS_RED: 0, + }[self] + + @property + def filter_cube(self) -> str: + """The identifier of the filter cube this mode needs, or "" if it needs none.""" + return { + ImagingMode.BRIGHTFIELD: "", + ImagingMode.CY5: "Cy5", + ImagingMode.DAPI: "DAPI", + ImagingMode.GFP: "FITC", + ImagingMode.RFP: "TRITC", + ImagingMode.TEXAS_RED: "TxRed", + }[self] + + @property + def excitation_source(self) -> str: + return { + ImagingMode.BRIGHTFIELD: "5069278", + ImagingMode.CY5: "5050028", + ImagingMode.DAPI: "GUV3809", + ImagingMode.GFP: "5050029", + ImagingMode.RFP: "5050028", + ImagingMode.TEXAS_RED: "5050028", + }[self] + + +# "machine-auto" hands the setting to the instrument: auto-exposure for Exposure, +# hardware autofocus for FocalPosition. +Exposure = Union[float, Literal["machine-auto"]] +FocalPosition = Union[float, Literal["machine-auto"]] +Gain = Union[float, Literal["machine-auto"]] + + +@dataclass +class ImagingResult: + """Result of a capture. + + Attributes: + images: One image per acquired frame. + exposure_time: Exposure time in ms, as reported by the instrument. + focal_height: Focal height in mm. + """ + + images: List[Image] + exposure_time: float + focal_height: float diff --git a/pylabrobot/molecular_devices/imageXpress/pico/pico.py b/pylabrobot/molecular_devices/imageXpress/pico/pico.py new file mode 100644 index 00000000000..18a56d431d8 --- /dev/null +++ b/pylabrobot/molecular_devices/imageXpress/pico/pico.py @@ -0,0 +1,683 @@ +import asyncio +import base64 +import hashlib +import io +import json +import logging +import struct +import time +from collections import defaultdict +from typing import Callable, Dict, List, Optional, Tuple, TypeVar + +from pylabrobot.io.sila.grpc import ( + command_execution_uuid, + decode_command_confirmation, + decode_fields, + decode_grpc_error, + decode_sila_string_response, + get_field_bytes, + get_field_varint, + length_delimited, + lock_server_params, + metadata_lock_identifier, + sila_string, + unlock_server_params, + varint_as_signed, +) +from pylabrobot.molecular_devices.imageXpress.pico.models import ( + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.utils import row_index_to_label +from pylabrobot.resources.well import WellBottomType + +try: + import grpc # type: ignore[import-untyped] + + HAS_GRPC = True +except ImportError as e: + grpc = None # type: ignore[assignment] + HAS_GRPC = False + _GRPC_IMPORT_ERROR = e + +try: + import numpy as np # type: ignore[import-not-found] + + HAS_NUMPY = True +except ImportError: + HAS_NUMPY = False + +try: + from PIL import Image as PILImage # type: ignore[import-not-found] + + HAS_PIL = True +except ImportError: + HAS_PIL = False + +logger = logging.getLogger(__name__) + + +def _snap_images_params(labware_json: str, snap_json: str) -> bytes: + return length_delimited(1, sila_string(labware_json)) + length_delimited( + 2, sila_string(snap_json) + ) + + +def _decode_intermediate_response(data: bytes) -> Tuple[bytes, Dict[str, int]]: + fields = decode_fields(data) + + binary_msg = get_field_bytes(fields, 1) + if binary_msg is None: + raise ValueError("No IntermediateImageData in intermediate response") + binary_fields = decode_fields(binary_msg) + chunk_data = get_field_bytes(binary_fields, 1) + if chunk_data is None: + chunk_data = b"" + + snap_event_msg = get_field_bytes(fields, 2) + if snap_event_msg is None: + raise ValueError("No SnapEventData in intermediate response") + snap_event_fields = decode_fields(snap_event_msg) + + metadata_struct_msg = get_field_bytes(snap_event_fields, 1) + if metadata_struct_msg is None: + raise ValueError("No LargeBinaryPacketMetadata in SnapEventData") + meta_fields = decode_fields(metadata_struct_msg) + + def _extract_integer(field_num: int) -> int: + integer_msg = get_field_bytes(meta_fields, field_num) + if integer_msg is None: + return 0 + int_fields = decode_fields(integer_msg) + val = get_field_varint(int_fields, 1) + return varint_as_signed(val) if val is not None else 0 + + metadata = { + "blob_index": _extract_integer(1), + "blob_checksum": _extract_integer(2), + "packet_count": _extract_integer(3), + "packet_index": _extract_integer(4), + } + + return chunk_data, metadata + + +# --------------------------------------------------------------------------- +# Default JSON parameter builders +# --------------------------------------------------------------------------- + + +def _labware_params_from_plate(plate: Plate) -> dict: + """Derive Pico labware JSON from a PLR :class:`Plate`.""" + well_a1 = plate.get_well("A1") + nrows = plate.num_items_y + ncols = plate.num_items_x + + col_spacing = plate.item_dx if ncols > 1 else 9.0 + row_spacing = plate.item_dy if nrows > 1 else 9.0 + + a1_loc = well_a1.location + assert a1_loc is not None, "Well A1 must have a location" + well_size_x = well_a1.get_size_x() + well_size_y = well_a1.get_size_y() + + dist2firstcol = a1_loc.x + well_size_x / 2.0 + + last_row_label = row_index_to_label(nrows - 1) + last_row_well = plate.get_well(f"{last_row_label}1") + last_row_loc = last_row_well.location + assert last_row_loc is not None, f"Well {last_row_label}1 must have a location" + dist2firstrow = last_row_loc.y + well_size_y / 2.0 + + bottom_length = well_size_x + bottom_width = well_size_y + volume = well_a1.max_volume or 350.0 + bottom_thickness = well_a1.material_z_thickness + bottom_elevation = a1_loc.z or 0.0 + round_bottom = well_a1.bottom_type in (WellBottomType.U, WellBottomType.V) + + return { + "LabwareType": 1, + "LabwareDimensions": { + "ncavities": nrows * ncols, + "nrows": nrows, + "columns": ncols, + "labware_length": plate.get_size_x(), + "labware_width": plate.get_size_y(), + "labware_height": plate.get_size_z(), + "dist2firstcol": dist2firstcol, + "dist2firstrow": dist2firstrow, + "row_distance": row_spacing, + "well2well_dist_col": col_spacing, + "bottom_elevation": bottom_elevation, + "bottom_thickness": bottom_thickness, + "bottom_clearance": 1.73, + "bottom_length": bottom_length, + "bottom_width": bottom_width, + "round_bottom": round_bottom, + "volume": volume, + }, + "RefractionIndex": 1.0, + } + + +def _default_snap_params( + well_row: int = 0, + well_col: int = 0, + exposure_us: int = 10000, + light_channel: int = 0, + filter_cube: str = "DAPI", + excitation_source: str = "GUV3809", + objective: str = "PL FLUOTAR 4x/0.13", +) -> dict: + return { + "imagesChannelParameters": [ + { + "focusOffsetNm": 0, + "imageSize": {"X": 2008, "Y": 2008}, + "exposureTimeUs": exposure_us, + "illuminationConfig": { + "filterCubeId": filter_cube, + "excitationSourceId": excitation_source, + "lightChannel": light_channel, + }, + "imageDataFormat": { + "ImageFormat": "Tiff", + "BPPLayout": { + "TotBPP": 16, + "SigBPP": 12, + "ShiftToMSB": True, + }, + }, + "objectiveId": objective, + "doAutoExposure": False, + "autoFocusRange": "normal", + "softwareAutofocusRange": "none", + "hardwareAutofocusSearch": "plateBottom", + } + ], + "capturePosition": { + "cavityCoordinatesIndexXy": {"Item1": well_col, "Item2": well_row}, + "siteCoordinatesNmXy": {"Item1": 0, "Item2": 0}, + }, + "skipAutofocus": True, + "localTimeReferencePosixMSec": int(time.time() * 1000), + "storageId": 0, + "focusSettings": { + "autofocusLocation": "fixedFocus", + "baseZPositionUm": 0.0, + }, + } + + +# --------------------------------------------------------------------------- +# Image extraction helpers +# --------------------------------------------------------------------------- + + +def _get(d: dict, *keys): + for k in keys: + if k in d: + return d[k] + return None + + +def _extract_image_buffer(snap_event: dict) -> Optional[bytes]: + image_data = _get(snap_event, "imageData", "ImageData") + if image_data is None: + return None + image_buffer = _get(image_data, "imageBuffer", "ImageBuffer") + if image_buffer is None: + return None + if isinstance(image_buffer, str): + return base64.b64decode(image_buffer) + if isinstance(image_buffer, list): + return bytes(image_buffer) + return None + + +def _get_image_info(snap_event: dict) -> dict: + image_data = _get(snap_event, "imageData", "ImageData") or {} + buffer_info = _get(image_data, "imageBufferInfo", "ImageBufferInfo") or {} + captured_info = _get(image_data, "capturedImageInfo", "CapturedImageInfo") or {} + return { + "width": _get(buffer_info, "width", "Width") or 0, + "height": _get(buffer_info, "height", "Height") or 0, + "stride": _get(buffer_info, "stride", "Stride") or 0, + "pixel_format": _get(buffer_info, "pixelFormat", "PixelFormat") or "", + "exposure_time_us": _get(captured_info, "exposureTimeUs", "ExposureTimeUs") or 0, + "z_focus_offset_um": _get(snap_event, "zFocusOffsetUm", "ZFocusOffsetUm") or 0, + "no_image_data": _get(snap_event, "noImageData", "NoImageData") or False, + } + + +def _buffer_to_ndarray(image_buffer: bytes, width: int, height: int): + if not HAS_NUMPY: + raise ImportError("numpy is required for PicoBackend") + + if len(image_buffer) >= 4 and image_buffer[:2] in (b"II", b"MM"): + if HAS_PIL: + img = PILImage.open(io.BytesIO(image_buffer)) + return np.array(img) + logger.warning("PIL not available, attempting raw decode of TIFF buffer") + + expected_16bit = width * height * 2 + if width > 0 and height > 0 and len(image_buffer) >= expected_16bit: + return np.frombuffer(image_buffer[:expected_16bit], dtype=np.uint16).reshape(height, width) + + expected_8bit = width * height + if width > 0 and height > 0 and len(image_buffer) >= expected_8bit: + return np.frombuffer(image_buffer[:expected_8bit], dtype=np.uint8).reshape(height, width) + + raise ValueError( + f"Cannot decode image buffer: {len(image_buffer)} bytes, expected {width}x{height} pixels" + ) + + +# --------------------------------------------------------------------------- +# gRPC service paths +# --------------------------------------------------------------------------- + +_LOCK_SVC = "sila2.org.silastandard.core.lockcontroller.v1.LockController" +_INST_SVC = "sila2.moldev.com.instruments.instrumentcontroller.v1.InstrumentController" +_SNAP_SVC = "sila2.moldev.com.instruments.snapcontroller.v1.SnapController" +_HW_SVC = "sila2.moldev.com.instruments.hardwarecontroller.v1.HardwareController" +_OBJ_SVC = "sila2.moldev.com.instruments.objectivescontroller.v1.ObjectivesController" +_FC_SVC = "sila2.moldev.com.instruments.filtercubescontroller.v1.FilterCubesController" +_LOCK_META_KEY = "sila-org.silastandard-core-lockcontroller-v1-metadata-lockidentifier-bin" + + +class Pico: + def __init__( + self, + host: str, + port: int = 8091, + lock_timeout: int = 3600, + objectives: Optional[Dict[int, Objective]] = None, + filter_cubes: Optional[Dict[int, ImagingMode]] = None, + ): + super().__init__() + self._host = host + self._port = port + self._lock_timeout = lock_timeout + + self._channel: Optional["grpc.Channel"] = None + self._lock_id: Optional[str] = None + self._locked = False + self._door_open = False + + self._objectives: Dict[int, Objective] = objectives or {} + self._filter_cubes: Dict[int, ImagingMode] = filter_cubes or {} + + # -- gRPC helpers -- + + @property + def channel(self) -> "grpc.Channel": + if self._channel is None: + raise RuntimeError("Backend not set up. Call setup() first.") + return self._channel + + def _lock_metadata(self) -> List[Tuple[str, bytes]]: + assert self._lock_id is not None + return [(_LOCK_META_KEY, metadata_lock_identifier(self._lock_id))] + + async def _relock(self) -> None: + try: + await self._unlock() + except (grpc.RpcError, RuntimeError): + pass + await self._lock() + + _T = TypeVar("_T") + + async def _rpc( + self, + service: str, + method: str, + fn: Callable[[], _T], + with_lock: bool, + ) -> _T: + for attempt in range(2): + try: + return await asyncio.to_thread(fn) + except grpc.RpcError as e: + if attempt == 0 and with_lock and "CommandRequiresLock" in decode_grpc_error(e): + await self._relock() + continue + raise RuntimeError(f"{service}/{method}: {decode_grpc_error(e)}") from e + raise RuntimeError("unreachable") + + async def _call( + self, + service: str, + method: str, + request: bytes = b"", + with_lock: bool = False, + timeout: float = 30.0, + ) -> bytes: + channel = self.channel + metadata = self._lock_metadata() if with_lock else None + + def fn(): + rpc = channel.unary_unary( + f"/{service}/{method}", + request_serializer=lambda x: x, + response_deserializer=lambda x: x, + ) + return rpc(request, metadata=metadata, timeout=timeout) + + return await self._rpc(service, method, fn, with_lock) + + async def _stream( + self, + service: str, + method: str, + request: bytes = b"", + with_lock: bool = False, + timeout: float = 300.0, + ) -> List[bytes]: + channel = self.channel + metadata = self._lock_metadata() if with_lock else None + + def fn(): + rpc = channel.unary_stream( + f"/{service}/{method}", + request_serializer=lambda x: x, + response_deserializer=lambda x: x, + ) + return list(rpc(request, metadata=metadata, timeout=timeout)) + + return await self._rpc(service, method, fn, with_lock) + + async def _lock(self) -> None: + assert self._lock_id is not None + await self._call(_LOCK_SVC, "LockServer", lock_server_params(self._lock_id, self._lock_timeout)) + self._locked = True + + async def _unlock(self) -> None: + assert self._lock_id is not None + await self._call(_LOCK_SVC, "UnlockServer", unlock_server_params(self._lock_id)) + self._locked = False + + async def _initialize(self) -> None: + await self._call(_INST_SVC, "Initialize", b"", with_lock=True) + + # -- lifecycle -- + + async def setup(self) -> None: + if not HAS_GRPC: + raise RuntimeError(f"grpcio is required for PicoDriver. Import error: {_GRPC_IMPORT_ERROR}") + self._channel = grpc.insecure_channel( + f"{self._host}:{self._port}", + options=[ + ("grpc.keepalive_time_ms", 10000), + ("grpc.max_receive_message_length", 64 * 1024 * 1024), + ], + ) + self._lock_id = "pylabrobot" + + try: + await self._unlock() + except (grpc.RpcError, RuntimeError): + pass + + await self._lock() + logger.info("PicoDriver: connected to %s:%d", self._host, self._port) + + installed_obj = await self._request_installed_objectives() + num_obj = len(installed_obj) + for pos, obj in self._objectives.items(): + if pos >= num_obj: + raise ValueError( + f"Objective position {pos} out of range (instrument has {num_obj} positions)" + ) + await self.change_objective(pos, obj.objective_id) + + installed_fc = await self._request_installed_filter_cubes() + num_fc = len(installed_fc) + for pos, mode in self._filter_cubes.items(): + if pos >= num_fc: + raise ValueError( + f"Filter cube position {pos} out of range (instrument has {num_fc} positions)" + ) + await self.change_filter_cube(pos, mode.filter_cube) + + async def stop(self) -> None: + if self._channel is not None: + if self._locked: + try: + await self._unlock() + except (grpc.RpcError, RuntimeError) as e: + logger.warning("PicoBackend: unlock failed during stop: %s", e) + self._channel.close() + self._channel = None + logger.info("PicoDriver: stopped") + + async def request_configuration(self) -> dict: + """Query the full instrument configuration.""" + raw = await self._call(_INST_SVC, "Get_InstrumentConfiguration", b"") + data: dict = json.loads(decode_sila_string_response(raw)) + return dict(data.get("InstrumentConfiguration", data)) + + # -- door -- + + @property + def door_open(self) -> bool: + return self._door_open + + async def open_door(self) -> None: + await self._initialize() + await self._call(_HW_SVC, "OpenPlateDrawer", b"", True) + self._door_open = True + + async def close_door(self) -> None: + await self._initialize() + await self._call(_HW_SVC, "ClosePlateDrawer", b"", True) + self._door_open = False + + # -- objectives & filter cubes -- + + async def _request_installed_objectives(self) -> List[dict]: + raw = await self._call(_OBJ_SVC, "Get_InstalledObjectives", b"") + data: dict = json.loads(decode_sila_string_response(raw)) + return list(data.get("objectivesData", [])) + + async def _request_installed_filter_cubes(self) -> List[dict]: + raw = await self._call(_FC_SVC, "Get_InstalledFilterCubes", b"") + data: dict = json.loads(decode_sila_string_response(raw)) + return list(data.get("filterCubesData", [])) + + async def request_available_objectives(self, position: int) -> List[dict]: + params = json.dumps({"Index": position}) + req = length_delimited(1, sila_string(params)) + raw = await self._call(_OBJ_SVC, "GetAvailableObjectivesForPosition", req, True) + data: dict = json.loads(decode_sila_string_response(raw)) + return list(data.get("objectives", data.get("Objectives", []))) + + async def request_available_filter_cubes(self) -> List[dict]: + raw = await self._call(_FC_SVC, "Get_CompatibleFilterCubes", b"") + data: dict = json.loads(decode_sila_string_response(raw)) + return list(data.get("filterCubes", data.get("FilterCubes", []))) + + async def change_objective(self, position: int, objective_id: str) -> None: + available = await self.request_available_objectives(position) + valid_ids = [obj.get("Id", obj.get("id")) for obj in available] + if objective_id not in valid_ids: + raise ValueError( + f"Objective {objective_id!r} is not compatible with position {position}. " + f"Valid IDs: {valid_ids}" + ) + params = json.dumps({"Id": objective_id, "Index": position}) + req = length_delimited(1, sila_string(params)) + await self._call(_OBJ_SVC, "ChangeHardware", req, True) + + async def change_filter_cube(self, position: int, filter_cube_id: str) -> None: + available = await self.request_available_filter_cubes() + valid_ids = [fc.get("Id", fc.get("id")) for fc in available] + if filter_cube_id not in valid_ids: + raise ValueError( + f"Filter cube {filter_cube_id!r} is not compatible with this instrument. " + f"Valid IDs: {valid_ids}" + ) + params = json.dumps({"Id": filter_cube_id, "Index": position}) + req = length_delimited(1, sila_string(params)) + await self._call(_FC_SVC, "ChangeHardware", req, True) + + async def enter_objective_maintenance(self, position: int) -> None: + if self.door_open: + raise RuntimeError("Cannot enter objective maintenance while the plate drawer is open.") + params = json.dumps({"Index": position}) + req = length_delimited(1, sila_string(params)) + await self._initialize() + await self._call(_OBJ_SVC, "EnterObjectiveMaintenance", req, True) + + async def exit_objective_maintenance(self) -> None: + await self._call(_OBJ_SVC, "ExitObjectiveMaintenance", b"", True) + + # -- imaging -- + + async def _snap_images(self, labware_params: dict, snap_params: dict) -> List[dict]: + labware_json = json.dumps(labware_params) + snap_json = json.dumps(snap_params) + + await self._initialize() + + request = _snap_images_params(labware_json, snap_json) + confirmation_raw = await self._call( + _SNAP_SVC, "SnapImages", request, with_lock=True, timeout=60.0 + ) + exec_uuid = decode_command_confirmation(confirmation_raw) + logger.debug("SnapImages exec UUID: %s", exec_uuid[:8]) + + uuid_request = command_execution_uuid(exec_uuid) + chunks: Dict[int, Dict[int, bytes]] = defaultdict(dict) + checksums: Dict[int, int] = {} + + for response_raw in await self._stream( + _SNAP_SVC, + "SnapImages_Intermediate", + uuid_request, + with_lock=True, + timeout=300.0, + ): + chunk_data, meta = _decode_intermediate_response(response_raw) + chunks[meta["blob_index"]][meta["packet_index"]] = chunk_data + checksums[meta["blob_index"]] = meta["blob_checksum"] + + await self._call(_SNAP_SVC, "SnapImages_Result", uuid_request, with_lock=True, timeout=60.0) + + images = [] + for blob_idx in sorted(chunks.keys()): + blob_chunks = chunks[blob_idx] + reassembled = b"".join(blob_chunks[k] for k in sorted(blob_chunks.keys())) + + md5_digest = hashlib.md5(reassembled, usedforsecurity=False).digest() + computed = struct.unpack(" ImagingResult: + logger.info( + "[Pico %s:%s] capture: row=%d, col=%d, mode=%s", + self._host, + self._port, + row, + column, + mode, + ) + light_channel = mode.light_channel + filter_cube = mode.filter_cube + excitation_source = mode.excitation_source + objective_str = objective.objective_id + + if objective not in self._objectives.values(): + raise ValueError( + f"Objective {objective!r} is not configured. " + f"Configured objectives: {dict(self._objectives)}" + ) + if filter_cube and mode not in self._filter_cubes.values(): + raise ValueError( + f"Imaging mode {mode!r} is not configured. " + f"Configured filter cubes: {dict(self._filter_cubes)}" + ) + + # Convert exposure: PLR uses ms, Pico uses microseconds + if isinstance(exposure_time, (int, float)): + exposure_us = int(exposure_time * 1000) + do_auto_exposure = False + else: + exposure_us = 10000 + do_auto_exposure = True + + # Convert focal height: PLR uses mm, Pico uses um + if isinstance(focal_height, (int, float)): + base_z_um = focal_height * 1000.0 + skip_autofocus = True + else: + base_z_um = 0.0 + skip_autofocus = False + + snap_params = _default_snap_params( + well_row=row, + well_col=column, + exposure_us=exposure_us, + light_channel=light_channel, + filter_cube=filter_cube, + excitation_source=excitation_source, + objective=objective_str, + ) + snap_params["imagesChannelParameters"][0]["doAutoExposure"] = do_auto_exposure + snap_params["skipAutofocus"] = skip_autofocus + snap_params["focusSettings"]["baseZPositionUm"] = base_z_um + + labware_params = _labware_params_from_plate(plate) + images = await self._snap_images(labware_params, snap_params) + + result_images: List = [] + actual_exposure_us = exposure_us + for snap_event in images: + image_buffer = _extract_image_buffer(snap_event) + if image_buffer is None: + info = _get_image_info(snap_event) + if info.get("no_image_data"): + logger.debug("Skipping autofocus-only frame") + else: + logger.warning("Could not extract image buffer from snap event") + continue + + info = _get_image_info(snap_event) + actual_exposure_us = info.get("exposure_time_us", exposure_us) + arr = _buffer_to_ndarray(image_buffer, info["width"], info["height"]) + result_images.append(arr) + + return ImagingResult( + images=result_images, + exposure_time=actual_exposure_us / 1000.0, + focal_height=focal_height if isinstance(focal_height, (int, float)) else 0.0, + ) diff --git a/pylabrobot/molecular_devices/imageXpress/pico/tests/__init__.py b/pylabrobot/molecular_devices/imageXpress/pico/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/molecular_devices/imageXpress/pico/tests/pico_tests.py b/pylabrobot/molecular_devices/imageXpress/pico/tests/pico_tests.py new file mode 100644 index 00000000000..055134cc741 --- /dev/null +++ b/pylabrobot/molecular_devices/imageXpress/pico/tests/pico_tests.py @@ -0,0 +1,823 @@ +import base64 +import hashlib +import json +import struct +import unittest +from typing import Dict, List, Tuple +from unittest.mock import patch + +import pytest + +pytest.importorskip("numpy") +pytest.importorskip("grpc") + +import numpy as np # type: ignore[import-not-found] + +from pylabrobot.io.sila.grpc import ( + decode_fields, + get_field_bytes, + length_delimited, + sila_string, + varint_field, +) +from pylabrobot.molecular_devices.imageXpress.pico.models import ImagingMode, Objective +from pylabrobot.molecular_devices.imageXpress.pico.pico import ( + _FC_SVC, + _HW_SVC, + _INST_SVC, + _LOCK_META_KEY, + _LOCK_SVC, + _OBJ_SVC, + _SNAP_SVC, + Pico, + _decode_intermediate_response, + _extract_image_buffer, + _get_image_info, +) +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.utils import create_ordered_items_2d +from pylabrobot.resources.well import Well, WellBottomType + +# --------------------------------------------------------------------------- +# Test plate fixture +# --------------------------------------------------------------------------- + + +def _test_plate() -> Plate: + """Create a 96-well plate with known geometry for testing. + + dx/dy are LFB (left-front-bottom of well bounding box). Pico dist2first* + values are center-based, so the expected centers are dx + size/2 and + dy + size/2: 10.9 + 3.4 = 14.3, 7.96 + 3.4 = 11.36. + """ + return Plate( + name="test_plate", + size_x=127.6, + size_y=85.75, + size_z=13.83, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=12, + num_items_y=8, + dx=10.9, + dy=7.96, + dz=1.5, + item_dx=9.0, + item_dy=9.0, + size_x=6.8, + size_y=6.8, + size_z=10.67, + bottom_type=WellBottomType.FLAT, + material_z_thickness=0.17, + max_volume=350.0, + ), + ) + + +# --------------------------------------------------------------------------- +# Helpers: build fake SiLA protobuf responses +# --------------------------------------------------------------------------- + + +def _sila_string_response(value: str) -> bytes: + """Wrap a string as a SiLA String response: field 1 -> SiLA String(field 1 -> utf8).""" + return length_delimited(1, sila_string(value)) + + +def _sila_integer_msg(value: int) -> bytes: + return varint_field(1, value) + + +def _intermediate_response( + chunk_data: bytes, + blob_index: int = 0, + blob_checksum: int = 0, + packet_count: int = 1, + packet_index: int = 0, +) -> bytes: + """Build a fake SnapImages_Intermediate protobuf response.""" + binary_msg = length_delimited(1, chunk_data) + metadata_struct = ( + length_delimited(1, _sila_integer_msg(blob_index)) + + length_delimited(2, _sila_integer_msg(blob_checksum)) + + length_delimited(3, _sila_integer_msg(packet_count)) + + length_delimited(4, _sila_integer_msg(packet_index)) + ) + snap_event = length_delimited(1, metadata_struct) + return length_delimited(1, binary_msg) + length_delimited(2, snap_event) + + +# --------------------------------------------------------------------------- +# Mock gRPC channel that records calls with metadata +# --------------------------------------------------------------------------- + + +class _Call: + """A recorded gRPC call.""" + + __slots__ = ("path", "request", "metadata", "timeout") + + def __init__(self, path: str, request: bytes, metadata, timeout): + self.path = path + self.request = request + self.metadata = metadata + self.timeout = timeout + + @property + def has_lock_metadata(self) -> bool: + if self.metadata is None: + return False + return any(k == _LOCK_META_KEY for k, _ in self.metadata) + + +class _MockChannel: + """Mock gRPC channel that records every call with full context.""" + + def __init__(self): + self.calls: List[_Call] = [] + self.responses: Dict[str, bytes] = {} + self.stream_responses: Dict[str, list] = {} + self.closed = False + + def close(self): + self.closed = True + + def set_response(self, path: str, response: bytes): + self.responses[path] = response + + def set_stream_response(self, path: str, responses: list): + self.stream_responses[path] = responses + + def unary_unary(self, path, request_serializer=None, response_deserializer=None): + def handler(request, metadata=None, timeout=None): + self.calls.append(_Call(path, request, metadata, timeout)) + if path in self.responses: + return self.responses[path] + return b"" + + return handler + + def unary_stream(self, path, request_serializer=None, response_deserializer=None): + def handler(request, metadata=None, timeout=None): + self.calls.append(_Call(path, request, metadata, timeout)) + return iter(self.stream_responses.get(path, [])) + + return handler + + def get_calls(self, path: str) -> List[_Call]: + return [c for c in self.calls if c.path == path] + + +def _make_backend( + objectives=None, + filter_cubes=None, + lock_timeout=3600, +) -> Tuple[Pico, _MockChannel]: + """Create a PicoBackend with a mock channel, bypassing setup().""" + backend = Pico( + host="127.0.0.1", + port=8091, + lock_timeout=lock_timeout, + objectives=objectives or {}, + filter_cubes=filter_cubes or {}, + ) + channel = _MockChannel() + backend._channel = channel + backend._lock_id = "pylabrobot" + backend._locked = True + return backend, channel + + +def _decode_sila_string_from_request(data: bytes) -> str: + """Decode the SiLA String from field 1 of a request message.""" + fields = decode_fields(data) + sila_str_msg = get_field_bytes(fields, 1) + assert sila_str_msg is not None + inner = decode_fields(sila_str_msg) + value = get_field_bytes(inner, 1) + assert value is not None + return value.decode("utf-8") + + +def _unwrap_sila_string(data: bytes) -> str: + """Unwrap a raw SiLA String message (field 1 = utf8 bytes).""" + fields = decode_fields(data) + value = get_field_bytes(fields, 1) + assert value is not None + return value.decode("utf-8") + + +# --------------------------------------------------------------------------- +# Tests: setup() command sequence +# --------------------------------------------------------------------------- + + +class TestSetup(unittest.IsolatedAsyncioTestCase): + async def test_setup_sends_correct_sequence(self): + """setup() with no objectives/filter_cubes: unlock stale, lock, query hardware.""" + backend = Pico(host="127.0.0.1", lock_timeout=120) + channel = _MockChannel() + + channel.set_response(f"/{_LOCK_SVC}/UnlockServer", b"") + channel.set_response(f"/{_LOCK_SVC}/LockServer", b"") + channel.set_response( + f"/{_OBJ_SVC}/Get_InstalledObjectives", + _sila_string_response(json.dumps({"objectivesData": []})), + ) + channel.set_response( + f"/{_FC_SVC}/Get_InstalledFilterCubes", + _sila_string_response(json.dumps({"filterCubesData": []})), + ) + + with patch("grpc.insecure_channel", return_value=channel): + await backend.setup() + + self.assertEqual(len(channel.calls), 4) + self.assertEqual(channel.calls[0].path, f"/{_LOCK_SVC}/UnlockServer") + self.assertEqual(channel.calls[1].path, f"/{_LOCK_SVC}/LockServer") + self.assertEqual(channel.calls[2].path, f"/{_OBJ_SVC}/Get_InstalledObjectives") + self.assertEqual(channel.calls[3].path, f"/{_FC_SVC}/Get_InstalledFilterCubes") + + # Unlock request contains lock ID + self.assertEqual(_decode_sila_string_from_request(channel.calls[0].request), "pylabrobot") + + # Lock request contains lock ID "pylabrobot" + self.assertEqual(_decode_sila_string_from_request(channel.calls[1].request), "pylabrobot") + + async def test_setup_configures_objectives_and_filter_cubes(self): + """When objectives/filter_cubes are specified, setup() calls ChangeHardware.""" + backend = Pico( + host="127.0.0.1", + objectives={0: Objective.O_4X_PL_FL}, + filter_cubes={0: ImagingMode.DAPI}, + ) + channel = _MockChannel() + + channel.set_response(f"/{_LOCK_SVC}/UnlockServer", b"") + channel.set_response(f"/{_LOCK_SVC}/LockServer", b"") + channel.set_response( + f"/{_OBJ_SVC}/Get_InstalledObjectives", + _sila_string_response(json.dumps({"objectivesData": [{"Id": "PL FLUOTAR 4x/0.13"}]})), + ) + channel.set_response( + f"/{_FC_SVC}/Get_InstalledFilterCubes", + _sila_string_response(json.dumps({"filterCubesData": [{"Id": "DAPI"}]})), + ) + # get_available_objectives / get_available_filter_cubes for validation + channel.set_response( + f"/{_OBJ_SVC}/GetAvailableObjectivesForPosition", + _sila_string_response(json.dumps({"objectives": [{"Id": "PL FLUOTAR 4x/0.13"}]})), + ) + channel.set_response( + f"/{_FC_SVC}/Get_CompatibleFilterCubes", + _sila_string_response(json.dumps({"filterCubes": [{"Id": "DAPI"}]})), + ) + channel.set_response(f"/{_OBJ_SVC}/ChangeHardware", b"") + channel.set_response(f"/{_FC_SVC}/ChangeHardware", b"") + + with patch("grpc.insecure_channel", return_value=channel): + await backend.setup() + + # Verify ChangeHardware was called with correct JSON params + obj_change_calls = channel.get_calls(f"/{_OBJ_SVC}/ChangeHardware") + self.assertEqual(len(obj_change_calls), 1) + obj_params = json.loads(_decode_sila_string_from_request(obj_change_calls[0].request)) + self.assertEqual(obj_params["Id"], "PL FLUOTAR 4x/0.13") + self.assertEqual(obj_params["Index"], 0) + + fc_change_calls = channel.get_calls(f"/{_FC_SVC}/ChangeHardware") + self.assertEqual(len(fc_change_calls), 1) + fc_params = json.loads(_decode_sila_string_from_request(fc_change_calls[0].request)) + self.assertEqual(fc_params["Id"], "DAPI") + self.assertEqual(fc_params["Index"], 0) + + +# --------------------------------------------------------------------------- +# Tests: stop() command sequence +# --------------------------------------------------------------------------- + + +class TestStop(unittest.IsolatedAsyncioTestCase): + async def test_stop_sends_unlock(self): + backend, channel = _make_backend() + + await backend.stop() + + self.assertEqual(len(channel.calls), 1) + self.assertEqual(channel.calls[0].path, f"/{_LOCK_SVC}/UnlockServer") + self.assertEqual(_decode_sila_string_from_request(channel.calls[0].request), "pylabrobot") + self.assertTrue(channel.closed) + + +# --------------------------------------------------------------------------- +# Tests: door control commands +# --------------------------------------------------------------------------- + + +class TestDoorCommands(unittest.IsolatedAsyncioTestCase): + async def test_open_door(self): + backend, channel = _make_backend() + channel.set_response(f"/{_INST_SVC}/Initialize", b"") + channel.set_response(f"/{_HW_SVC}/OpenPlateDrawer", b"") + + await backend.open_door() + + self.assertEqual(len(channel.calls), 2) + self.assertEqual(channel.calls[0].path, f"/{_INST_SVC}/Initialize") + self.assertTrue(channel.calls[0].has_lock_metadata) + self.assertEqual(channel.calls[1].path, f"/{_HW_SVC}/OpenPlateDrawer") + self.assertTrue(channel.calls[1].has_lock_metadata) + self.assertTrue(backend.door_open) + + async def test_close_door(self): + backend, channel = _make_backend() + backend._door_open = True + channel.set_response(f"/{_INST_SVC}/Initialize", b"") + channel.set_response(f"/{_HW_SVC}/ClosePlateDrawer", b"") + + await backend.close_door() + + self.assertEqual(len(channel.calls), 2) + self.assertEqual(channel.calls[0].path, f"/{_INST_SVC}/Initialize") + self.assertTrue(channel.calls[0].has_lock_metadata) + self.assertEqual(channel.calls[1].path, f"/{_HW_SVC}/ClosePlateDrawer") + self.assertTrue(channel.calls[1].has_lock_metadata) + self.assertFalse(backend.door_open) + + +# --------------------------------------------------------------------------- +# Tests: objective maintenance commands +# --------------------------------------------------------------------------- + + +class TestObjectiveMaintenanceCommands(unittest.IsolatedAsyncioTestCase): + async def test_enter_maintenance(self): + backend, channel = _make_backend() + channel.set_response(f"/{_INST_SVC}/Initialize", b"") + channel.set_response(f"/{_OBJ_SVC}/EnterObjectiveMaintenance", b"") + + await backend.enter_objective_maintenance(2) + + self.assertEqual(len(channel.calls), 2) + self.assertEqual(channel.calls[0].path, f"/{_INST_SVC}/Initialize") + self.assertEqual(channel.calls[1].path, f"/{_OBJ_SVC}/EnterObjectiveMaintenance") + self.assertTrue(channel.calls[1].has_lock_metadata) + params = json.loads(_decode_sila_string_from_request(channel.calls[1].request)) + self.assertEqual(params, {"Index": 2}) + + async def test_exit_maintenance(self): + backend, channel = _make_backend() + channel.set_response(f"/{_OBJ_SVC}/ExitObjectiveMaintenance", b"") + + await backend.exit_objective_maintenance() + + self.assertEqual(len(channel.calls), 1) + self.assertEqual(channel.calls[0].path, f"/{_OBJ_SVC}/ExitObjectiveMaintenance") + self.assertEqual(channel.calls[0].request, b"") + self.assertTrue(channel.calls[0].has_lock_metadata) + + +# --------------------------------------------------------------------------- +# Tests: request_configuration command + response decoding +# --------------------------------------------------------------------------- + + +class TestGetConfiguration(unittest.IsolatedAsyncioTestCase): + async def test_decodes_instrument_configuration(self): + backend, channel = _make_backend() + config = { + "InstrumentConfiguration": { + "objectivesComponent": {"objectives": [{"Id": "4x", "Magnification": 4}]}, + "filterCubesComponent": {"filterCubes": [{"Id": "DAPI"}]}, + } + } + channel.set_response( + f"/{_INST_SVC}/Get_InstrumentConfiguration", + _sila_string_response(json.dumps(config)), + ) + + result = await backend.request_configuration() + + self.assertEqual(len(channel.calls), 1) + self.assertEqual(channel.calls[0].path, f"/{_INST_SVC}/Get_InstrumentConfiguration") + self.assertEqual(channel.calls[0].request, b"") + # Response unwrapped from SiLA String → JSON → InstrumentConfiguration key extracted + self.assertEqual(result, config["InstrumentConfiguration"]) + + +# --------------------------------------------------------------------------- +# Tests: change_objective / change_filter_cube commands +# --------------------------------------------------------------------------- + + +class TestChangeHardwareCommands(unittest.IsolatedAsyncioTestCase): + async def test_change_objective(self): + backend, channel = _make_backend() + available = [{"Id": "PL FLUOTAR 4x/0.13"}, {"Id": "PL FLUOTAR 10x/0.30"}] + channel.set_response( + f"/{_OBJ_SVC}/GetAvailableObjectivesForPosition", + _sila_string_response(json.dumps({"objectives": available})), + ) + channel.set_response(f"/{_OBJ_SVC}/ChangeHardware", b"") + + await backend.change_objective(1, "PL FLUOTAR 10x/0.30") + + # Query available, then change + self.assertEqual(len(channel.calls), 2) + self.assertEqual(channel.calls[0].path, f"/{_OBJ_SVC}/GetAvailableObjectivesForPosition") + query_params = json.loads(_decode_sila_string_from_request(channel.calls[0].request)) + self.assertEqual(query_params, {"Index": 1}) + self.assertTrue(channel.calls[0].has_lock_metadata) + + self.assertEqual(channel.calls[1].path, f"/{_OBJ_SVC}/ChangeHardware") + change_params = json.loads(_decode_sila_string_from_request(channel.calls[1].request)) + self.assertEqual(change_params, {"Id": "PL FLUOTAR 10x/0.30", "Index": 1}) + self.assertTrue(channel.calls[1].has_lock_metadata) + + async def test_change_objective_rejects_invalid_id(self): + backend, channel = _make_backend() + channel.set_response( + f"/{_OBJ_SVC}/GetAvailableObjectivesForPosition", + _sila_string_response(json.dumps({"objectives": [{"Id": "4x"}]})), + ) + + with self.assertRaises(ValueError) as ctx: + await backend.change_objective(0, "INVALID") + self.assertIn("not compatible", str(ctx.exception)) + + # Only the query was sent, ChangeHardware was NOT called + self.assertEqual(len(channel.calls), 1) + self.assertEqual(channel.calls[0].path, f"/{_OBJ_SVC}/GetAvailableObjectivesForPosition") + + async def test_change_filter_cube(self): + backend, channel = _make_backend() + channel.set_response( + f"/{_FC_SVC}/Get_CompatibleFilterCubes", + _sila_string_response(json.dumps({"filterCubes": [{"Id": "DAPI"}, {"Id": "FITC"}]})), + ) + channel.set_response(f"/{_FC_SVC}/ChangeHardware", b"") + + await backend.change_filter_cube(1, "FITC") + + self.assertEqual(len(channel.calls), 2) + self.assertEqual(channel.calls[0].path, f"/{_FC_SVC}/Get_CompatibleFilterCubes") + self.assertEqual(channel.calls[1].path, f"/{_FC_SVC}/ChangeHardware") + params = json.loads(_decode_sila_string_from_request(channel.calls[1].request)) + self.assertEqual(params, {"Id": "FITC", "Index": 1}) + self.assertTrue(channel.calls[1].has_lock_metadata) + + +# --------------------------------------------------------------------------- +# Tests: capture() — full command flow + response decoding +# --------------------------------------------------------------------------- + + +class TestCapture(unittest.IsolatedAsyncioTestCase): + def _setup_capture_channel( + self, + channel: _MockChannel, + exec_uuid: str, + snap_event_json: dict, + ): + """Wire up the mock channel for a complete capture() flow.""" + channel.set_response(f"/{_INST_SVC}/Initialize", b"") + channel.set_response(f"/{_SNAP_SVC}/SnapImages", _sila_string_response(exec_uuid)) + channel.set_response(f"/{_SNAP_SVC}/SnapImages_Result", b"") + + blob_bytes = json.dumps(snap_event_json).encode("utf-8") + md5_digest = hashlib.md5(blob_bytes).digest() + checksum = struct.unpack(" 5.0ms + self.assertAlmostEqual(result.focal_height, 1.0) + + async def test_capture_multi_chunk_reassembly(self): + """Verify image data is correctly reassembled from multiple chunks.""" + + backend, channel = _make_backend( + objectives={0: Objective.O_4X_PL_FL}, + filter_cubes={0: ImagingMode.DAPI}, + ) + + pixel_data = np.array([[10, 20], [30, 40]], dtype=np.uint16) + snap_event = { + "imageData": { + "imageBuffer": base64.b64encode(pixel_data.tobytes()).decode(), + "imageBufferInfo": {"width": 2, "height": 2}, + "capturedImageInfo": {"exposureTimeUs": 1000}, + }, + } + blob_bytes = json.dumps(snap_event).encode("utf-8") + md5_digest = hashlib.md5(blob_bytes).digest() + checksum = struct.unpack("" +COMMAND_TERMINATORS: Dict[str, int] = { + "!AUTOFILTER": 1, + "!AUTOPMT": 1, + "!BAUD": 1, + "!CALIBRATE": 1, + "!CANCEL": 1, + "!CLEAR": 1, + "!CLOSE": 1, + "!CSPEED": 1, + "!REFERENCE": 1, + "!EMFILTER": 1, + "!EMWAVELENGTH": 1, + "!ERROR": 2, + "!EXWAVELENGTH": 1, + "!FPW": 1, + "!INIT": 1, + "!MODE": 1, + "!NVRAM": 1, + "!OPEN": 1, + "!ORDER": 1, + "OPTION": 2, + "!AIR_CAL": 1, + "!PMT": 1, + "!PMTCAL": 1, + "!QUEUE": 2, + "!READ": 1, + "!TOP": 1, + "!READSTAGE": 2, + "!READTYPE": 2, + "!RESEND": 1, + "!RESET": 1, + "!SHAKE": 1, + "!SPEED": 2, + "!STATUS": 2, + "!STRIP": 1, + "!TAG": 1, + "!TEMP": 2, + "!TRANSFER": 2, + "!USER_NUMBER": 2, + "!XPOS": 1, + "!YPOS": 1, + "!WAVELENGTH": 1, + "!WELLSCANMODE": 2, + "!PATHCAL": 2, + "!COUNTTIME": 1, + "!COUNTTIMEDELAY": 1, +} + + +MolecularDevicesResponse = List[str] + + +# --------------------------------------------------------------------------- +# Enums & settings dataclasses +# --------------------------------------------------------------------------- + + +# The read mode of the plate reader. Selects which read path runs; never sent to the device +# directly, so it needs no wire mapping. +ReadMode = Literal["absorbance", "fluorescence", "luminescence", "polarization", "time_resolved"] + +# The type of read to perform. +ReadType = Literal["endpoint", "kinetic", "spectrum", "well_scan"] +READ_TYPE_TO_WIRE: Dict[str, str] = { + "endpoint": "ENDPOINT", + "kinetic": "KINETIC", + "spectrum": "SPECTRUM", + "well_scan": "WELLSCAN", +} + +# The order in which to read the plate wells. +ReadOrder = Literal["column", "wavelength"] +READ_ORDER_TO_WIRE: Dict[str, str] = {"column": "COLUMN", "wavelength": "WAVELENGTH"} + +# The calibration mode for the read. +Calibrate = Literal["on", "once", "off"] +CALIBRATE_TO_WIRE: Dict[str, str] = {"on": "ON", "once": "ONCE", "off": "OFF"} + +# The speed of the plate carriage. +CarriageSpeed = Literal["normal", "slow"] +CARRIAGE_SPEED_TO_WIRE: Dict[str, str] = {"normal": "8", "slow": "1"} + +# The photomultiplier tube gain setting. +PmtGain = Literal["auto", "high", "medium", "low"] +PMT_GAIN_TO_WIRE: Dict[str, str] = { + "auto": "ON", + "high": "HIGH", + "medium": "MED", + "low": "LOW", +} + + +@dataclass +class ShakeSettings: + """Settings for shaking the plate during a read.""" + + before_read: bool = False + before_read_duration: int = 0 + between_reads: bool = False + between_reads_duration: int = 0 + + +@dataclass +class KineticSettings: + """Settings for kinetic reads.""" + + interval: int + num_readings: int + + +@dataclass +class SpectrumSettings: + """Settings for spectrum reads.""" + + start_wavelength: int + step: int + num_steps: int + excitation_emission_type: Optional[Literal["EXSPECTRUM", "EMSPECTRUM"]] = None + + +@dataclass +class MolecularDevicesSettings: + """A comprehensive, internal container for all plate reader settings.""" + + plate: Plate = field(repr=False) + read_mode: ReadMode + read_type: ReadType + read_order: ReadOrder + calibrate: Calibrate + shake_settings: Optional[ShakeSettings] + carriage_speed: CarriageSpeed + speed_read: bool + kinetic_settings: Optional[KineticSettings] + spectrum_settings: Optional[SpectrumSettings] + wavelengths: List[Union[int, Tuple[int, bool]]] = field(default_factory=list) + excitation_wavelengths: List[int] = field(default_factory=list) + emission_wavelengths: List[int] = field(default_factory=list) + cutoff_filters: List[int] = field(default_factory=list) + path_check: bool = False + read_from_bottom: bool = False + pmt_gain: Union[PmtGain, int] = "auto" + flashes_per_well: int = 1 + cuvette: bool = False + settling_time: int = 0 + + +# --------------------------------------------------------------------------- +# Driver — serial I/O and device-level operations +# --------------------------------------------------------------------------- + + +class MolecularDevicesPlateReader: + """Serial driver for Molecular Devices plate readers. + + Owns the serial connection, command protocol, and device-level operations + (open/close tray, status, error log, shake). + """ + + def __init__( + self, port: str, human_readable_device_name: str = "Molecular Devices Plate Reader" + ) -> None: + super().__init__() + self.port = port + self.io = Serial( + human_readable_device_name=human_readable_device_name, + port=self.port, + baudrate=9600, + timeout=0.2, + ) + + async def setup(self) -> None: + await self.io.setup() + await self.send_command("!") + logger.info("[SpectraMax %s] connected", self.port) + + async def stop(self) -> None: + await self.io.stop() + logger.info("[SpectraMax %s] disconnected", self.port) + + async def send_command( + self, command: str, timeout: int = 60, num_res_fields=None + ) -> MolecularDevicesResponse: + """Send a command and receive the response, automatically determining the number of + response fields. + """ + base_command = command.split(" ")[0] + if num_res_fields is None: + num_res_fields = COMMAND_TERMINATORS.get(base_command, 1) + else: + num_res_fields = max(1, num_res_fields) + + await self.io.write(command.encode() + b"\r") + raw_response = b"" + timeout_time = time.time() + timeout + while True: + raw_response += await self.io.readline() + await asyncio.sleep(0.001) + if time.time() > timeout_time: + logger.error( + "[SpectraMax %s] timeout waiting for response to command: %s", self.port, command + ) + raise TimeoutError(f"Timeout waiting for response to command: {command}") + if raw_response.count(RES_TERM_CHAR) >= num_res_fields: + break + response = raw_response.decode("utf-8", errors="replace").strip().split(RES_TERM_CHAR.decode()) + response = [r.strip() for r in response if r.strip() != ""] + self._parse_basic_errors(response, command) + return response + + def _parse_basic_errors(self, response: List[str], command: str) -> None: + if not response: + logger.error("[SpectraMax %s] command '%s' returned empty response", self.port, command) + raise SpectraMaxError(f"Command '{command}' failed with empty response.") + + error_code_msg = response[0] if "FAIL" in response[0] else response[-1] + if "FAIL" in error_code_msg: + parts = error_code_msg.split("\t") + try: + error_code_str = parts[-1] + error_code = int(error_code_str.strip()) + if error_code in ERROR_CODES: + message, err_class = ERROR_CODES[error_code] + raise err_class(f"Command '{command}' failed with error {error_code}: {message}") + raise SpectraMaxError(f"Command '{command}' failed with unknown error code: {error_code}") + except (ValueError, IndexError): + raise SpectraMaxError(f"Command '{command}' failed with unparsable error: {response[0]}") + + if not any("OK" in r for r in response): + raise SpectraMaxError(f"Command '{command}' failed with response: {response}") + if "warning" in response[0].lower(): + logger.warning("[SpectraMax %s] warning for command '%s': %s", self.port, command, response) + + # -- device-level operations -- + + async def open(self) -> None: + """Open the plate tray.""" + await self.send_command("!OPEN") + + async def close(self) -> None: + """Close the plate tray.""" + await self.send_command("!CLOSE") + + async def request_status(self) -> List[str]: + """Get the current device status.""" + res = await self.send_command("!STATUS") + if len(res) > 1: + return res[1].split() + raise ValueError(f"Could not parse status from response: {res}") + + async def read_error_log(self) -> List[str]: + """Read the device error log.""" + res = await self.send_command("!ERROR") + if len(res) > 1: + return res[1].split() + raise ValueError(f"Could not parse error log from response: {res}") + + async def clear_error_log(self) -> None: + """Clear the device error log.""" + await self.send_command("!CLEAR ERROR") + + async def request_firmware_version(self) -> List[str]: + """Get the firmware version.""" + res = await self.send_command("!OPTION") + return res[1].split() + + async def start_shake(self) -> None: + """Start shaking.""" + await self.send_command("!SHAKE NOW") + + async def stop_shake(self) -> None: + """Stop shaking.""" + await self.send_command("!SHAKE STOP") + + async def wait_for_idle(self, timeout: int = 600): + """Wait for the plate reader to become idle.""" + start_time = time.time() + while True: + if time.time() - start_time > timeout: + logger.error("[SpectraMax %s] timeout waiting for idle after %ds", self.port, timeout) + raise TimeoutError("Timeout waiting for plate reader to become idle.") + status = await self.request_status() + if status and status[1] == "IDLE": + break + await asyncio.sleep(1) + + async def _read_now(self) -> None: + await self.send_command("!READ") + + async def _transfer_data(self, settings: MolecularDevicesSettings) -> List[Dict]: + """Transfer data from the plate reader.""" + + if (settings.read_type == "kinetic" and settings.kinetic_settings) or ( + settings.read_type == "spectrum" and settings.spectrum_settings + ): + if settings.kinetic_settings: + num_readings = settings.kinetic_settings.num_readings + elif settings.spectrum_settings: + num_readings = settings.spectrum_settings.num_steps + else: + raise ValueError("Kinetic or Spectrum settings must be provided for this read type.") + + all_reads = [] + for _ in range(num_readings): + res = await self.send_command("!TRANSFER") + data_str = res[1] + read_data = self._parse_data(data_str, settings) + all_reads.extend(read_data) + return all_reads + + res = await self.send_command("!TRANSFER") + data_str = res[1] + return self._parse_data(data_str, settings) + + def _parse_data(self, data_str: str, settings: MolecularDevicesSettings) -> List[Dict]: + lines = re.split(r"\r\n|\n", data_str.strip()) + lines = [line.strip() for line in lines if line.strip()] + + header_parts = lines[0].split("\t") + measurement_time = float(header_parts[0]) + temperature = float(header_parts[1]) + + line_idx = 1 + while line_idx < len(lines): + line = lines[line_idx] + if line.startswith("L:") and line_idx > 1: + break + line_idx += 1 + + data_collection = [] + cur_read_wavelengths = [] + data_columns: List[List[float]] = [] + for i in range(line_idx, len(lines)): + line = lines[i] + if line.startswith("L:"): + cur_read_wavelengths.append(line.split("\t")[1:]) + if i > line_idx and data_columns: + data_collection.append(data_columns) + data_columns = [] + match = re.match(r"^\s*(\d+):\s*(.*)", line) + if match: + values_str = re.split(r"\s+", match.group(2).strip()) + values = [] + for v in values_str: + if v.strip().replace(".", "", 1).isdigit(): + values.append(float(v.strip())) + elif v.strip() == "#SAT": + values.append(float("inf")) + else: + values.append(float("nan")) + data_columns.append(values) + if data_columns: + data_collection.append(data_columns) + + data_collection_transposed = [] + for data_columns in data_collection: + data_rows = [] + if data_columns: + num_rows = len(data_columns[0]) + num_cols = len(data_columns) + for i in range(num_rows): + row = [data_columns[j][i] for j in range(num_cols)] + data_rows.append(row) + data_collection_transposed.append(data_rows) + + measurements = [] + read_mode = settings.read_mode + for i, data_rows in enumerate(data_collection_transposed): + measurement = { + "data": data_rows, + "temperature": temperature, + "time": measurement_time, + } + if read_mode == "absorbance": + wl = int(cur_read_wavelengths[i][0]) + measurement["wavelength"] = wl + elif read_mode in ("fluorescence", "polarization", "time_resolved"): + ex_wl = int(cur_read_wavelengths[i][0]) + em_wl = int(cur_read_wavelengths[i][1]) + measurement["ex_wavelength"] = ex_wl + measurement["em_wavelength"] = em_wl + elif read_mode == "luminescence": + em_wl = int(cur_read_wavelengths[i][1]) + measurement["em_wavelength"] = em_wl + measurements.append(measurement) + + return measurements + + async def _set_clear(self) -> None: + await self.send_command("!CLEAR DATA") + + async def _set_mode(self, settings: MolecularDevicesSettings) -> None: + cmd = f"!MODE {READ_TYPE_TO_WIRE[settings.read_type]}" + if settings.read_type == "kinetic" and settings.kinetic_settings: + ks = settings.kinetic_settings + cmd += f" {ks.interval} {ks.num_readings}" + elif settings.read_type == "spectrum" and settings.spectrum_settings: + ss = settings.spectrum_settings + cmd = "!MODE" + scan_type = ss.excitation_emission_type or "SPECTRUM" + cmd += f" {scan_type} {ss.start_wavelength} {ss.step} {ss.num_steps}" + await self.send_command(cmd) + + async def _set_wavelengths(self, settings: MolecularDevicesSettings) -> None: + if settings.read_mode == "absorbance": + wl_parts = [] + for wl in settings.wavelengths: + wl_parts.append(f"F{wl[0]}" if isinstance(wl, tuple) and wl[1] else str(wl)) + wl_str = " ".join(wl_parts) + if settings.path_check: + wl_str += " 900 998" + await self.send_command(f"!WAVELENGTH {wl_str}") + elif settings.read_mode in ("fluorescence", "polarization", "time_resolved"): + ex_wl_str = " ".join(map(str, settings.excitation_wavelengths)) + em_wl_str = " ".join(map(str, settings.emission_wavelengths)) + await self.send_command(f"!EXWAVELENGTH {ex_wl_str}") + await self.send_command(f"!EMWAVELENGTH {em_wl_str}") + elif settings.read_mode == "luminescence": + wl_str = " ".join(map(str, settings.emission_wavelengths)) + await self.send_command(f"!EMWAVELENGTH {wl_str}") + else: + raise NotImplementedError(f"{settings.read_mode} not supported") + + async def _set_plate_position(self, settings: MolecularDevicesSettings) -> None: + plate = settings.plate + num_cols, num_rows, size_y = plate.num_items_x, plate.num_items_y, plate.get_size_y() + if num_cols < 2 or num_rows < 2: + raise ValueError("Plate must have at least 2 rows and 2 columns to calculate well spacing.") + top_left_well = plate.get_item(0) + if top_left_well.location is None: + raise ValueError("Top left well location is not set.") + top_left_well_center = top_left_well.location + top_left_well.get_anchor(x="c", y="c") + loc_A1 = plate.get_item("A1").location + loc_A2 = plate.get_item("A2").location + loc_B1 = plate.get_item("B1").location + if loc_A1 is None or loc_A2 is None or loc_B1 is None: + raise ValueError("Well locations for A1, A2, or B1 are not set.") + dx = loc_A2.x - loc_A1.x + dy = loc_A1.y - loc_B1.y + + x_pos_cmd = f"!XPOS {top_left_well_center.x:.3f} {dx:.3f} {num_cols}" + y_pos_cmd = f"!YPOS {size_y - top_left_well_center.y:.3f} {dy:.3f} {num_rows}" + await self.send_command(x_pos_cmd) + await self.send_command(y_pos_cmd) + + async def _set_strip(self, settings: MolecularDevicesSettings) -> None: + await self.send_command(f"!STRIP 1 {settings.plate.num_items_x}") + + async def _set_shake(self, settings: MolecularDevicesSettings) -> None: + if not settings.shake_settings: + await self.send_command("!SHAKE OFF") + return + ss = settings.shake_settings + shake_mode = "ON" if ss.before_read or ss.between_reads else "OFF" + before_duration = ss.before_read_duration if ss.before_read else 0 + ki = settings.kinetic_settings.interval if settings.kinetic_settings else 0 + if ss.between_reads and ki > 0: + between_duration = ss.between_reads_duration + wait_duration = ki - between_duration + else: + between_duration = 0 + wait_duration = 0 + await self.send_command(f"!SHAKE {shake_mode}") + await self.send_command(f"!SHAKE {before_duration} {ki} {wait_duration} {between_duration} 0") + + async def _set_carriage_speed(self, settings: MolecularDevicesSettings) -> None: + await self.send_command(f"!CSPEED {CARRIAGE_SPEED_TO_WIRE[settings.carriage_speed]}") + + async def _set_read_stage(self, settings: MolecularDevicesSettings) -> None: + if settings.read_mode in ("fluorescence", "luminescence", "polarization", "time_resolved"): + stage = "BOT" if settings.read_from_bottom else "TOP" + await self.send_command(f"!READSTAGE {stage}") + + async def _set_flashes_per_well(self, settings: MolecularDevicesSettings) -> None: + if settings.read_mode in ("fluorescence", "luminescence", "polarization", "time_resolved"): + await self.send_command(f"!FPW {settings.flashes_per_well}") + + async def _set_pmt(self, settings: MolecularDevicesSettings) -> None: + if settings.read_mode not in ("fluorescence", "luminescence", "polarization", "time_resolved"): + return + gain = settings.pmt_gain + if gain == "auto": + await self.send_command("!AUTOPMT ON") + else: + # gain is either one of the named settings or a raw numeric PMT value. + gain_val = PMT_GAIN_TO_WIRE[gain] if isinstance(gain, str) else gain + await self.send_command("!AUTOPMT OFF") + await self.send_command(f"!PMT {gain_val}") + + async def _set_filter(self, settings: MolecularDevicesSettings) -> None: + if ( + settings.read_mode in ("fluorescence", "polarization", "time_resolved") + and settings.cutoff_filters + ): + cf_str = " ".join(map(str, settings.cutoff_filters)) + await self.send_command("!AUTOFILTER OFF") + await self.send_command(f"!EMFILTER {cf_str}") + else: + await self.send_command("!AUTOFILTER ON") + + async def _set_calibrate(self, settings: MolecularDevicesSettings) -> None: + if settings.read_mode == "absorbance": + await self.send_command(f"!CALIBRATE {CALIBRATE_TO_WIRE[settings.calibrate]}") + else: + await self.send_command(f"!PMTCAL {CALIBRATE_TO_WIRE[settings.calibrate]}") + + async def _set_order(self, settings: MolecularDevicesSettings) -> None: + await self.send_command(f"!ORDER {READ_ORDER_TO_WIRE[settings.read_order]}") + + async def _set_speed(self, settings: MolecularDevicesSettings) -> None: + if settings.read_mode == "absorbance": + mode = "ON" if settings.speed_read else "OFF" + await self.send_command(f"!SPEED {mode}") + + async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: + if settings.read_mode == "polarization": + command = "FPSETTLETIME" + value = settings.settling_time + else: + command = "CARCOL" + value = settings.settling_time if settings.settling_time > 100 else 100 + await self.send_command(f"!NVRAM {command} {value}") + + async def _set_tag(self, settings: MolecularDevicesSettings) -> None: + if settings.read_mode == "polarization" and settings.read_type == "kinetic": + await self.send_command("!TAG ON") + else: + await self.send_command("!TAG OFF") + + async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: + """Set the READTYPE command and the expected number of response fields.""" + cuvette = settings.cuvette + num_res_fields = COMMAND_TERMINATORS.get("!READTYPE", 2) + + if settings.read_mode == "absorbance": + cmd = f"!READTYPE ABS{'CUV' if cuvette else 'PLA'}" + elif settings.read_mode == "fluorescence": + cmd = f"!READTYPE FLU{'CUV' if cuvette else ''}" + num_res_fields = 2 if cuvette else 1 + elif settings.read_mode == "luminescence": + cmd = f"!READTYPE LUM{'CUV' if cuvette else ''}" + num_res_fields = 2 if cuvette else 1 + elif settings.read_mode == "polarization": + cmd = "!READTYPE POLAR" + num_res_fields = 1 + elif settings.read_mode == "time_resolved": + cmd = "!READTYPE TIME 0 250" + num_res_fields = 1 + else: + raise ValueError(f"Unsupported read mode: {settings.read_mode}") + + await self.send_command(cmd, num_res_fields=num_res_fields) + + async def _set_integration_time( + self, settings: MolecularDevicesSettings, delay_time: int, integration_time: int + ) -> None: + if settings.read_mode == "time_resolved": + await self.send_command(f"!COUNTTIMEDELAY {delay_time}") + await self.send_command(f"!COUNTTIME {integration_time * 0.001}") + + def _get_cutoff_filter_index_from_wavelength(self, wavelength: int) -> int: + """Converts a wavelength to a cutoff filter index.""" + FILTERS = [ + (0, 322, 1), + (325, 415, 16), + (420, 435, 2), + (435, 455, 3), + (455, 475, 4), + (475, 495, 5), + (495, 515, 6), + (515, 530, 7), + (530, 550, 8), + (550, 570, 9), + (570, 590, 10), + (590, 610, 11), + (610, 630, 12), + (630, 665, 13), + (665, 695, 14), + (695, 900, 15), + ] + for min_wl, max_wl, cutoff_filter_index in FILTERS: + if min_wl <= wavelength < max_wl: + return cutoff_filter_index + raise ValueError(f"No cutoff filter found for wavelength {wavelength}") + + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + wavelengths: Optional[List[Union[int, Tuple[int, bool]]]] = None, + read_type: ReadType = "endpoint", + read_order: ReadOrder = "column", + calibrate: Calibrate = "once", + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = "normal", + speed_read: bool = False, + path_check: bool = False, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[AbsorbanceResult]: + """Read absorbance. + + Args: + plate: Plate to read. + wells: Wells to read. + wavelength: Wavelength in nm, used when ``wavelengths`` is not given. + wavelengths: List of wavelengths to read. Each entry is either an int (wavelength + in nm) or a tuple of ``(wavelength, pathcheck_reference)`` where the bool + indicates if that wavelength is used as a PathCheck reference. If None, uses + ``wavelength``. + read_type: Read type (endpoint, kinetic, spectrum, or well scan). + read_order: Read order (column or wavelength). + calibrate: Calibration mode (on, once, or off). + shake_settings: Optional shake settings to apply before reading. + carriage_speed: Carriage speed (normal or slow). + speed_read: If True, enable speed read mode for faster measurements with reduced + accuracy. + path_check: If True, enable PathCheck pathlength correction for absorbance values. + kinetic_settings: Optional kinetic read settings (for kinetic read type). + spectrum_settings: Optional spectrum scan settings (for spectrum read type). + cuvette: If True, read a cuvette instead of a plate. + settling_time: Settling time in milliseconds before reading. + timeout: Read timeout in seconds. + """ + wavelengths = wavelengths if wavelengths is not None else [wavelength] + logger.info( + "[SpectraMax %s] read absorbance: plate='%s', wavelengths=%s nm", + self.port, + plate.name, + wavelengths, + ) + settings = MolecularDevicesSettings( + plate=plate, + read_mode="absorbance", + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + speed_read=speed_read, + path_check=path_check, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + wavelengths=wavelengths, + cuvette=cuvette, + settling_time=settling_time, + ) + await self._set_clear() + if not cuvette: + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_wavelengths(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_speed(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) + + await self._read_now() + await self.wait_for_idle(timeout=timeout) + dicts = await self._transfer_data(settings) + return [ + AbsorbanceResult( + data=d["data"], + wavelength=d["wavelength"], + temperature=d["temperature"], + timestamp=d["time"], + ) + for d in dicts + ] + + @property + def supports_active_cooling(self) -> bool: + return False + + async def request_temperature(self) -> Tuple[float, float]: + """Get (current_temp, set_point) from the device.""" + res = await self.send_command("!TEMP") + if len(res) > 1: + parts = res[1].split() + else: + parts = res[0].replace("OK", "").split() + + if len(parts) >= 2: + return (float(parts[1]), float(parts[0])) + raise ValueError(f"Could not parse temperature from response: {res}") + + async def request_current_temperature(self) -> float: + current, _ = await self.request_temperature() + logger.info("[SpectraMax %s] read temperature: actual=%.1f C", self.port, current) + return current + + async def set_temperature(self, temperature: float) -> None: + if not (0 <= temperature <= 45): + raise ValueError("Temperature must be between 0 and 45°C.") + logger.info("[SpectraMax %s] set temperature: target=%.1f C", self.port, temperature) + await self.send_command(f"!TEMP {temperature}") + + async def deactivate(self) -> None: + logger.info("[SpectraMax %s] deactivate temperature control", self.port) + await self.send_command("!TEMP 0") diff --git a/pylabrobot/molecular_devices/spectramax/base_tests.py b/pylabrobot/molecular_devices/spectramax/base_tests.py new file mode 100644 index 00000000000..970b0ef02d8 --- /dev/null +++ b/pylabrobot/molecular_devices/spectramax/base_tests.py @@ -0,0 +1,615 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, call, patch + +from pylabrobot.molecular_devices.spectramax.base import ( + KineticSettings, + MolecularDevicesPlateReader, + MolecularDevicesSettings, + ShakeSettings, + SpectrumSettings, +) +from pylabrobot.molecular_devices.spectramax.results import ( + AbsorbanceResult, + FluorescenceResult, + LuminescenceResult, +) +from pylabrobot.molecular_devices.spectramax.spectramax_m5 import SpectraMaxM5 +from pylabrobot.resources.agenbio.plates import AGenBio_96_wellplate_Ub_2200ul + + +class TestMolecularDevicesPlateReader(unittest.IsolatedAsyncioTestCase): + """Tests for MolecularDevicesPlateReader.""" + + device: MolecularDevicesPlateReader + mock_serial: MagicMock + send_command_mock: AsyncMock + + def setUp(self): + self.mock_serial = MagicMock() + self.mock_serial.setup = AsyncMock() + self.mock_serial.stop = AsyncMock() + self.mock_serial.write = AsyncMock() + self.mock_serial.readline = AsyncMock(return_value=b"OK>\r\n") + + with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): + self.device = MolecularDevicesPlateReader(port="COM1") + self.device.io = self.mock_serial + self.send_command_mock = patch.object( + self.device, "send_command", new_callable=AsyncMock + ).start() + self.addCleanup(patch.stopall) + + async def test_setup_stop(self): + with patch.object( + self.device, "send_command", wraps=self.device.send_command + ) as wrapped_send_command: + await self.device.setup() + self.mock_serial.setup.assert_called_once() + wrapped_send_command.assert_called_with("!") + await self.device.stop() + self.mock_serial.stop.assert_called_once() + + async def test_set_clear(self): + await self.device._set_clear() + self.send_command_mock.assert_called_once_with("!CLEAR DATA") + + async def test_set_mode(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode="absorbance", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.device._set_mode(settings) + self.send_command_mock.assert_called_once_with("!MODE ENDPOINT") + + self.send_command_mock.reset_mock() + settings.read_type = "kinetic" + settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) + await self.device._set_mode(settings) + self.send_command_mock.assert_called_once_with("!MODE KINETIC 10 5") + + self.send_command_mock.reset_mock() + settings.read_type = "spectrum" + settings.spectrum_settings = SpectrumSettings(start_wavelength=200, step=10, num_steps=50) + await self.device._set_mode(settings) + self.send_command_mock.assert_called_once_with("!MODE SPECTRUM 200 10 50") + + self.send_command_mock.reset_mock() + settings.spectrum_settings.excitation_emission_type = "EXSPECTRUM" + await self.device._set_mode(settings) + self.send_command_mock.assert_called_once_with("!MODE EXSPECTRUM 200 10 50") + + async def test_set_wavelengths(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode="absorbance", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + wavelengths=[500, (600, True)], + kinetic_settings=None, + spectrum_settings=None, + ) + await self.device._set_wavelengths(settings) + self.send_command_mock.assert_called_once_with("!WAVELENGTH 500 F600") + + self.send_command_mock.reset_mock() + settings.path_check = True + await self.device._set_wavelengths(settings) + self.send_command_mock.assert_called_once_with("!WAVELENGTH 500 F600 900 998") + + self.send_command_mock.reset_mock() + settings.read_mode = "fluorescence" + settings.excitation_wavelengths = [485] + settings.emission_wavelengths = [520] + await self.device._set_wavelengths(settings) + self.send_command_mock.assert_has_calls([call("!EXWAVELENGTH 485"), call("!EMWAVELENGTH 520")]) + + self.send_command_mock.reset_mock() + settings.read_mode = "luminescence" + settings.emission_wavelengths = [590] + await self.device._set_wavelengths(settings) + self.send_command_mock.assert_called_once_with("!EMWAVELENGTH 590") + + async def test_set_plate_position(self): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + settings = MolecularDevicesSettings( + plate=plate, + read_mode="absorbance", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.device._set_plate_position(settings) + self.send_command_mock.assert_has_calls( + [call("!XPOS 13.380 9.000 12"), call("!YPOS 12.240 9.000 8")] + ) + + async def test_set_strip(self): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + settings = MolecularDevicesSettings( + plate=plate, + read_mode="absorbance", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.device._set_strip(settings) + self.send_command_mock.assert_called_once_with("!STRIP 1 12") + + async def test_set_shake(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode="absorbance", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.device._set_shake(settings) + self.send_command_mock.assert_called_once_with("!SHAKE OFF") + + self.send_command_mock.reset_mock() + settings.shake_settings = ShakeSettings(before_read=True, before_read_duration=5) + await self.device._set_shake(settings) + self.send_command_mock.assert_has_calls([call("!SHAKE ON"), call("!SHAKE 5 0 0 0 0")]) + + self.send_command_mock.reset_mock() + settings.shake_settings = ShakeSettings(between_reads=True, between_reads_duration=3) + settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) + await self.device._set_shake(settings) + self.send_command_mock.assert_has_calls([call("!SHAKE ON"), call("!SHAKE 0 10 7 3 0")]) + + async def test_set_carriage_speed(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode="absorbance", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.device._set_carriage_speed(settings) + self.send_command_mock.assert_called_once_with("!CSPEED 8") + self.send_command_mock.reset_mock() + settings.carriage_speed = "slow" + await self.device._set_carriage_speed(settings) + self.send_command_mock.assert_called_once_with("!CSPEED 1") + + async def test_set_read_stage(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode="fluorescence", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.device._set_read_stage(settings) + self.send_command_mock.assert_called_once_with("!READSTAGE TOP") + self.send_command_mock.reset_mock() + settings.read_from_bottom = True + await self.device._set_read_stage(settings) + self.send_command_mock.assert_called_once_with("!READSTAGE BOT") + self.send_command_mock.reset_mock() + settings.read_mode = "absorbance" + await self.device._set_read_stage(settings) + self.send_command_mock.assert_not_called() + + async def test_set_flashes_per_well(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode="fluorescence", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + flashes_per_well=10, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.device._set_flashes_per_well(settings) + self.send_command_mock.assert_called_once_with("!FPW 10") + self.send_command_mock.reset_mock() + settings.read_mode = "absorbance" + await self.device._set_flashes_per_well(settings) + self.send_command_mock.assert_not_called() + + async def test_set_pmt(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode="fluorescence", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + pmt_gain="auto", + kinetic_settings=None, + spectrum_settings=None, + ) + await self.device._set_pmt(settings) + self.send_command_mock.assert_called_once_with("!AUTOPMT ON") + self.send_command_mock.reset_mock() + settings.pmt_gain = "high" + await self.device._set_pmt(settings) + self.send_command_mock.assert_has_calls([call("!AUTOPMT OFF"), call("!PMT HIGH")]) + self.send_command_mock.reset_mock() + settings.pmt_gain = 9 + await self.device._set_pmt(settings) + self.send_command_mock.assert_has_calls([call("!AUTOPMT OFF"), call("!PMT 9")]) + self.send_command_mock.reset_mock() + settings.read_mode = "absorbance" + await self.device._set_pmt(settings) + self.send_command_mock.assert_not_called() + + async def test_set_filter(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode="fluorescence", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + cutoff_filters=[self.device._get_cutoff_filter_index_from_wavelength(535), 9], + kinetic_settings=None, + spectrum_settings=None, + ) + await self.device._set_filter(settings) + self.send_command_mock.assert_has_calls([call("!AUTOFILTER OFF"), call("!EMFILTER 8 9")]) + self.send_command_mock.reset_mock() + settings.cutoff_filters = [] + await self.device._set_filter(settings) + self.send_command_mock.assert_called_once_with("!AUTOFILTER ON") + self.send_command_mock.reset_mock() + settings.read_mode = "absorbance" + settings.cutoff_filters = [515, 530] + await self.device._set_filter(settings) + self.send_command_mock.assert_called_once_with("!AUTOFILTER ON") + + async def test_set_calibrate(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode="absorbance", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.device._set_calibrate(settings) + self.send_command_mock.assert_called_once_with("!CALIBRATE ON") + self.send_command_mock.reset_mock() + settings.read_mode = "fluorescence" + await self.device._set_calibrate(settings) + self.send_command_mock.assert_called_once_with("!PMTCAL ON") + + async def test_set_order(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode="absorbance", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.device._set_order(settings) + self.send_command_mock.assert_called_once_with("!ORDER COLUMN") + self.send_command_mock.reset_mock() + settings.read_order = "wavelength" + await self.device._set_order(settings) + self.send_command_mock.assert_called_once_with("!ORDER WAVELENGTH") + + async def test_set_speed(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode="absorbance", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=True, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.device._set_speed(settings) + self.send_command_mock.assert_called_once_with("!SPEED ON") + self.send_command_mock.reset_mock() + settings.speed_read = False + await self.device._set_speed(settings) + self.send_command_mock.assert_called_once_with("!SPEED OFF") + self.send_command_mock.reset_mock() + settings.read_mode = "fluorescence" + await self.device._set_speed(settings) + self.send_command_mock.assert_not_called() + + async def test_set_integration_time(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode="time_resolved", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.device._set_integration_time(settings, 10, 100) + self.send_command_mock.assert_has_calls([call("!COUNTTIMEDELAY 10"), call("!COUNTTIME 0.1")]) + self.send_command_mock.reset_mock() + settings.read_mode = "absorbance" + await self.device._set_integration_time(settings, 10, 100) + self.send_command_mock.assert_not_called() + + async def test_set_nvram_polar(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode="polarization", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + settling_time=5, + ) + await self.device._set_nvram(settings) + self.send_command_mock.assert_called_once_with("!NVRAM FPSETTLETIME 5") + + async def test_set_nvram_other(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode="absorbance", + read_type="endpoint", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + settling_time=10, + ) + await self.device._set_nvram(settings) + self.send_command_mock.assert_called_once_with("!NVRAM CARCOL 100") + self.send_command_mock.reset_mock() + settings.settling_time = 110 + await self.device._set_nvram(settings) + self.send_command_mock.assert_called_once_with("!NVRAM CARCOL 110") + + async def test_set_tag(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode="polarization", + read_type="kinetic", + read_order="column", + calibrate="on", + shake_settings=None, + carriage_speed="normal", + speed_read=False, + kinetic_settings=KineticSettings(interval=10, num_readings=5), + spectrum_settings=None, + ) + await self.device._set_tag(settings) + self.send_command_mock.assert_called_once_with("!TAG ON") + self.send_command_mock.reset_mock() + settings.read_type = "endpoint" + await self.device._set_tag(settings) + self.send_command_mock.assert_called_once_with("!TAG OFF") + self.send_command_mock.reset_mock() + settings.read_mode = "absorbance" + settings.read_type = "kinetic" + await self.device._set_tag(settings) + self.send_command_mock.assert_called_once_with("!TAG OFF") + + async def test_read_absorbance(self): + with ( + patch.object(self.device, "_read_now", new_callable=AsyncMock) as mock_read_now, + patch.object(self.device, "wait_for_idle", new_callable=AsyncMock) as mock_wait, + patch.object( + self.device, + "_transfer_data", + new_callable=AsyncMock, + return_value=[{"data": [[0.1]], "wavelength": 500, "temperature": 25.0, "time": 12345.6}], + ) as mock_transfer, + ): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + results = await self.device.read_absorbance(plate, plate.get_wells(), 500) + + self.assertIsInstance(results, list) + self.assertEqual(len(results), 1) + self.assertIsInstance(results[0], AbsorbanceResult) + self.assertEqual(results[0].wavelength, 500) + self.assertEqual(results[0].temperature, 25.0) + self.assertEqual(results[0].timestamp, 12345.6) + + commands = [c.args[0] for c in self.send_command_mock.call_args_list] + self.assertIn("!CLEAR DATA", commands) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!WAVELENGTH 500", commands) + self.assertIn("!CALIBRATE ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn("!SPEED OFF", commands) + + readtype_call = next( + c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE ABSPLA" + ) + self.assertEqual(readtype_call.kwargs, {"num_res_fields": 2}) + + mock_read_now.assert_called_once() + mock_wait.assert_called_once() + mock_transfer.assert_called_once() + + +class TestSpectraMaxM5(unittest.IsolatedAsyncioTestCase): + """Tests for SpectraMaxM5 fluorescence and luminescence reads.""" + + device: SpectraMaxM5 + mock_serial: MagicMock + send_command_mock: AsyncMock + + def setUp(self): + self.mock_serial = MagicMock() + self.mock_serial.setup = AsyncMock() + self.mock_serial.stop = AsyncMock() + self.mock_serial.write = AsyncMock() + self.mock_serial.readline = AsyncMock(return_value=b"OK>\r\n") + + with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): + self.device = SpectraMaxM5(name="m5", port="COM1") + self.device.io = self.mock_serial + self.send_command_mock = patch.object( + self.device, "send_command", new_callable=AsyncMock + ).start() + self.addCleanup(patch.stopall) + + async def test_read_fluorescence(self): + with ( + patch.object(self.device, "_read_now", new_callable=AsyncMock) as mock_read_now, + patch.object(self.device, "wait_for_idle", new_callable=AsyncMock) as mock_wait, + patch.object( + self.device, + "_transfer_data", + new_callable=AsyncMock, + return_value=[ + { + "data": [[100.0]], + "ex_wavelength": 485, + "em_wavelength": 520, + "temperature": 25.0, + "time": 12345.6, + } + ], + ) as mock_transfer, + ): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + results = await self.device.read_fluorescence( + plate, plate.get_wells(), excitation_wavelength=485, emission_wavelength=520, focal_height=0 + ) + + self.assertIsInstance(results, list) + self.assertEqual(len(results), 1) + self.assertIsInstance(results[0], FluorescenceResult) + self.assertEqual(results[0].excitation_wavelength, 485) + self.assertEqual(results[0].emission_wavelength, 520) + self.assertEqual(results[0].temperature, 25.0) + self.assertEqual(results[0].timestamp, 12345.6) + + commands = [c.args[0] for c in self.send_command_mock.call_args_list] + self.assertIn("!CLEAR DATA", commands) + self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!FPW 10", commands) + self.assertIn("!AUTOPMT ON", commands) + self.assertIn("!EXWAVELENGTH 485", commands) + self.assertIn("!EMWAVELENGTH 520", commands) + self.assertIn("!PMTCAL ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn("!READSTAGE TOP", commands) + + readtype_call = next( + c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE FLU" + ) + self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) + + mock_read_now.assert_called_once() + mock_wait.assert_called_once() + mock_transfer.assert_called_once() + + async def test_read_luminescence(self): + with ( + patch.object(self.device, "_read_now", new_callable=AsyncMock) as mock_read_now, + patch.object(self.device, "wait_for_idle", new_callable=AsyncMock) as mock_wait, + patch.object( + self.device, + "_transfer_data", + new_callable=AsyncMock, + return_value=[ + {"data": [[1000.0]], "em_wavelength": 590, "temperature": 25.0, "time": 12345.6} + ], + ) as mock_transfer, + ): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + results = await self.device.read_luminescence( + plate, + plate.get_wells(), + focal_height=0, + emission_wavelengths=[590], + ) + + self.assertIsInstance(results, list) + self.assertEqual(len(results), 1) + self.assertIsInstance(results[0], LuminescenceResult) + self.assertEqual(results[0].temperature, 25.0) + self.assertEqual(results[0].timestamp, 12345.6) + + commands = [c.args[0] for c in self.send_command_mock.call_args_list] + self.assertIn("!CLEAR DATA", commands) + self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!EMWAVELENGTH 590", commands) + self.assertIn("!PMTCAL ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + + mock_read_now.assert_called_once() + mock_wait.assert_called_once() + mock_transfer.assert_called_once() diff --git a/pylabrobot/molecular_devices/spectramax/errors.py b/pylabrobot/molecular_devices/spectramax/errors.py new file mode 100644 index 00000000000..95c2ac9de82 --- /dev/null +++ b/pylabrobot/molecular_devices/spectramax/errors.py @@ -0,0 +1,77 @@ +from typing import Dict, Tuple + + +class SpectraMaxError(Exception): + """Exceptions raised by a SpectraMax plate reader.""" + + +class SpectraMaxUnrecognizedCommandError(SpectraMaxError): + """Unrecognized command errors sent from the computer.""" + + +class SpectraMaxFirmwareError(SpectraMaxError): + """Firmware errors.""" + + +class SpectraMaxHardwareError(SpectraMaxError): + """Hardware errors.""" + + +class SpectraMaxMotionError(SpectraMaxError): + """Motion errors.""" + + +class SpectraMaxNVRAMError(SpectraMaxError): + """NVRAM errors.""" + + +ERROR_CODES: Dict[int, Tuple[str, type]] = { + 100: ("command not found", SpectraMaxUnrecognizedCommandError), + 101: ("invalid argument", SpectraMaxUnrecognizedCommandError), + 102: ("too many arguments", SpectraMaxUnrecognizedCommandError), + 103: ("not enough arguments", SpectraMaxUnrecognizedCommandError), + 104: ("input line too long", SpectraMaxUnrecognizedCommandError), + 105: ("command invalid, system busy", SpectraMaxUnrecognizedCommandError), + 106: ("command invalid, measurement in progress", SpectraMaxUnrecognizedCommandError), + 107: ("no data to transfer", SpectraMaxUnrecognizedCommandError), + 108: ("data buffer full", SpectraMaxUnrecognizedCommandError), + 109: ("error buffer overflow", SpectraMaxUnrecognizedCommandError), + 110: ("stray light cuvette, door open?", SpectraMaxUnrecognizedCommandError), + 111: ("invalid read settings", SpectraMaxUnrecognizedCommandError), + 200: ("assert failed", SpectraMaxFirmwareError), + 201: ("bad error number", SpectraMaxFirmwareError), + 202: ("receive queue overflow", SpectraMaxFirmwareError), + 203: ("serial port parity error", SpectraMaxFirmwareError), + 204: ("serial port overrun error", SpectraMaxFirmwareError), + 205: ("serial port framing error", SpectraMaxFirmwareError), + 206: ("cmd generated too much output", SpectraMaxFirmwareError), + 207: ("fatal trap", SpectraMaxFirmwareError), + 208: ("RTOS error", SpectraMaxFirmwareError), + 209: ("stack overflow", SpectraMaxFirmwareError), + 210: ("unknown interrupt", SpectraMaxFirmwareError), + 300: ("thermistor faulty", SpectraMaxHardwareError), + 301: ("safe temperature limit exceeded", SpectraMaxHardwareError), + 302: ("low light", SpectraMaxHardwareError), + 303: ("unable to cal dark current", SpectraMaxHardwareError), + 304: ("signal level saturation", SpectraMaxHardwareError), + 305: ("reference level saturation", SpectraMaxHardwareError), + 306: ("plate air cal fail, low light", SpectraMaxHardwareError), + 307: ("cuv air ref fail", SpectraMaxHardwareError), + 308: ("stray light", SpectraMaxHardwareError), + 312: ("gain calibration failed", SpectraMaxHardwareError), + 313: ("reference gain check fail", SpectraMaxHardwareError), + 314: ("low lamp level warning", SpectraMaxHardwareError), + 315: ("can't find zero order", SpectraMaxHardwareError), + 316: ("grating motor driver faulty", SpectraMaxHardwareError), + 317: ("monitor ADC faulty", SpectraMaxHardwareError), + 400: ("carriage motion error", SpectraMaxMotionError), + 401: ("filter wheel error", SpectraMaxMotionError), + 402: ("grating error", SpectraMaxMotionError), + 403: ("stage error", SpectraMaxMotionError), + 500: ("NVRAM CRC corrupt", SpectraMaxNVRAMError), + 501: ("NVRAM Grating cal data bad", SpectraMaxNVRAMError), + 502: ("NVRAM Cuvette air cal data error", SpectraMaxNVRAMError), + 503: ("NVRAM Plate air cal data error", SpectraMaxNVRAMError), + 504: ("NVRAM Carriage offset error", SpectraMaxNVRAMError), + 505: ("NVRAM Stage offset error", SpectraMaxNVRAMError), +} diff --git a/pylabrobot/molecular_devices/spectramax/results.py b/pylabrobot/molecular_devices/spectramax/results.py new file mode 100644 index 00000000000..230e61b58a8 --- /dev/null +++ b/pylabrobot/molecular_devices/spectramax/results.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import dataclasses +from typing import List, Optional + + +@dataclasses.dataclass +class AbsorbanceResult: + """Result of an absorbance measurement. + + Attributes: + data: 2D array indexed [row][col]. ``None`` for unmeasured wells. + wavelength: Wavelength in nm. + temperature: Temperature in degrees C, or ``None`` if not available. + timestamp: Measurement time as reported by the instrument, in seconds. + """ + + data: List[List[Optional[float]]] + wavelength: int + temperature: Optional[float] + timestamp: float + + +@dataclasses.dataclass +class FluorescenceResult: + """Result of a fluorescence measurement. + + Attributes: + data: 2D array indexed [row][col]. ``None`` for unmeasured wells. + excitation_wavelength: Excitation wavelength in nm. + emission_wavelength: Emission wavelength in nm. + temperature: Temperature in degrees C, or ``None`` if not available. + timestamp: Measurement time as reported by the instrument, in seconds. + """ + + data: List[List[Optional[float]]] + excitation_wavelength: int + emission_wavelength: int + temperature: Optional[float] + timestamp: float + + +@dataclasses.dataclass +class LuminescenceResult: + """Result of a luminescence measurement. + + Attributes: + data: 2D array indexed [row][col]. ``None`` for unmeasured wells. + temperature: Temperature in degrees C, or ``None`` if not available. + timestamp: Measurement time as reported by the instrument, in seconds. + """ + + data: List[List[Optional[float]]] + temperature: Optional[float] + timestamp: float diff --git a/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py b/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py new file mode 100644 index 00000000000..97bf5b73dbc --- /dev/null +++ b/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py @@ -0,0 +1,31 @@ +from pylabrobot.resources import Coordinate, PlateHolder + +from .base import MolecularDevicesPlateReader, MolecularDevicesSettings + + +class SpectraMax384Plus(MolecularDevicesPlateReader): + """Molecular Devices SpectraMax 384 Plus plate reader. Absorbance only. + + Overrides ``_set_readtype`` (simpler CUV/PLA), and no-ops ``_set_nvram`` / ``_set_tag``. + """ + + def __init__(self, name: str, port: str): + super().__init__(port=port, human_readable_device_name="Molecular Devices SpectraMax 384 Plus") + self.loading_tray = PlateHolder( + name=name + "_loading_tray", + size_x=127.76, + size_y=85.48, + size_z=0, # TODO: measure + pedestal_size_z=0, # TODO: measure + child_location=Coordinate.zero(), # TODO: measure + ) + + async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: + cmd = f"!READTYPE {'CUV' if settings.cuvette else 'PLA'}" + await self.send_command(cmd, num_res_fields=1) + + async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: + pass + + async def _set_tag(self, settings: MolecularDevicesSettings) -> None: + pass diff --git a/pylabrobot/molecular_devices/spectramax/spectramax_m5.py b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py new file mode 100644 index 00000000000..1d8f12d3977 --- /dev/null +++ b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py @@ -0,0 +1,410 @@ +import logging +from typing import Dict, List, Optional, Union + +from pylabrobot.molecular_devices.spectramax.results import ( + FluorescenceResult, + LuminescenceResult, +) +from pylabrobot.resources import Coordinate +from pylabrobot.resources.carrier import PlateHolder +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + +from .base import ( + Calibrate, + CarriageSpeed, + KineticSettings, + MolecularDevicesPlateReader, + MolecularDevicesSettings, + PmtGain, + ReadOrder, + ReadType, + ShakeSettings, + SpectrumSettings, +) + +logger = logging.getLogger(__name__) + + +class SpectraMaxM5(MolecularDevicesPlateReader): + def __init__( + self, + name: str, + port: str, + ): + super().__init__(port=port, human_readable_device_name="Molecular Devices SpectraMax M5") + self.loading_tray = PlateHolder( + name=name + "_loading_tray", + size_x=127.76, + size_y=85.48, + size_z=0, # TODO: measure + pedestal_size_z=0, # TODO: measure + child_location=Coordinate.zero(), # TODO: measure + ) + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + excitation_wavelengths: Optional[List[int]] = None, + emission_wavelengths: Optional[List[int]] = None, + cutoff_filters: Optional[List[int]] = None, + read_type: ReadType = "endpoint", + read_order: ReadOrder = "column", + calibrate: Calibrate = "once", + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = "normal", + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = "auto", + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[FluorescenceResult]: + """Read fluorescence. + + Args: + plate: Plate to read. + wells: Wells to read. + excitation_wavelength: Excitation wavelength in nm, used when + ``excitation_wavelengths`` is not given. + emission_wavelength: Emission wavelength in nm, used when ``emission_wavelengths`` + is not given. + focal_height: Focal height in mm. + excitation_wavelengths: List of excitation wavelengths in nm. + emission_wavelengths: List of emission wavelengths in nm. + cutoff_filters: List of cutoff filter indices. If None, auto-selected from the + emission wavelength. + read_type: Read type (endpoint, kinetic, spectrum, or well scan). + read_order: Read order (column or wavelength). + calibrate: Calibration mode (on, once, or off). + shake_settings: Optional shake settings to apply before reading. + carriage_speed: Carriage speed (normal or slow). + read_from_bottom: If True, read from the bottom of the plate. + pmt_gain: PMT gain setting (a named setting or a manual integer value). + flashes_per_well: Number of flashes per well. + kinetic_settings: Optional kinetic read settings (for kinetic read type). + spectrum_settings: Optional spectrum scan settings (for spectrum read type). + cuvette: If True, read a cuvette instead of a plate. + settling_time: Settling time in milliseconds before reading. + timeout: Read timeout in seconds. + """ + + logger.info( + "[SpectraMax M5 %s] read fluorescence: plate=%s, ex=%d nm, em=%d nm, wells=%d", + self.port, + plate.name, + excitation_wavelength, + emission_wavelength, + len(wells), + ) + excitation_wavelengths = excitation_wavelengths or [excitation_wavelength] + emission_wavelengths = emission_wavelengths or [emission_wavelength] + cutoff_filters = cutoff_filters + if cutoff_filters is None: + cutoff_filters = [self._get_cutoff_filter_index_from_wavelength(emission_wavelength)] + + settings = MolecularDevicesSettings( + plate=plate, + read_mode="fluorescence", + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + ) + await self._set_clear() + if not cuvette: + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_flashes_per_well(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_filter(settings) + await self._set_read_stage(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) + + await self._read_now() + await self.wait_for_idle(timeout=timeout) + dicts = await self._transfer_data(settings) + return [ + FluorescenceResult( + data=d["data"], + excitation_wavelength=d["ex_wavelength"], + emission_wavelength=d["em_wavelength"], + temperature=d["temperature"], + timestamp=d["time"], + ) + for d in dicts + ] + + async def read_fluorescence_polarization( + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = "endpoint", + read_order: ReadOrder = "column", + calibrate: Calibrate = "once", + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = "normal", + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = "auto", + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict]: + """Read fluorescence polarization.""" + logger.info( + "[SpectraMax M5 %s] read fluorescence polarization: plate='%s', ex=%s nm, em=%s nm", + self.port, + plate.name, + excitation_wavelengths, + emission_wavelengths, + ) + settings = MolecularDevicesSettings( + plate=plate, + read_mode="polarization", + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + ) + await self._set_clear() + if not cuvette: + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_flashes_per_well(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_filter(settings) + await self._set_read_stage(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) + + await self._read_now() + await self.wait_for_idle(timeout=timeout) + return await self._transfer_data(settings) + + async def read_time_resolved_fluorescence( + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + delay_time: int, + integration_time: int, + read_type: ReadType = "endpoint", + read_order: ReadOrder = "column", + calibrate: Calibrate = "once", + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = "normal", + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = "auto", + flashes_per_well: int = 50, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict]: + """Read time-resolved fluorescence.""" + logger.info( + "[SpectraMax M5 %s] read time-resolved fluorescence: plate='%s', ex=%s nm, em=%s nm", + self.port, + plate.name, + excitation_wavelengths, + emission_wavelengths, + ) + settings = MolecularDevicesSettings( + plate=plate, + read_mode="time_resolved", + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + ) + await self._set_clear() + await self._set_readtype(settings) + await self._set_integration_time(settings, delay_time, integration_time) + + if not cuvette: + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_flashes_per_well(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_filter(settings) + await self._set_calibrate(settings) + await self._set_read_stage(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + + await self._read_now() + await self.wait_for_idle(timeout=timeout) + return await self._transfer_data(settings) + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float, + emission_wavelengths: Optional[List[int]] = None, + read_type: ReadType = "endpoint", + read_order: ReadOrder = "column", + calibrate: Calibrate = "once", + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = "normal", + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = "auto", + flashes_per_well: int = 0, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[LuminescenceResult]: + """Read luminescence. + + Args: + plate: Plate to read. + wells: Wells to read. + focal_height: Focal height in mm. + emission_wavelengths: List of emission wavelengths in nm. Required for + SpectraMax M5 luminescence reads. + read_type: Read type (endpoint, kinetic, spectrum, or well scan). + read_order: Read order (column or wavelength). + calibrate: Calibration mode (on, once, or off). + shake_settings: Optional shake settings to apply before reading. + carriage_speed: Carriage speed (normal or slow). + read_from_bottom: If True, read from the bottom of the plate. + pmt_gain: PMT gain setting (a named setting or a manual integer value). + flashes_per_well: Number of flashes per well. 0 uses the instrument default. + kinetic_settings: Optional kinetic read settings (for kinetic read type). + spectrum_settings: Optional spectrum scan settings (for spectrum read type). + cuvette: If True, read a cuvette instead of a plate. + settling_time: Settling time in milliseconds before reading. + timeout: Read timeout in seconds. + """ + + logger.info( + "[SpectraMax M5 %s] read luminescence: plate=%s, wells=%d", + self.port, + plate.name, + len(wells), + ) + if emission_wavelengths is None: + raise ValueError("emission_wavelengths is required for SpectraMax M5 luminescence reads") + + settings = MolecularDevicesSettings( + plate=plate, + read_mode="luminescence", + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + emission_wavelengths=emission_wavelengths, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + ) + await self._set_clear() + await self._set_read_stage(settings) + + if not cuvette: + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_read_stage(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) + + await self._read_now() + await self.wait_for_idle(timeout=timeout) + dicts = await self._transfer_data(settings) + return [ + LuminescenceResult( + data=d["data"], + temperature=d["temperature"], + timestamp=d["time"], + ) + for d in dicts + ] diff --git a/pylabrobot/only_fans/__init__.py b/pylabrobot/only_fans/__init__.py index acc732faeba..16a13f31918 100644 --- a/pylabrobot/only_fans/__init__.py +++ b/pylabrobot/only_fans/__init__.py @@ -1,3 +1,9 @@ -from .backend import FanBackend -from .fan import Fan -from .hamilton_hepa_fan_backend import HamiltonHepaFanBackend +import warnings + +warnings.warn( + "Importing from pylabrobot.only_fans is deprecated. Use pylabrobot.legacy.only_fans instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.only_fans import * # noqa: F401,F403,E402 diff --git a/pylabrobot/peeling/__init__.py b/pylabrobot/peeling/__init__.py index 48292ba9623..100bce810a3 100644 --- a/pylabrobot/peeling/__init__.py +++ b/pylabrobot/peeling/__init__.py @@ -1,2 +1,9 @@ -from .xpeel import xpeel -from .xpeel_backend import XPeelBackend +import warnings + +warnings.warn( + "Importing from pylabrobot.peeling is deprecated. Use pylabrobot.legacy.peeling instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.peeling import * # noqa: F401,F403,E402 diff --git a/pylabrobot/peeling/xpeel.py b/pylabrobot/peeling/xpeel.py deleted file mode 100644 index 7a0d2f7127a..00000000000 --- a/pylabrobot/peeling/xpeel.py +++ /dev/null @@ -1,8 +0,0 @@ -from pylabrobot.peeling.peeler import Peeler -from pylabrobot.peeling.xpeel_backend import XPeelBackend - - -def xpeel(port: str) -> Peeler: - return Peeler( - backend=XPeelBackend(port=port), - ) diff --git a/pylabrobot/pioreactor/__init__.py b/pylabrobot/pioreactor/__init__.py new file mode 100644 index 00000000000..e370e8b6316 --- /dev/null +++ b/pylabrobot/pioreactor/__init__.py @@ -0,0 +1 @@ +from .bioreactors import * diff --git a/pylabrobot/pioreactor/bioreactors.py b/pylabrobot/pioreactor/bioreactors.py new file mode 100644 index 00000000000..cfcbc7d7205 --- /dev/null +++ b/pylabrobot/pioreactor/bioreactors.py @@ -0,0 +1,77 @@ +from typing import Optional + +from pylabrobot.resources.container import Container +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource + + +class Pioreactor(Resource): + """A Pioreactor bioreactor unit (https://pioreactor.com). + + The culture vessel is modeled as a single child :class:`Container`. + """ + + def assign_child_resource( + self, + resource: Resource, + location: Optional[Coordinate], + reassign: bool = True, + ): + if not isinstance(resource, Container): + raise TypeError("Pioreactor can only hold a Container as a child.") + if len(self.children) > 0: + raise ValueError("Pioreactor already has a vessel assigned.") + super().assign_child_resource(resource, location, reassign=reassign) + + @property + def vessel(self) -> Container: + if not self.children: + raise ValueError(f"Pioreactor '{self.name}' has no vessel assigned.") + vessel = self.children[0] + assert isinstance(vessel, Container) + return vessel + + +def pioreactor_20ml(name: str) -> Pioreactor: + """Pioreactor 20mL Vessel: https://pioreactor.com/products/pioreactor-20ml + + Geometry (mm): + - Outer footprint (on holder): 127.74 x 85.40 + - Total height: 126.5 + - Vessel cavity: Ø 23.5, depth 57.0, centered + """ + outer_x = 127.74 + outer_y = 85.40 + outer_z = 126.5 + diameter = 23.5 + depth = 57.0 + material_z_thickness = 1.0 + vessel_z = 76.0 # measured: vessel outer base relative to pioreactor bottom + + pioreactor = Pioreactor( + name=name, + size_x=outer_x, + size_y=outer_y, + size_z=outer_z, + model=pioreactor_20ml.__name__, + category="bioreactor", + ) + + vessel = Container( + name=f"{name}_vessel", + size_x=diameter, + size_y=diameter, + size_z=depth, + material_z_thickness=material_z_thickness, + max_volume=20_000, + category="bioreactor_vessel", + ) + pioreactor.assign_child_resource( + vessel, + location=Coordinate( + x=(outer_x - diameter) / 2.0, + y=(outer_y - diameter) / 2.0, + z=vessel_z, + ), + ) + return pioreactor diff --git a/pylabrobot/plate_reading/__init__.py b/pylabrobot/plate_reading/__init__.py index 187d794b3c1..e08734db787 100644 --- a/pylabrobot/plate_reading/__init__.py +++ b/pylabrobot/plate_reading/__init__.py @@ -1,53 +1,10 @@ -from __future__ import annotations +import warnings -from typing import Any - -from .agilent import ( - BioTekPlateReaderBackend, - Cytation5Backend, - Cytation5ImagingConfig, - CytationBackend, - CytationImagingConfig, - SynergyH1Backend, -) -from .bmg_labtech import CLARIOstarBackend -from .byonoy import ( - ByonoyAbsorbance96AutomateBackend, - ByonoyLuminescence96AutomateBackend, -) -from .chatterbox import PlateReaderChatterboxBackend -from .image_reader import ImageReader -from .imager import Imager -from .molecular_devices import ( - Calibrate, - CarriageSpeed, - KineticSettings, - MolecularDevicesBackend, - MolecularDevicesError, - MolecularDevicesFirmwareError, - MolecularDevicesHardwareError, - MolecularDevicesMotionError, - MolecularDevicesNVRAMError, - MolecularDevicesSettings, - MolecularDevicesSpectraMax384PlusBackend, - MolecularDevicesSpectraMaxGeminiEMBackend, - MolecularDevicesSpectraMaxM5Backend, - MolecularDevicesUnrecognizedCommandError, - PmtGain, - ReadMode, - ReadOrder, - ReadType, - ShakeSettings, - SpectrumSettings, +warnings.warn( + "Importing from pylabrobot.plate_reading is deprecated. " + "Use pylabrobot.legacy.plate_reading instead.", + DeprecationWarning, + stacklevel=2, ) -from .plate_reader import PlateReader -from .standard import ( - Exposure, - FocalPosition, - Gain, - ImagingMode, - ImagingResult, - Objective, -) -from .tecan import ExperimentalTecanInfinite200ProBackend -from .tecan.spark20m.spark_backend import ExperimentalSparkBackend + +from pylabrobot.legacy.plate_reading import * # noqa: F401,F403,E402 diff --git a/pylabrobot/plate_reading/biotek_backend.py b/pylabrobot/plate_reading/biotek_backend.py deleted file mode 100644 index c3e71dcba25..00000000000 --- a/pylabrobot/plate_reading/biotek_backend.py +++ /dev/null @@ -1,8 +0,0 @@ -import warnings - -from .agilent.biotek_backend import BioTekPlateReaderBackend # noqa: F401 - -warnings.warn( - "pylabrobot.plate_reading.biotek_backend is deprecated and will be removed in a future release. " - "Please use pylabrobot.plate_reading.agilent.biotek_backend instead.", -) diff --git a/pylabrobot/plate_reading/biotek_synergyh1_backend.py b/pylabrobot/plate_reading/biotek_synergyh1_backend.py deleted file mode 100644 index 8f6589ab25d..00000000000 --- a/pylabrobot/plate_reading/biotek_synergyh1_backend.py +++ /dev/null @@ -1,8 +0,0 @@ -import warnings - -from .agilent.biotek_synergyh1_backend import SynergyH1Backend # noqa: F401 - -warnings.warn( - "pylabrobot.plate_reading.biotek_synergyh1_backend is deprecated and will be removed in a future release. " - "Please use pylabrobot.plate_reading.agilent.biotek_synergyh1_backend instead.", -) diff --git a/pylabrobot/plate_reading/clario_star_backend.py b/pylabrobot/plate_reading/clario_star_backend.py deleted file mode 100644 index db37b025a1f..00000000000 --- a/pylabrobot/plate_reading/clario_star_backend.py +++ /dev/null @@ -1,8 +0,0 @@ -import warnings - -from .bmg_labtech.clario_star_backend import CLARIOstarBackend # noqa: F401 - -warnings.warn( - "pylabrobot.plate_reading.clario_star_backend is deprecated and will be removed in a future release. " - "Please use pylabrobot.plate_reading.bmg_labtech.clario_star_backend instead.", -) diff --git a/pylabrobot/plate_reading/molecular_devices_backend.py b/pylabrobot/plate_reading/molecular_devices_backend.py deleted file mode 100644 index d3ae0840ef5..00000000000 --- a/pylabrobot/plate_reading/molecular_devices_backend.py +++ /dev/null @@ -1,11 +0,0 @@ -import warnings - -from .molecular_devices.backend import ( # noqa: F401 - MolecularDevicesBackend, - MolecularDevicesSettings, -) - -warnings.warn( - "pylabrobot.plate_reading.molecular_devices_backend is deprecated and will be removed in a future release. " - "Please use pylabrobot.plate_reading.molecular_devices.molecular_devices_backend instead.", -) diff --git a/pylabrobot/plate_reading/spectramax_384_plus_backend.py b/pylabrobot/plate_reading/spectramax_384_plus_backend.py deleted file mode 100644 index b209792ed7b..00000000000 --- a/pylabrobot/plate_reading/spectramax_384_plus_backend.py +++ /dev/null @@ -1,10 +0,0 @@ -import warnings - -from .molecular_devices.spectramax_384_plus_backend import ( - MolecularDevicesSpectraMax384PlusBackend, # noqa: F401 -) - -warnings.warn( - "pylabrobot.plate_reading.spectramax_384_plus_backend is deprecated and will be removed in a future release. " - "Please use pylabrobot.plate_reading.molecular_devices.spectramax_384_plus_backend instead.", -) diff --git a/pylabrobot/plate_reading/spectramax_m5_backend.py b/pylabrobot/plate_reading/spectramax_m5_backend.py deleted file mode 100644 index b58cf75ae0a..00000000000 --- a/pylabrobot/plate_reading/spectramax_m5_backend.py +++ /dev/null @@ -1,10 +0,0 @@ -import warnings - -from .molecular_devices.spectramax_m5_backend import ( - MolecularDevicesSpectraMaxM5Backend, # noqa: F401 -) - -warnings.warn( - "pylabrobot.plate_reading.spectramax_m5_backend is deprecated and will be removed in a future release. " - "Please use pylabrobot.plate_reading.molecular_devices.spectramax_m5_backend instead.", -) diff --git a/pylabrobot/plate_washing/__init__.py b/pylabrobot/plate_washing/__init__.py index c46811a7fe2..6c4dfa070fe 100644 --- a/pylabrobot/plate_washing/__init__.py +++ b/pylabrobot/plate_washing/__init__.py @@ -1,4 +1,10 @@ -"""Plate washing module for PyLabRobot. +import warnings -This module provides support for automated plate washers. -""" +warnings.warn( + "Importing from pylabrobot.plate_washing is deprecated. " + "Use pylabrobot.legacy.plate_washing instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.plate_washing import * # noqa: F401,F403,E402 diff --git a/pylabrobot/powder_dispensing/__init__.py b/pylabrobot/powder_dispensing/__init__.py index 472e6d490c0..be4d114b760 100644 --- a/pylabrobot/powder_dispensing/__init__.py +++ b/pylabrobot/powder_dispensing/__init__.py @@ -1 +1,10 @@ -from .powder_dispenser import PowderDispenser +import warnings + +warnings.warn( + "Importing from pylabrobot.powder_dispensing is deprecated. " + "Use pylabrobot.legacy.powder_dispensing instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.powder_dispensing import * # noqa: F401,F403,E402 diff --git a/pylabrobot/pumps/__init__.py b/pylabrobot/pumps/__init__.py index 6a4a86f00f4..669d079bd4a 100644 --- a/pylabrobot/pumps/__init__.py +++ b/pylabrobot/pumps/__init__.py @@ -1,6 +1,9 @@ -from .agrowpumps import AgrowPumpArray -from .calibration import PumpCalibration -from .cole_parmer import MasterflexBackend -from .errors import NotCalibratedError -from .pump import Pump -from .pumparray import PumpArray +import warnings + +warnings.warn( + "Importing from pylabrobot.pumps is deprecated. Use pylabrobot.legacy.pumps instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.pumps import * # noqa: F401,F403,E402 diff --git a/pylabrobot/qinstruments/__init__.py b/pylabrobot/qinstruments/__init__.py new file mode 100644 index 00000000000..ce4089c621a --- /dev/null +++ b/pylabrobot/qinstruments/__init__.py @@ -0,0 +1,15 @@ +from pylabrobot.qinstruments.bioshake import ( + BioShake, + BioShake3000, + BioShake3000Elm, + BioShake3000ElmDWP, + BioShake3000T, + BioShake3000TElm, + BioShake5000Elm, + BioShakeD30Elm, + BioShakeD30TElm, + BioShakeQ1, + BioShakeQ2, + ColdPlate, + Heatplate, +) diff --git a/pylabrobot/qinstruments/bioshake.py b/pylabrobot/qinstruments/bioshake.py new file mode 100644 index 00000000000..5a58e10a23f --- /dev/null +++ b/pylabrobot/qinstruments/bioshake.py @@ -0,0 +1,385 @@ +import asyncio +import logging +from typing import Optional, Union + +from pylabrobot.io.serial import Serial +from pylabrobot.resources import Coordinate +from pylabrobot.resources.carrier import PlateHolder + +try: + import serial + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e + +logger = logging.getLogger(__name__) + + +class BioShake(PlateHolder): + """Serial driver for QInstruments BioShake devices.""" + + def __init__( + self, + port: str, + name: str, + size_x: float, + size_y: float, + size_z: float, + child_location: Coordinate, + pedestal_size_z: float, + model: Optional[str] = None, + timeout: int = 60, + supports_shaking: bool = False, + supports_locking: bool = False, + supports_temperature_control: bool = False, + supports_active_cooling: bool = False, + ): + if not HAS_SERIAL: + raise RuntimeError(f"pyserial is required for BioShake. Import error: {_SERIAL_IMPORT_ERROR}") + + PlateHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + child_location=child_location, + pedestal_size_z=pedestal_size_z, + category="bioshake", + model=model, + ) + + self.port = port + self.timeout = timeout + self.io = Serial( + human_readable_device_name="QInstruments BioShake", + port=self.port, + baudrate=9600, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + write_timeout=10, + timeout=self.timeout, + ) + self.supports_shaking = supports_shaking + self.supports_locking = supports_locking + self.supports_temperature_control = supports_temperature_control + self.supports_active_cooling = supports_active_cooling + + async def send_command(self, cmd: str, delay: float = 0.5, timeout: float = 2): + try: + await self.io.reset_input_buffer() + await self.io.reset_output_buffer() + + await self.io.write((cmd + "\r").encode("ascii")) + await asyncio.sleep(delay) + + try: + response = await asyncio.wait_for(self.io.readline(), timeout=timeout) + except asyncio.TimeoutError: + raise RuntimeError(f"Timed out waiting for response to '{cmd}'") + + decoded = response.decode("ascii", errors="ignore").strip() + + if not decoded: + raise RuntimeError(f"No response for '{cmd}'") + + if decoded.startswith("e"): + logger.error("[BioShake %s] error for '%s': '%s'", self.port, cmd, decoded) + raise RuntimeError(f"Device returned error for '{cmd}': '{decoded}'") + + if decoded.startswith("u ->"): + raise NotImplementedError(f"'{cmd}' not supported: '{decoded}'") + + if decoded.lower().startswith("ok"): + return None + + return decoded + + except Exception as e: + raise RuntimeError(f"Unexpected error while sending '{cmd}': {type(e).__name__}: {e}") from e + + async def setup(self, skip_home: bool = False): + """ + Args: + skip_home: If True, skip the reset and home steps during setup. + """ + await self.io.setup() + if not skip_home and self.supports_shaking: + await self.reset() + await asyncio.sleep(4) + await self.home() + logger.info("[BioShake %s] connected", self.port) + + async def stop(self): + await self.io.stop() + logger.info("[BioShake %s] disconnected", self.port) + + async def reset(self): + await self.io.reset_input_buffer() + await self.io.reset_output_buffer() + + await self.io.write(("resetDevice\r").encode("ascii")) + + start = asyncio.get_event_loop().time() + max_seconds = 30 + + while True: + if asyncio.get_event_loop().time() - start > max_seconds: + raise TimeoutError("Reset did not complete in time") + + try: + response = await asyncio.wait_for(self.io.readline(), timeout=2) + decoded = response.decode("ascii", errors="ignore").strip() + await asyncio.sleep(0.1) + + if len(decoded) > 0: + if "Initialization complete" in decoded: + break + + except asyncio.TimeoutError: + continue + + async def home(self): + if not self.supports_shaking: + raise RuntimeError("This BioShake device does not support shaking.") + await self.send_command(cmd="shakeGoHome", delay=5) + + async def shake(self, speed: float, duration: float): + if not self.supports_shaking: + raise RuntimeError("This BioShake device does not support shaking.") + await self.start_shaking(speed=speed) + await asyncio.sleep(duration) + await self.stop_shaking() + + async def start_shaking(self, speed: float, acceleration: Union[int, float] = 0): + if not self.supports_shaking: + raise RuntimeError("This BioShake device does not support shaking.") + if isinstance(speed, float): + if not speed.is_integer(): + raise ValueError(f"Speed must be a whole number, not {speed}") + speed = int(speed) + if not isinstance(speed, int): + raise TypeError( + f"Speed must be an integer or a whole number float, not {type(speed).__name__}" + ) + + min_speed = int(float(await self.send_command(cmd="getShakeMinRpm", delay=0.2))) + max_speed = int(float(await self.send_command(cmd="getShakeMaxRpm", delay=0.2))) + + if not (min_speed <= speed <= max_speed): + raise ValueError( + f"Speed {speed} RPM is out of range. Allowed range is {min_speed}-{max_speed} RPM" + ) + + await self.send_command(cmd=f"setShakeTargetSpeed{speed}") + + if isinstance(acceleration, float): + if not acceleration.is_integer(): + raise ValueError(f"Acceleration must be a whole number, not {acceleration}") + acceleration = int(acceleration) + if not isinstance(acceleration, int): + raise TypeError( + f"Acceleration must be an integer or a whole number float, not {type(acceleration).__name__}" + ) + + min_accel = int(float(await self.send_command(cmd="getShakeAccelerationMin", delay=0.2))) + max_accel = int(float(await self.send_command(cmd="getShakeAccelerationMax", delay=0.2))) + + if not (min_accel <= acceleration <= max_accel): + raise ValueError( + f"Acceleration {acceleration} seconds is out of range. " + f"Allowed range is {min_accel}-{max_accel} seconds" + ) + + await self.send_command(cmd=f"setShakeAcceleration{acceleration}", delay=0.2) + logger.info("[BioShake %s] start shaking: speed=%d, accel=%d", self.port, speed, acceleration) + await self.send_command(cmd="shakeOn", delay=0.2) + + async def stop_shaking(self, deceleration: Union[int, float] = 0): + if not self.supports_shaking: + raise RuntimeError("This BioShake device does not support shaking.") + if isinstance(deceleration, float): + if not deceleration.is_integer(): + raise ValueError(f"Deceleration must be a whole number, not {deceleration}") + deceleration = int(deceleration) + if not isinstance(deceleration, int): + raise TypeError( + f"Deceleration must be an integer or a whole number float, " + f"not {type(deceleration).__name__}" + ) + + min_decel = int(float(await self.send_command(cmd="getShakeAccelerationMin", delay=0.2))) + max_decel = int(float(await self.send_command(cmd="getShakeAccelerationMax", delay=0.2))) + + if not (min_decel <= deceleration <= max_decel): + raise ValueError( + f"Deceleration {deceleration} seconds is out of range. " + f"Allowed range is {min_decel}-{max_decel} seconds" + ) + + await self.send_command(cmd=f"setShakeAcceleration{deceleration}", delay=0.2) + logger.info("[BioShake %s] stop shaking (decel=%d)", self.port, deceleration) + await self.send_command(cmd="shakeOff", delay=0.2) + + # The firmware needs the motor to fully decelerate before ELM can operate. + await asyncio.sleep(3) + + async def lock_plate(self): + if not self.supports_locking: + raise RuntimeError("This BioShake device does not have an ELM and cannot lock plates.") + logger.info("[BioShake %s] lock plate", self.port) + await self.send_command(cmd="setElmLockPos", delay=0.3) + + async def unlock_plate(self): + if not self.supports_locking: + raise RuntimeError("This BioShake device does not have an ELM and cannot lock plates.") + logger.info("[BioShake %s] unlock plate", self.port) + await self.send_command(cmd="setElmUnlockPos", delay=0.3) + + async def set_temperature(self, temperature: float): + if not self.supports_temperature_control: + raise RuntimeError("This BioShake device does not support temperature control.") + min_temp = int(float(await self.send_command(cmd="getTempMin", delay=0.2))) + max_temp = int(float(await self.send_command(cmd="getTempMax", delay=0.2))) + + if not (min_temp <= temperature <= max_temp): + raise ValueError( + f"Temperature {temperature} C is out of range. Allowed range is {min_temp}-{max_temp} C." + ) + + temperature_tenths = temperature * 10 + + if isinstance(temperature_tenths, float): + if not temperature_tenths.is_integer(): + raise ValueError(f"Temperature must be a whole number in 1/10 C, not {temperature_tenths}") + temperature_tenths = int(temperature_tenths) + + logger.info("[BioShake %s] setting temperature to %.1f C", self.port, temperature) + await self.send_command(cmd=f"setTempTarget{temperature_tenths}", delay=0.2) + await self.send_command(cmd="tempOn", delay=0.2) + + async def request_current_temperature(self) -> float: + if not self.supports_temperature_control: + raise RuntimeError("This BioShake device does not support temperature control.") + response = await self.send_command(cmd="getTempActual", delay=0.2) + temp = float(response) + logger.info("[BioShake %s] read temperature: actual=%.1f C", self.port, temp) + return temp + + async def deactivate(self): + logger.info("[BioShake %s] deactivating temperature", self.port) + await self.send_command(cmd="tempOff", delay=0.2) + + +# -- Factory functions for specific models -- + + +def BioShake3000(name: str, port: str) -> BioShake: + """BioShake 3000 - shaking 200-3000 rpm, no ELM, no heating.""" + return BioShake( + name=name, + port=port, + size_x=142, # from spec + size_y=99, # from spec + size_z=60.67, # from spec + child_location=Coordinate(7.12, 6.76, 51.75), # from spec + pedestal_size_z=0, + supports_shaking=True, + model=BioShake3000.__name__, + ) + + +def BioShake3000Elm(name: str, port: str) -> BioShake: + """BioShake 3000 elm - shaking 200-3000 rpm, ELM, no heating.""" + return BioShake( + name=name, + port=port, + size_x=142, # from spec + size_y=99, # from spec + size_z=55.35, # from spec + child_location=Coordinate(7.12, 6.76, 48.20), # from spec + pedestal_size_z=0, + supports_shaking=True, + supports_locking=True, + model=BioShake3000Elm.__name__, + ) + + +def BioShake3000ElmDWP(name: str, port: str) -> BioShake: + """BioShake 3000 elm DWP - shaking 200-3000 rpm, ELM, no heating.""" + return BioShake( + name=name, + port=port, + size_x=142, # from spec + size_y=99, # from spec + size_z=55.35, # from spec + child_location=Coordinate(7.12, 6.76, 48.20), # from spec + pedestal_size_z=0, + supports_shaking=True, + supports_locking=True, + model=BioShake3000ElmDWP.__name__, + ) + + +def BioShakeD30Elm(name: str, port: str) -> BioShake: + """BioShake D30 elm - shaking 200-2000 rpm, ELM, no heating.""" + raise NotImplementedError("BioShakeD30Elm is missing resource definition.") + + +def BioShake5000Elm(name: str, port: str) -> BioShake: + """BioShake 5000 elm - shaking 200-5000 rpm, ELM, no heating.""" + raise NotImplementedError("BioShake5000Elm is missing resource definition.") + + +def BioShake3000T(name: str, port: str) -> BioShake: + """BioShake 3000-T - shaking 200-3000 rpm, no ELM, heating.""" + raise NotImplementedError("BioShake3000T is missing resource definition.") + + +def BioShake3000TElm(name: str, port: str) -> BioShake: + """BioShake 3000-T elm - shaking 200-3000 rpm, ELM, heating.""" + raise NotImplementedError("BioShake3000TElm is missing resource definition.") + + +def BioShakeD30TElm(name: str, port: str) -> BioShake: + """BioShake D30-T elm - shaking 200-2000 rpm, ELM, heating.""" + raise NotImplementedError("BioShakeD30TElm is missing resource definition.") + + +def BioShakeQ1(name: str, port: str) -> BioShake: + """BioShake Q1 - shaking 200-3000 rpm, ELM, heating, active cooling. + + Dimensions defined with microplate adapter #2016-1024 (flat bottom standard). + """ + return BioShake( + name=name, + port=port, + size_x=142, # from spec + size_y=99, # from spec + size_z=97.30, # from spec + child_location=Coordinate(7.12, 6.76, 90.50), # from spec + pedestal_size_z=0, + supports_shaking=True, + supports_locking=True, + supports_temperature_control=True, + supports_active_cooling=True, + model=BioShakeQ1.__name__, + ) + + +def BioShakeQ2(name: str, port: str) -> BioShake: + """BioShake Q2 - shaking 200-3000 rpm, ELM, heating, active cooling.""" + raise NotImplementedError("BioShakeQ2 is missing resource definition.") + + +def Heatplate(name: str, port: str) -> BioShake: + """Heatplate - no shaking, heating only.""" + raise NotImplementedError("Heatplate is missing resource definition.") + + +def ColdPlate(name: str, port: str) -> BioShake: + """ColdPlate - no shaking, heating, active cooling.""" + raise NotImplementedError("ColdPlate is missing resource definition.") diff --git a/pylabrobot/resources/barcode.py b/pylabrobot/resources/barcode.py index 25b4d1ac361..7f06252b146 100644 --- a/pylabrobot/resources/barcode.py +++ b/pylabrobot/resources/barcode.py @@ -36,13 +36,5 @@ def serialize(self) -> dict: "position_on_resource": self.position_on_resource, } - @staticmethod - def deserialize(data: dict) -> "Barcode": - return Barcode( - data=data["data"], - symbology=data["symbology"], - position_on_resource=data["position_on_resource"], - ) - def __str__(self) -> str: return f'Barcode(data="{self.data}", symbology="{self.symbology}", position_on_resource="{self.position_on_resource}")' diff --git a/pylabrobot/resources/carrier.py b/pylabrobot/resources/carrier.py index d9720260576..057e4dae213 100644 --- a/pylabrobot/resources/carrier.py +++ b/pylabrobot/resources/carrier.py @@ -10,7 +10,7 @@ from .resource import Resource from .resource_stack import ResourceStack -logger = logging.getLogger("pylabrobot") +logger = logging.getLogger(__name__) S = TypeVar("S", bound=ResourceHolder) diff --git a/pylabrobot/resources/carrier_tests.py b/pylabrobot/resources/carrier_tests.py index 0b704899824..a9dbd05f29a 100644 --- a/pylabrobot/resources/carrier_tests.py +++ b/pylabrobot/resources/carrier_tests.py @@ -221,13 +221,7 @@ def test_serialization(self): "size_x": 135.0, "size_y": 497.0, "size_z": 13.0, - "location": None, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, "category": "tip_carrier", - "model": None, - "barcode": None, - "preferred_pickup_location": None, - "parent_name": None, "children": [ { "name": "tip_car-0", @@ -241,14 +235,9 @@ def test_serialization(self): "y": 20, "z": 30, }, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, "category": "resource_holder", - "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, - "children": [], "parent_name": "tip_car", - "model": None, - "barcode": None, - "preferred_pickup_location": None, + "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, }, { "name": "tip_car-1", @@ -262,14 +251,9 @@ def test_serialization(self): "y": 50, "z": 30, }, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, "category": "resource_holder", - "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, - "children": [], "parent_name": "tip_car", - "model": None, - "barcode": None, - "preferred_pickup_location": None, + "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, }, { "name": "tip_car-2", @@ -283,14 +267,9 @@ def test_serialization(self): "y": 80, "z": 30, }, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, "category": "resource_holder", - "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, - "children": [], "parent_name": "tip_car", - "model": None, - "barcode": None, - "preferred_pickup_location": None, + "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, }, { "name": "tip_car-3", @@ -304,14 +283,9 @@ def test_serialization(self): "y": 130, "z": 30, }, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, "category": "resource_holder", - "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, - "children": [], "parent_name": "tip_car", - "model": None, - "barcode": None, - "preferred_pickup_location": None, + "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, }, { "name": "tip_car-4", @@ -325,14 +299,9 @@ def test_serialization(self): "y": 160, "z": 30, }, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, "category": "resource_holder", - "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, - "children": [], "parent_name": "tip_car", - "model": None, - "barcode": None, - "preferred_pickup_location": None, + "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, }, ], }, diff --git a/pylabrobot/resources/container_tests.py b/pylabrobot/resources/container_tests.py index e2d8660160c..4db47840f76 100644 --- a/pylabrobot/resources/container_tests.py +++ b/pylabrobot/resources/container_tests.py @@ -1,7 +1,7 @@ import json import unittest -from pylabrobot.liquid_handling.errors import ChannelsDoNotFitError +from pylabrobot.legacy.liquid_handling.errors import ChannelsDoNotFitError from pylabrobot.serializer import serialize from .container import Container @@ -34,21 +34,13 @@ def compute_height_from_volume(volume): "size_x": 10, "size_y": 10, "size_z": 10, + "type": "Container", "material_z_thickness": 1, - "category": None, - "model": None, - "barcode": None, - "preferred_pickup_location": None, "max_volume": 1000, "compute_volume_from_height": serialize(compute_volume_from_height), "compute_height_from_volume": serialize(compute_height_from_volume), "height_volume_data": None, "no_go_zones": [], - "parent_name": None, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, - "type": "Container", - "children": [], - "location": None, }, ) @@ -222,7 +214,7 @@ def _make_container(self, size_y, no_go_zones=None): return Container(name="c", size_x=10, size_y=size_y, size_z=10, no_go_zones=no_go_zones) def test_no_zones_uses_standard_spread(self): - from pylabrobot.liquid_handling.channel_positioning import compute_channel_offsets + from pylabrobot.legacy.liquid_handling.channel_positioning import compute_channel_offsets c = self._make_container(90) result = compute_channel_offsets(c, num_channels=1) @@ -231,7 +223,7 @@ def test_no_zones_uses_standard_spread(self): self.assertAlmostEqual(result[0].y, 0.0) def test_1_channel_in_2_compartments(self): - from pylabrobot.liquid_handling.channel_positioning import compute_channel_offsets + from pylabrobot.legacy.liquid_handling.channel_positioning import compute_channel_offsets # 90mm container, divider at Y=44-46 -> 2 compartments [0,44] and [46,90] # edge_clearance = 2.0 @@ -248,7 +240,7 @@ def test_1_channel_in_2_compartments(self): self.assertAlmostEqual(result[0].y, 23.0) def test_2_channels_across_2_compartments(self): - from pylabrobot.liquid_handling.channel_positioning import compute_channel_offsets + from pylabrobot.legacy.liquid_handling.channel_positioning import compute_channel_offsets c = self._make_container( 90, @@ -260,7 +252,7 @@ def test_2_channels_across_2_compartments(self): self.assertGreater(result[0].y, result[1].y) def test_4_channels_across_2_compartments(self): - from pylabrobot.liquid_handling.channel_positioning import compute_channel_offsets + from pylabrobot.legacy.liquid_handling.channel_positioning import compute_channel_offsets c = self._make_container( 90, @@ -270,7 +262,7 @@ def test_4_channels_across_2_compartments(self): self.assertEqual(len(result), 4) def test_raises_when_impossible(self): - from pylabrobot.liquid_handling.channel_positioning import compute_channel_offsets + from pylabrobot.legacy.liquid_handling.channel_positioning import compute_channel_offsets # Entire container is no-go c = self._make_container( @@ -281,7 +273,7 @@ def test_raises_when_impossible(self): compute_channel_offsets(c, num_channels=1) def test_3_compartments_6_channels(self): - from pylabrobot.liquid_handling.channel_positioning import compute_channel_offsets + from pylabrobot.legacy.liquid_handling.channel_positioning import compute_channel_offsets # 150mm container, 2 dividers -> 3 compartments, 6 channels -> 2 per compartment c = self._make_container( diff --git a/pylabrobot/resources/corning/axygen/plates.py b/pylabrobot/resources/corning/axygen/plates.py index fe8338fc98f..879fea5c3b4 100644 --- a/pylabrobot/resources/corning/axygen/plates.py +++ b/pylabrobot/resources/corning/axygen/plates.py @@ -16,7 +16,7 @@ def cor_axy_24_wellplate_10mL_Vb(name: str) -> Plate: """ - Corning cat. no.: P-DW-10ML-24-C-S + Corning cat. no.: P-DW-10ML-24-C-S, P-DW-10ML-24-C - manufacturer_link: https://ecatalog.corning.com/life-sciences/b2b/UK/en/Genomics-&-Molecular-Biology/Automation-Consumables/Deep-Well-Plate/Axygen%C2%AE-Deep-Well-and-Assay-Plates/p/P-DW-10ML-24-C - brand: Axygen - distributor: (Fisher Scientific, 12557837) diff --git a/pylabrobot/resources/deck.py b/pylabrobot/resources/deck.py index f2805bc7038..47e30c9d8c9 100644 --- a/pylabrobot/resources/deck.py +++ b/pylabrobot/resources/deck.py @@ -45,7 +45,7 @@ def __init__( def serialize(self) -> dict: """Serialize this deck.""" super_serialized = super().serialize() - del super_serialized["model"] # deck's don't typically have a model + super_serialized.pop("model", None) # deck's don't typically have a model return super_serialized def _check_naming_conflicts(self, resource: Resource): diff --git a/pylabrobot/resources/hamilton/hamilton_deck_tests.py b/pylabrobot/resources/hamilton/hamilton_deck_tests.py index 71d81f5fd71..d41199de305 100644 --- a/pylabrobot/resources/hamilton/hamilton_deck_tests.py +++ b/pylabrobot/resources/hamilton/hamilton_deck_tests.py @@ -102,9 +102,9 @@ def test_assign_gigantic_resource(self): self.assertEqual( log.output, [ - "WARNING:pylabrobot:Resource 'HUGE' is very high on the deck: 412.42 mm. Be " + "WARNING:pylabrobot.resources.hamilton.hamilton_decks:Resource 'HUGE' is very high on the deck: 412.42 mm. Be " "careful when traversing the deck.", - "WARNING:pylabrobot:Resource 'HUGE' is very high on the deck: 412.42 mm. Be " + "WARNING:pylabrobot.resources.hamilton.hamilton_decks:Resource 'HUGE' is very high on the deck: 412.42 mm. Be " "careful when grabbing this resource.", ], ) diff --git a/pylabrobot/resources/hamilton/hamilton_decks.py b/pylabrobot/resources/hamilton/hamilton_decks.py index 43ebc1f9cc4..8c0b9d3fff9 100644 --- a/pylabrobot/resources/hamilton/hamilton_decks.py +++ b/pylabrobot/resources/hamilton/hamilton_decks.py @@ -13,7 +13,7 @@ from pylabrobot.resources.tip_rack import TipRack, TipSpot from pylabrobot.resources.trash import Trash -logger = logging.getLogger("pylabrobot") +logger = logging.getLogger(__name__) _RAILS_WIDTH = 22.5 # space between rails (mm) diff --git a/pylabrobot/resources/hamilton/nimbus_decks.py b/pylabrobot/resources/hamilton/nimbus_decks.py index f82d19a5ebe..00b90c2cbac 100644 --- a/pylabrobot/resources/hamilton/nimbus_decks.py +++ b/pylabrobot/resources/hamilton/nimbus_decks.py @@ -17,7 +17,7 @@ from pylabrobot.resources.trash import Trash from pylabrobot.serializer import serialize -logger = logging.getLogger("pylabrobot") +logger = logging.getLogger(__name__) class NimbusDeck(HamiltonDeck): diff --git a/pylabrobot/resources/hamilton/tip_creators.py b/pylabrobot/resources/hamilton/tip_creators.py index a6c6df89dce..985f6d4fcdf 100644 --- a/pylabrobot/resources/hamilton/tip_creators.py +++ b/pylabrobot/resources/hamilton/tip_creators.py @@ -93,25 +93,13 @@ def __repr__(self) -> str: def serialize(self): super_serialized = super().serialize() - del super_serialized["fitting_depth"] # inferred from tip size + super_serialized.pop("fitting_depth", None) # inferred from tip size return { **super_serialized, "pickup_method": self.pickup_method.name, "tip_size": self.tip_size.name, } - @classmethod - def deserialize(cls, data): - return HamiltonTip( - name=data["name"], - has_filter=data["has_filter"], - total_tip_length=data["total_tip_length"], - nominal_volume=data.get("nominal_volume"), - maximal_volume=data["maximal_volume"], - tip_size=TipSize[data["tip_size"]], - pickup_method=TipPickupMethod[data["pickup_method"]], - ) - def standard_volume_tip_no_filter(name: Optional[str] = None) -> HamiltonTip: """Deprecated. Use :func:`hamilton_tip_300uL` instead.""" diff --git a/pylabrobot/resources/hamilton/vantage_decks.py b/pylabrobot/resources/hamilton/vantage_decks.py index 56d06d6654b..01797975a69 100644 --- a/pylabrobot/resources/hamilton/vantage_decks.py +++ b/pylabrobot/resources/hamilton/vantage_decks.py @@ -61,5 +61,5 @@ def rails_to_location(self, rails: int) -> Coordinate: def serialize(self) -> dict: super_serialized = super().serialize() for key in ["size_x", "size_y", "size_z", "num_rails"]: - super_serialized.pop(key) + super_serialized.pop(key, None) return {"size": self.size, **super_serialized} diff --git a/pylabrobot/resources/height_functions.py b/pylabrobot/resources/height_functions.py index 390b682fd05..20deb4376fb 100644 --- a/pylabrobot/resources/height_functions.py +++ b/pylabrobot/resources/height_functions.py @@ -3,7 +3,7 @@ def _height_of_volume_in_spherical_cap(r: float, liquid_volume: float) -> float: This function is deprecated. Please use the equivalent function in src/pylabrobot/pylabrobot/resources/height_volume_functions.py """ - raise DeprecationWarning( + raise NotImplementedError( "This function is deprecated. Please use the equivalent function in " "src/pylabrobot/pylabrobot/resources/height_volume_functions.py" ) @@ -20,7 +20,7 @@ def calculate_liquid_height_in_container_2segments_square_vbottom( This function is deprecated. Please use the equivalent function in src/pylabrobot/pylabrobot/resources/height_volume_functions.py """ - raise DeprecationWarning( + raise NotImplementedError( "This function is deprecated. Please use the equivalent function in " "src/pylabrobot/pylabrobot/resources/height_volume_functions.py" ) @@ -33,7 +33,7 @@ def calculate_liquid_height_in_container_2segments_square_ubottom( This function is deprecated. Please use the equivalent function in src/pylabrobot/pylabrobot/resources/height_volume_functions.py """ - raise DeprecationWarning( + raise NotImplementedError( "This function is deprecated. Please use the equivalent function in " "src/pylabrobot/pylabrobot/resources/height_volume_functions.py" ) @@ -46,7 +46,7 @@ def calculate_liquid_height_in_container_2segments_round_vbottom( This function is deprecated. Please use the equivalent function in src/pylabrobot/pylabrobot/resources/height_volume_functions.py """ - raise DeprecationWarning( + raise NotImplementedError( "This function is deprecated. Please use the equivalent function in " "src/pylabrobot/pylabrobot/resources/height_volume_functions.py" ) @@ -59,7 +59,7 @@ def calculate_liquid_height_in_container_2segments_round_ubottom( This function is deprecated. Please use the equivalent function in src/pylabrobot/pylabrobot/resources/height_volume_functions.py """ - raise DeprecationWarning( + raise NotImplementedError( "This function is deprecated. Please use the equivalent function in " "src/pylabrobot/pylabrobot/resources/height_volume_functions.py" ) @@ -72,7 +72,7 @@ def calculate_liquid_height_container_1segment_round_fbottom( This function is deprecated. Please use the equivalent function in src/pylabrobot/pylabrobot/resources/height_volume_functions.py """ - raise DeprecationWarning( + raise NotImplementedError( "This function is deprecated. Please use the equivalent function in " "src/pylabrobot/pylabrobot/resources/height_volume_functions.py" ) diff --git a/pylabrobot/resources/petri_dish.py b/pylabrobot/resources/petri_dish.py index 2d9cbfe5dbf..74d5f142e4b 100644 --- a/pylabrobot/resources/petri_dish.py +++ b/pylabrobot/resources/petri_dish.py @@ -42,7 +42,7 @@ def __init__( def serialize(self): super_serialized = super().serialize() for key in ["size_x", "size_y", "size_z"]: - del super_serialized[key] + super_serialized.pop(key, None) return { **super_serialized, diff --git a/pylabrobot/resources/petri_dish_tests.py b/pylabrobot/resources/petri_dish_tests.py index a9e916c4b3e..4de5ca77088 100644 --- a/pylabrobot/resources/petri_dish_tests.py +++ b/pylabrobot/resources/petri_dish_tests.py @@ -18,23 +18,16 @@ def test_petri_dish_serialization(self): serialized, { "name": "petri_dish", + "type": "PetriDish", "category": "petri_dish", - "diameter": 90.0, - "height": 15.0, + "max_volume": 121500.0, "material_z_thickness": None, "compute_volume_from_height": None, "compute_height_from_volume": None, "height_volume_data": None, "no_go_zones": [], - "parent_name": None, - "type": "PetriDish", - "children": [], - "location": None, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, - "max_volume": 121500.0, - "model": None, - "barcode": None, - "preferred_pickup_location": None, + "diameter": 90.0, + "height": 15.0, }, ) @@ -45,18 +38,11 @@ def test_petri_dish_holder_serialization(self): serialized, { "name": "petri_dish_holder", - "category": "petri_dish_holder", + "type": "PetriDishHolder", "size_x": 127.76, "size_y": 85.48, "size_z": 14.5, - "parent_name": None, - "type": "PetriDishHolder", - "children": [], - "location": None, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, - "model": None, - "barcode": None, - "preferred_pickup_location": None, + "category": "petri_dish_holder", }, ) diff --git a/pylabrobot/resources/plate.py b/pylabrobot/resources/plate.py index 70dfdf6edd2..63d716e7f83 100644 --- a/pylabrobot/resources/plate.py +++ b/pylabrobot/resources/plate.py @@ -82,11 +82,12 @@ def assign_child_resource( return super().assign_child_resource(resource, location=location, reassign=reassign) def serialize(self) -> dict: - return { - **super().serialize(), - "plate_type": self.plate_type, - "stacking_z_height": self.stacking_z_height, - } + data = super().serialize() + if self.plate_type != "skirted": + data["plate_type"] = self.plate_type + if self.stacking_z_height is not None: + data["stacking_z_height"] = self.stacking_z_height + return data def __eq__(self, other) -> bool: # `stacking_z_height` is a physical dimension (the pitch a plate adds to a stack), so plates @@ -116,12 +117,16 @@ def get_well(self, identifier: Union[str, int, Tuple[int, int]]) -> "Well": return super().get_item(identifier) - def get_wells(self, identifier: Union[str, Sequence[int]]) -> List["Well"]: + def get_wells(self, identifier: Optional[Union[str, Sequence[int]]] = None) -> List["Well"]: """Get the wells with the given identifier. + If no identifier is given, all wells are returned. + See :meth:`~.get_items` for more information. """ + if identifier is None: + return super().get_items(list(range(self.num_items))) return super().get_items(identifier) def set_well_volumes( diff --git a/pylabrobot/resources/plate_adapter.py b/pylabrobot/resources/plate_adapter.py index 164d46d5318..a997ecf1698 100644 --- a/pylabrobot/resources/plate_adapter.py +++ b/pylabrobot/resources/plate_adapter.py @@ -10,7 +10,7 @@ from pylabrobot.resources.plate import Plate from pylabrobot.resources.resource import Resource -logger = logging.getLogger("pylabrobot") +logger = logging.getLogger(__name__) class PlateAdapter(Resource): @@ -117,25 +117,6 @@ def serialize(self) -> dict: "plate_z_offset": self.plate_z_offset, } - @classmethod - def deserialize(cls, data: dict, allow_marshal: bool = False) -> PlateAdapter: - return cls( - name=data["name"], - size_x=data["size_x"], - size_y=data["size_y"], - size_z=data["size_z"], - dx=data["dx"], - dy=data["dy"], - dz=data["dz"], - adapter_hole_size_x=data["adapter_hole_size_x"], - adapter_hole_size_y=data["adapter_hole_size_y"], - adapter_hole_dx=data["adapter_hole_dx"], - adapter_hole_dy=data["adapter_hole_dy"], - plate_z_offset=data["plate_z_offset"], - category=data.get("category"), - model=data.get("model"), - ) - def compute_plate_location(self, resource: Plate) -> Coordinate: """Compute the location of the `Plate` child resource in relationship to the `PlateAdapter` to align the `Plate` well-grid with the adapter's hole grid.""" diff --git a/pylabrobot/resources/plate_adapter_tests.py b/pylabrobot/resources/plate_adapter_tests.py index 9d3d1f0ce8c..1dd936fe530 100644 --- a/pylabrobot/resources/plate_adapter_tests.py +++ b/pylabrobot/resources/plate_adapter_tests.py @@ -29,14 +29,7 @@ def test_plate_adapter_serialization(self): "size_x": 128.0, "size_y": 86.0, "size_z": 15.0, - "location": None, - "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}, "category": "plate_adapter", - "model": None, - "barcode": None, - "preferred_pickup_location": None, - "children": [], - "parent_name": None, "dx": 0.0, "dy": 1.0, "dz": 2.0, diff --git a/pylabrobot/resources/resource.py b/pylabrobot/resources/resource.py index 492dbf4092e..e93394502f2 100644 --- a/pylabrobot/resources/resource.py +++ b/pylabrobot/resources/resource.py @@ -20,7 +20,7 @@ else: from typing_extensions import Self -logger = logging.getLogger("pylabrobot") +logger = logging.getLogger(__name__) def _compute_location_from_anchors( @@ -125,21 +125,30 @@ def get_size_z(self) -> float: return self._local_size_z def serialize(self) -> dict: - return { + data: dict = { "name": self.name, "type": self.__class__.__name__, "size_x": self._size_x, "size_y": self._size_y, "size_z": self._size_z, - "location": serialize(self.location), - "rotation": serialize(self.rotation), - "category": self.category, - "model": self.model, - "barcode": self.barcode.serialize() if self.barcode is not None else None, - "preferred_pickup_location": serialize(self.preferred_pickup_location), - "children": [child.serialize() for child in self.children], - "parent_name": self.parent.name if self.parent is not None else None, } + if self.location is not None: + data["location"] = serialize(self.location) + if not (self.rotation.x == 0 and self.rotation.y == 0 and self.rotation.z == 0): + data["rotation"] = serialize(self.rotation) + if self.category is not None: + data["category"] = self.category + if self.model is not None: + data["model"] = self.model + if self.barcode is not None: + data["barcode"] = self.barcode.serialize() + if self.preferred_pickup_location is not None: + data["preferred_pickup_location"] = serialize(self.preferred_pickup_location) + if self.children: + data["children"] = [child.serialize() for child in self.children] + if self.parent is not None: + data["parent_name"] = self.parent.name + return data @property def name(self) -> str: @@ -753,15 +762,16 @@ def deserialize(cls, data: dict, allow_marshal: bool = False) -> Self: "parent_name", "location", ]: # delete meta keys - del data_copy[key] - children_data = data_copy.pop("children") - rotation = data_copy.pop("rotation") + data_copy.pop(key, None) + children_data = data_copy.pop("children", []) + rotation = data_copy.pop("rotation", None) barcode = data_copy.pop("barcode", None) preferred_pickup_location = data_copy.pop("preferred_pickup_location", None) resource = subclass(**deserialize(data_copy, allow_marshal=allow_marshal)) - resource.rotation = Rotation.deserialize(rotation) # not pretty, should be done in init. + if rotation is not None: + resource.rotation = deserialize(rotation) # not pretty, should be done in init. if barcode is not None: - resource.barcode = Barcode.deserialize(barcode) + resource.barcode = Barcode(**barcode) if preferred_pickup_location is not None: resource.preferred_pickup_location = cast(Coordinate, deserialize(preferred_pickup_location)) @@ -875,7 +885,7 @@ def load_state(self, state: Dict[str, Any]) -> None: overriding this method should call ``super().load_state(state)``. """ if "rotation" in state: - self.rotation = Rotation.deserialize(state["rotation"]) + self.rotation = deserialize(state["rotation"]) # Developer note: you probably don't need to override this method. Instead, override `load_state`. def load_all_state(self, state: Dict[str, Dict[str, Any]]) -> None: diff --git a/pylabrobot/resources/resource_stack.py b/pylabrobot/resources/resource_stack.py index c526220f81f..f0857fb41cd 100644 --- a/pylabrobot/resources/resource_stack.py +++ b/pylabrobot/resources/resource_stack.py @@ -6,7 +6,7 @@ from pylabrobot.resources.resource import Resource from pylabrobot.resources.resource_holder import get_child_location -logger = logging.getLogger("pylabrobot") +logger = logging.getLogger(__name__) class ResourceStack(Resource): diff --git a/pylabrobot/resources/resource_tests.py b/pylabrobot/resources/resource_tests.py index 9ef3c987edb..7b393633d0a 100644 --- a/pylabrobot/resources/resource_tests.py +++ b/pylabrobot/resources/resource_tests.py @@ -224,27 +224,15 @@ def test_serialize(self): r.serialize(), { "name": "test", - "location": None, - "rotation": { - "type": "Rotation", - "x": 0, - "y": 0, - "z": 0, - }, "size_x": 10, "size_y": 10, "size_z": 10, "type": "Resource", - "children": [], - "category": None, - "parent_name": None, - "model": None, "barcode": { "data": "1234567890", "symbology": "code128", "position_on_resource": "left", }, - "preferred_pickup_location": None, }, ) @@ -257,13 +245,6 @@ def test_child_serialize(self): r.serialize(), { "name": "test", - "location": None, - "rotation": { - "type": "Rotation", - "x": 0, - "y": 0, - "z": 0, - }, "size_x": 10, "size_y": 10, "size_z": 10, @@ -277,29 +258,13 @@ def test_child_serialize(self): "y": 5, "z": 5, }, - "rotation": { - "type": "Rotation", - "x": 0, - "y": 0, - "z": 0, - }, "size_x": 1, "size_y": 1, "size_z": 1, "type": "Resource", - "children": [], - "category": None, "parent_name": "test", - "model": None, - "barcode": None, - "preferred_pickup_location": None, } ], - "category": None, - "parent_name": None, - "model": None, - "barcode": None, - "preferred_pickup_location": None, }, ) diff --git a/pylabrobot/resources/rotation.py b/pylabrobot/resources/rotation.py index 26cce995477..59b7e822adb 100644 --- a/pylabrobot/resources/rotation.py +++ b/pylabrobot/resources/rotation.py @@ -63,10 +63,6 @@ def __str__(self) -> str: def __add__(self, other) -> "Rotation": return Rotation(x=self.x + other.x, y=self.y + other.y, z=self.z + other.z) - @staticmethod - def deserialize(data) -> "Rotation": - return Rotation(data["x"], data["y"], data["z"]) - def __repr__(self) -> str: return self.__str__() diff --git a/pylabrobot/resources/thermo_fisher/plates.py b/pylabrobot/resources/thermo_fisher/plates.py index 5cba5123d43..f508fde019c 100644 --- a/pylabrobot/resources/thermo_fisher/plates.py +++ b/pylabrobot/resources/thermo_fisher/plates.py @@ -180,7 +180,7 @@ def Thermo_AB_96_wellplate_300ul_Vb_EnduraPlate(name: str, with_lid: bool = Fals - Thermal resistance: ? - Cleanliness: 'Certified DNA-, RNAse-, and PCR inhibitor-free with in-process sampling tests'. - ANSI/SLAS-format for compatibility with automated systems. - - optimal pickup_distance_from_top=4 mm. + - optimal pickup_distance_from_top=4 mm (i.e. pickup_distance_from_bottom=size_z-4). - total_volume = 300 ul. - working_volume = 200 ul (recommended by manufacturer). """ @@ -310,7 +310,7 @@ def thermo_AB_96_wellplate_300ul_Vb_MicroAmp(name: str, with_lid: bool = False) - Thermal resistance: ? - Cleanliness: 'Certified DNA/RNase Free'. - Warning: NOT ANSI/SLAS-format! - - optimal pickup_distance_from_top = 6 mm. + - optimal pickup_distance_from_top = 6 mm (i.e. pickup_distance_from_bottom=size_z-6). - total_volume = 300 ul. - working_volume = 200 ul (recommended by manufacturer). diff --git a/pylabrobot/resources/tip_rack.py b/pylabrobot/resources/tip_rack.py index fcc32b38927..ef405815455 100644 --- a/pylabrobot/resources/tip_rack.py +++ b/pylabrobot/resources/tip_rack.py @@ -122,7 +122,7 @@ def make_tip(name: str) -> Tip: name=data["name"], size_x=data["size_x"], size_y=data["size_y"], - size_z=data["size_z"], + size_z=data.get("size_z", 0), make_tip=make_tip, category=data.get("category", "tip_spot"), ) diff --git a/pylabrobot/resources/tip_tests.py b/pylabrobot/resources/tip_tests.py index 5ebdaaeb1f5..d3c98cacb37 100644 --- a/pylabrobot/resources/tip_tests.py +++ b/pylabrobot/resources/tip_tests.py @@ -63,7 +63,7 @@ def test_deserialize_subclass(self): TipPickupMethod.OUT_OF_RACK, name="test_tip", ) - self.assertEqual(HamiltonTip.deserialize(tip.serialize()), tip) + self.assertEqual(deserialize(tip.serialize()), tip) def test_nominal_volume_defaults_to_maximal_volume(self): """An unspecified nominal_volume falls back to maximal_volume (the physical capacity).""" @@ -82,4 +82,4 @@ def test_deserialize_legacy_without_nominal_volume(self): ) legacy = tip.serialize() del legacy["nominal_volume"] - self.assertEqual(HamiltonTip.deserialize(legacy).nominal_volume, 400.0) + self.assertEqual(deserialize(legacy).nominal_volume, 400.0) diff --git a/pylabrobot/resources/tip_tracker.py b/pylabrobot/resources/tip_tracker.py index 6291659aec2..1a876ce86f5 100644 --- a/pylabrobot/resources/tip_tracker.py +++ b/pylabrobot/resources/tip_tracker.py @@ -26,8 +26,10 @@ def does_tip_tracking() -> bool: def no_tip_tracking(): old_value = this.tip_tracking_enabled this.tip_tracking_enabled = False # type: ignore - yield - this.tip_tracking_enabled = old_value # type: ignore + try: + yield + finally: + this.tip_tracking_enabled = old_value # type: ignore TrackerCallback = Callable[[], None] @@ -119,7 +121,8 @@ def commit(self) -> None: def rollback(self) -> None: """Rollback the pending operations.""" - assert not self.is_disabled, "Tip tracker is disabled. Call `enable()`." + if self.is_disabled: + raise RuntimeError("Tip tracker is disabled. Call `enable()`.") self._pending_tip = self._tip def clear(self) -> None: diff --git a/pylabrobot/resources/trough.py b/pylabrobot/resources/trough.py index 55aca488c44..f92e74d35f5 100644 --- a/pylabrobot/resources/trough.py +++ b/pylabrobot/resources/trough.py @@ -52,3 +52,10 @@ def __init__( ) self.through_base_to_container_base = through_base_to_container_base self.bottom_type = bottom_type + + def serialize(self) -> dict: + data = super().serialize() + data["bottom_type"] = self.bottom_type.value + if self.through_base_to_container_base != 0: + data["through_base_to_container_base"] = self.through_base_to_container_base + return data diff --git a/pylabrobot/resources/volume_functions.py b/pylabrobot/resources/volume_functions.py index ddc914c9fb5..20d75d5ba40 100644 --- a/pylabrobot/resources/volume_functions.py +++ b/pylabrobot/resources/volume_functions.py @@ -9,7 +9,7 @@ def calculate_liquid_volume_container_2segments_square_vbottom( This function is deprecated. Please use the equivalent function in src/pylabrobot/pylabrobot/resources/height_volume_functions.py """ - raise DeprecationWarning( + raise NotImplementedError( "This function is deprecated. Please use the equivalent function in " "src/pylabrobot/pylabrobot/resources/height_volume_functions.py" ) @@ -22,7 +22,7 @@ def calculate_liquid_volume_container_2segments_square_ubottom( This function is deprecated. Please use the equivalent function in src/pylabrobot/pylabrobot/resources/height_volume_functions.py """ - raise DeprecationWarning( + raise NotImplementedError( "This function is deprecated. Please use the equivalent function in " "src/pylabrobot/pylabrobot/resources/height_volume_functions.py" ) @@ -35,7 +35,7 @@ def calculate_liquid_volume_container_2segments_round_vbottom( This function is deprecated. Please use the equivalent function in src/pylabrobot/pylabrobot/resources/height_volume_functions.py """ - raise DeprecationWarning( + raise NotImplementedError( "This function is deprecated. Please use the equivalent function in " "src/pylabrobot/pylabrobot/resources/height_volume_functions.py" ) @@ -48,7 +48,7 @@ def calculate_liquid_volume_container_2segments_round_ubottom( This function is deprecated. Please use the equivalent function in src/pylabrobot/pylabrobot/resources/height_volume_functions.py """ - raise DeprecationWarning( + raise NotImplementedError( "This function is deprecated. Please use the equivalent function in " "src/pylabrobot/pylabrobot/resources/height_volume_functions.py" ) @@ -61,7 +61,7 @@ def calculate_liquid_volume_container_1segment_round_fbottom( This function is deprecated. Please use the equivalent function in src/pylabrobot/pylabrobot/resources/height_volume_functions.py """ - raise DeprecationWarning( + raise NotImplementedError( "This function is deprecated. Please use the equivalent function in " "src/pylabrobot/pylabrobot/resources/height_volume_functions.py" ) diff --git a/pylabrobot/resources/volume_tracker.py b/pylabrobot/resources/volume_tracker.py index ca942faed0a..2d5642d9310 100644 --- a/pylabrobot/resources/volume_tracker.py +++ b/pylabrobot/resources/volume_tracker.py @@ -26,8 +26,10 @@ def does_volume_tracking() -> bool: def no_volume_tracking(): old_value = this.volume_tracking_enabled this.volume_tracking_enabled = False # type: ignore - yield - this.volume_tracking_enabled = old_value # type: ignore + try: + yield + finally: + this.volume_tracking_enabled = old_value # type: ignore VolumeTrackerCallback = Callable[[], None] @@ -139,7 +141,8 @@ def get_liquids(self, top_volume: float) -> List[Tuple[Optional[Liquid], float]] def commit(self) -> None: """Commit the pending operations.""" - assert not self.is_disabled, f"Volume tracker {self.thing} is disabled. Call `enable()`." + if self.is_disabled: + raise RuntimeError(f"Volume tracker {self.thing} is disabled. Call `enable()`.") self.volume = self.pending_volume if self._callback is not None: @@ -147,7 +150,8 @@ def commit(self) -> None: def rollback(self) -> None: """Rollback the pending operations.""" - assert not self.is_disabled, "Volume tracker is disabled. Call `enable()`." + if self.is_disabled: + raise RuntimeError("Volume tracker is disabled. Call `enable()`.") self.pending_volume = self.volume def serialize(self) -> dict: diff --git a/pylabrobot/resources/well_tests.py b/pylabrobot/resources/well_tests.py index 284c7cca82a..74371c86625 100644 --- a/pylabrobot/resources/well_tests.py +++ b/pylabrobot/resources/well_tests.py @@ -23,23 +23,17 @@ def test_serialize(self): "size_x": 1, "size_y": 2, "size_z": 3, - "material_z_thickness": None, - "bottom_type": "flat", - "cross_section_type": "circle", - "max_volume": 10, - "model": "model", - "barcode": None, - "preferred_pickup_location": None, - "category": "well", - "children": [], "type": "Well", - "parent_name": None, - "location": None, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, + "category": "well", + "model": "model", + "max_volume": 10, + "material_z_thickness": None, "compute_volume_from_height": None, "compute_height_from_volume": None, "height_volume_data": None, "no_go_zones": [], + "bottom_type": "flat", + "cross_section_type": "circle", }, ) diff --git a/pylabrobot/sartorius/__init__.py b/pylabrobot/sartorius/__init__.py new file mode 100644 index 00000000000..46e6cdef307 --- /dev/null +++ b/pylabrobot/sartorius/__init__.py @@ -0,0 +1,4 @@ +from .entris import ( + SartoriusEntris2, + SartoriusError, +) diff --git a/pylabrobot/sartorius/entris.py b/pylabrobot/sartorius/entris.py new file mode 100644 index 00000000000..19d2fd242c7 --- /dev/null +++ b/pylabrobot/sartorius/entris.py @@ -0,0 +1,200 @@ +import asyncio +import logging +import time +from typing import Optional + +from pylabrobot.io.serial import Serial + +logger = logging.getLogger(__name__) + + +ESC = "\x1b" + +# Device error codes. A status line beginning with "Stat" whose remainder +# matches one of these keys is an error condition. +DEVICE_ERRORS = { + "APP.ERR": "Invalid weight value (applied weight too low, negative, or no sample on pan).", + "DIS.ERR": "Value cannot be shown in the display (incompatible with the display format).", + "High": "Overloaded: maximum weighing capacity exceeded.", + "ERR 55": "Overloaded: maximum weighing capacity exceeded.", + "Low": "Weighing converter modulation too low (no pan, weight removed, or a system fault).", + "ERR 54": "Weighing converter modulation too low (no pan, weight removed, or a system fault).", + "COMM.ERR": "No weight values received (no communication between control module and weigh cell).", + "PRT.ERR": "[Print] key is locked (data interface set to xBPI mode).", + "SYS.ERR": "System data is faulty.", + "ERR 02": "Cannot calibrate: zero-point error (device not zeroed, or loaded).", + "ERR 10": "Taring not possible: tare memory reserved by an application program.", + "ERR 11": "Weight value cannot be saved to tare memory (value is negative or zero).", +} + + +class SartoriusError(Exception): + """Exceptions raised by a Sartorius Entris II balance.""" + + def __init__(self, title: str, message: Optional[str] = None) -> None: + self.title = title + self.message = message + + def __str__(self) -> str: + return f"{self.title}: {self.message}" if self.message else self.title + + +class SartoriusEntris2: + """Sartorius Entris II balance. + + Interface spec: https://www.sartorius.hr/media/dypfvdsn/entris-ii-technical-note-en-sartorius.pdf + (archived: https://archive.vn/NU6DW) + + Serial settings: + 9600 baud, 8 data bits, ODD parity, 1 stop bit, no handshake, "\\r\\n" + terminator. + + Commands (SBI control commands, format " CR LF"): + + :: + + P print current weight value + T tare (Zero/Tara command) + V zero (Key ZERO) + x1_ query balance model + x2_ query serial number + The trailing "_" in x1_/x2_ is a literal underscore. is the escape + control byte 0x1B; per the spec it is optional, and CR LF terminates. + + Not verified: has NOT been tested against hardware in PyLabRobot. A warning + is emitted at setup. + """ + + def __init__( + self, + port: Optional[str] = None, + vid: int = 0x0403, + pid: int = 0x6001, + tare_settle_s: float = 2.0, + ): + self.tare_settle_s = tare_settle_s + self.serial_number: Optional[str] = None + self.io = Serial( + human_readable_device_name="Sartorius Entris II", + port=port, + vid=vid, + pid=pid, + baudrate=9600, + bytesize=8, + parity="O", + stopbits=1, + timeout=15, + ) + + async def setup(self) -> None: + logger.warning( + "SartoriusEntris2 has NOT been tested against hardware in PyLabRobot. " + "Please make a PR to remove this message if you have verified it on your hardware." + ) + await self.io.setup() + # Bring online: send P up to twice, tolerating errors. + for _ in range(2): + try: + await self.send_command("P") + break + except (SartoriusError, TimeoutError): + continue + self.serial_number = await self.send_command("x2_") + logger.info("[Sartorius %s] connected: serial_number=%s", self.io.port, self.serial_number) + + async def stop(self) -> None: + await self.io.stop() + + # === Command layer === + + def _frame(self, token: str) -> bytes: + """Build the on-wire bytes for a command token: CR LF.""" + return (ESC + token + "\r\n").encode("ascii") + + async def send_command(self, token: str, timeout: int = 15, read_reply: bool = True) -> str: + """Send a command token and return the trimmed, de-spaced reply. + + Args: + token: command token, e.g. "P", "T", "x1\\_" (ESC prefix added here). + timeout: seconds to wait for a response. + read_reply: if False, do not read a reply (used for tare, which sends no + parseable line). + """ + + await self.io.reset_input_buffer() + frame = self._frame(token) + logger.debug("[Sartorius] send: %s", frame) + await self.io.write(frame) + + if not read_reply: + return "" + + raw_response = b"" + timeout_time = time.time() + timeout + while True: + raw_response = await self.io.readline() + await asyncio.sleep(0.001) + if time.time() > timeout_time: + raise TimeoutError("Timeout while waiting for response from scale.") + if raw_response != b"": + break + logger.debug("[Sartorius] recv: %s", raw_response) + response = raw_response.decode("ascii", errors="replace").strip() + if not response: + raise SartoriusError(title="No response from device", message=f"for command {token!r}") + + self._raise_for_device_error(response) + return response.replace(" ", "") + + @staticmethod + def _raise_for_device_error(response: str) -> None: + """Raise SartoriusError if the reply is a "Stat" error status line.""" + if response.startswith("Stat"): + remainder = response.replace("Stat", "").strip() + if any(k in remainder for k in ("ERR", "High", "Low")): + raise SartoriusError( + title=f"Device error: {remainder}", + message=DEVICE_ERRORS.get(remainder), + ) + + @staticmethod + def _parse_weight(response: str) -> float: + """Extract a float weight from a print reply. + + Keeps only [0-9 . + -] then parses, rejecting more than one decimal point. + """ + filtered = "".join(c for c in response if c.isdigit() or c in ".+-") + if filtered.count(".") > 1 or filtered in ("", "+", "-"): + raise SartoriusError(title="Invalid weight value", message=repr(response)) + try: + return float(filtered) + except ValueError as exc: + raise SartoriusError(title="Invalid weight value", message=repr(response)) from exc + + # === Public API === + + async def measure_weight(self) -> float: + """Measure the current weight in grams (P).""" + weight = self._parse_weight(await self.send_command("P")) + logger.info("[Sartorius %s] weight read: weight_g=%s", self.serial_number, weight) + return weight + + async def tare(self) -> None: + """Tare the balance (T).""" + await self.send_command("T", read_reply=False) + await asyncio.sleep(self.tare_settle_s) + logger.info("[Sartorius %s] tared", self.serial_number) + + async def zero(self) -> None: + """Zero the balance (V, Key ZERO).""" + await self.send_command("V", read_reply=False) + await asyncio.sleep(self.tare_settle_s) + logger.info("[Sartorius %s] zeroed", self.serial_number) + + async def get_model(self) -> str: + """Query the balance model string (x1_).""" + return await self.send_command("x1_") + + async def get_serial_number(self) -> str: + """Query the balance serial number (x2_).""" + return await self.send_command("x2_") diff --git a/pylabrobot/scales/__init__.py b/pylabrobot/scales/__init__.py index 5e798de1388..4e31fc9d5f0 100644 --- a/pylabrobot/scales/__init__.py +++ b/pylabrobot/scales/__init__.py @@ -1,7 +1,9 @@ -from pylabrobot.scales.chatterbox import ScaleChatterboxBackend -from pylabrobot.scales.mettler_toledo_backend import ( - MettlerToledoWXS205SDU, - MettlerToledoWXS205SDUBackend, +import warnings + +warnings.warn( + "Importing from pylabrobot.scales is deprecated. Use pylabrobot.legacy.scales instead.", + DeprecationWarning, + stacklevel=2, ) -from pylabrobot.scales.scale import Scale -from pylabrobot.scales.scale_backend import ScaleBackend + +from pylabrobot.legacy.scales import * # noqa: F401,F403,E402 diff --git a/pylabrobot/sealing/__init__.py b/pylabrobot/sealing/__init__.py index 1e9b56691ad..d22c8f7896e 100644 --- a/pylabrobot/sealing/__init__.py +++ b/pylabrobot/sealing/__init__.py @@ -1,3 +1,9 @@ -from .a4s import a4s -from .a4s_backend import A4SBackend -from .sealer import Sealer +import warnings + +warnings.warn( + "Importing from pylabrobot.sealing is deprecated. Use pylabrobot.legacy.sealing instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.sealing import * # noqa: F401,F403,E402 diff --git a/pylabrobot/shaking/__init__.py b/pylabrobot/shaking/__init__.py index f17a298d2d8..869847ea930 100644 --- a/pylabrobot/shaking/__init__.py +++ b/pylabrobot/shaking/__init__.py @@ -1,3 +1,9 @@ -from .backend import ShakerBackend -from .chatterbox import ShakerChatterboxBackend -from .shaker import Shaker +import warnings + +warnings.warn( + "Importing from pylabrobot.shaking is deprecated. Use pylabrobot.legacy.shaking instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.shaking import * # noqa: F401,F403,E402 diff --git a/pylabrobot/storage/__init__.py b/pylabrobot/storage/__init__.py index 3ccfc9cd4de..b79b3192094 100644 --- a/pylabrobot/storage/__init__.py +++ b/pylabrobot/storage/__init__.py @@ -1,6 +1,9 @@ -from .backend import IncubatorBackend -from .chatterbox import IncubatorChatterboxBackend -from .cytomat import CytomatBackend -from .incubator import Incubator -from .inheco.scila import SCILABackend -from .liconic import ExperimentalLiconicBackend +import warnings + +warnings.warn( + "Importing from pylabrobot.storage is deprecated. Use pylabrobot.legacy.storage instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.storage import * # noqa: F401,F403,E402 diff --git a/pylabrobot/temperature_controlling/__init__.py b/pylabrobot/temperature_controlling/__init__.py index 307117ced55..b74feceb0ca 100644 --- a/pylabrobot/temperature_controlling/__init__.py +++ b/pylabrobot/temperature_controlling/__init__.py @@ -1,6 +1,10 @@ -from .chatterbox import TemperatureControllerChatterboxBackend -from .inheco.control_box import InhecoTECControlBox -from .inheco.cpac import inheco_cpac_ultraflat -from .opentrons import OpentronsTemperatureModuleV2 -from .opentrons_backend import OpentronsTemperatureModuleBackend -from .temperature_controller import TemperatureController +import warnings + +warnings.warn( + "Importing from pylabrobot.temperature_controlling is deprecated. " + "Use pylabrobot.legacy.temperature_controlling instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.temperature_controlling import * # noqa: F401,F403,E402 diff --git a/pylabrobot/tests/serializer_tests.py b/pylabrobot/tests/serializer_tests.py index 356ae12cafd..8b8e3510816 100644 --- a/pylabrobot/tests/serializer_tests.py +++ b/pylabrobot/tests/serializer_tests.py @@ -1,6 +1,9 @@ import math -from pylabrobot.serializer import deserialize, serialize +from pylabrobot.serializer import ( + deserialize, + serialize, +) def test_serialize_deserialize_closure(): diff --git a/pylabrobot/thermo_fisher/__init__.py b/pylabrobot/thermo_fisher/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/thermo_fisher/alps/__init__.py b/pylabrobot/thermo_fisher/alps/__init__.py new file mode 100644 index 00000000000..ddee5061dbf --- /dev/null +++ b/pylabrobot/thermo_fisher/alps/__init__.py @@ -0,0 +1,7 @@ +from pylabrobot.thermo_fisher.alps.alps300 import ALPS300Status, ThermoScientificALPS300 +from pylabrobot.thermo_fisher.alps.alps3000 import ALPS3000Status, ThermoScientificALPS3000 +from pylabrobot.thermo_fisher.alps.alps5000 import ALPS5000Status, ThermoScientificALPS5000 +from pylabrobot.thermo_fisher.alps.sealer import ( + ThermoScientificALPSError, + ThermoScientificALPSSealer, +) diff --git a/pylabrobot/thermo_fisher/alps/alps300.py b/pylabrobot/thermo_fisher/alps/alps300.py new file mode 100644 index 00000000000..37631993373 --- /dev/null +++ b/pylabrobot/thermo_fisher/alps/alps300.py @@ -0,0 +1,78 @@ +import enum +from typing import Dict + +from pylabrobot.thermo_fisher.alps.sealer import ThermoScientificALPSSealer + + +class ALPS300Status(enum.IntFlag): + """Status byte returned by the ``?`` command (two hex digits). + + ``Ready`` (0x00) is the all-clear value; any other bit is a condition that + must be cleared, or masked when ignorable, before an operation runs. + """ + + Ready = 0x00 + Busy = 0x01 + Error = 0x02 + Sealing = 0x04 + NotAtSealTemperature = 0x08 + NotAssigned = 0x10 + FoilLow = 0x20 + DoorOpen = 0x40 + ProceedOverridden = 0x80 + + +STATUS_MESSAGES: Dict[enum.IntFlag, str] = { + ALPS300Status.Busy: "Busy", + ALPS300Status.Error: "Error", + ALPS300Status.Sealing: "Sealing", + ALPS300Status.NotAtSealTemperature: "Waiting for seal temperature", + ALPS300Status.NotAssigned: "Status not assigned", + ALPS300Status.FoilLow: "No foil detected or foil low.", + ALPS300Status.DoorOpen: "Door open", + ALPS300Status.ProceedOverridden: "Proceed overridden", +} + +# Error codes returned by the ``E`` command. The firmware leaves gaps in the +# numbering (there is no code 2). +ERRORS = { + 1: "Low air pressure detected. Check air pressure.", + 3: "Shuttle not out.", + 4: "No foil or low foil error. Check foil and foil sensor.", + 5: "Seal transfer error.", + 6: "Placement error (can be caused of low air pressure).", + 7: "Thermocouple error - ambient temperature may be too low.", +} + +# Status bits tolerated while waiting for the sealer to accept an operation. +_READY_MASK = ( + ALPS300Status.NotAtSealTemperature | ALPS300Status.FoilLow | ALPS300Status.ProceedOverridden +) +_SETUP_MASK = ALPS300Status.NotAtSealTemperature | ALPS300Status.ProceedOverridden + + +class ThermoScientificALPS300(ThermoScientificALPSSealer): + """Thermo Scientific ALPS 300 heat sealer. + + Not verified: has NOT been tested against hardware in PyLabRobot. A warning + is emitted at setup. + """ + + _HUMAN_READABLE_NAME = "Thermo Scientific ALPS 300 Heat Sealer" + STATUS = ALPS300Status + STATUS_MESSAGES = STATUS_MESSAGES + ERRORS = ERRORS + + async def setup(self) -> None: + await self._open() + await self.wait_for_idle(_SETUP_MASK) + await self.set_temperature(self.preheating_temperature) + + async def seal(self, temperature: int = 160, duration: float = 2.5) -> None: + """Seal a plate. + + Args: + temperature: sealing temperature in degrees C (5..199). + duration: sealing time in seconds (0.5..9.9). + """ + await self._seal(temperature, duration, _READY_MASK) diff --git a/pylabrobot/thermo_fisher/alps/alps3000.py b/pylabrobot/thermo_fisher/alps/alps3000.py new file mode 100644 index 00000000000..5af1932e4c5 --- /dev/null +++ b/pylabrobot/thermo_fisher/alps/alps3000.py @@ -0,0 +1,77 @@ +import enum +from typing import Dict + +from pylabrobot.thermo_fisher.alps.sealer import ThermoScientificALPSSealer + + +class ALPS3000Status(enum.IntFlag): + """Status byte returned by the ``?`` command (two hex digits). + + ``Ready`` (0x00) is the all-clear value; any other bit is a condition that + must be cleared, or masked when ignorable, before an operation runs. + """ + + Ready = 0x00 + NoFoil = 0x01 + Error = 0x02 + Busy = 0x04 + WaitingForSealTemperature = 0x08 + PlateNotPresent = 0x10 + LowAir = 0x20 + FoilLoadModeAfterPark = 0x40 + ParkMode = 0x80 + + +STATUS_MESSAGES: Dict[enum.IntFlag, str] = { + ALPS3000Status.NoFoil: "No foil detected", + ALPS3000Status.Error: "Error", + ALPS3000Status.Busy: "Busy", + ALPS3000Status.WaitingForSealTemperature: "Waiting for seal temperature", + ALPS3000Status.PlateNotPresent: "Plate not present in shuttle", + ALPS3000Status.LowAir: "Low air pressure detected", + ALPS3000Status.FoilLoadModeAfterPark: "Waiting for foil to be loaded", + ALPS3000Status.ParkMode: "Park mode", +} + +# Error codes returned by the ``E`` command. The firmware leaves gaps in the +# numbering. +ERRORS = { + 1: "Down error", + 2: "Up error", + 3: "Shuttle not in", + 4: "Shuttle not out", + 7: "Thermocouple error - ambient temperature may be too low", + 9: "Sealer overheating", + 10: "No foil detected", + 13: "Mains Air error", +} + +# Status bits tolerated while waiting for the sealer to accept an operation. +_READY_MASK = ALPS3000Status.WaitingForSealTemperature | ALPS3000Status.PlateNotPresent + + +class ThermoScientificALPS3000(ThermoScientificALPSSealer): + """Thermo Scientific ALPS 3000 heat sealer. + + Not verified: has NOT been tested against hardware in PyLabRobot. A warning + is emitted at setup. + """ + + _HUMAN_READABLE_NAME = "Thermo Scientific ALPS 3000 Heat Sealer" + STATUS = ALPS3000Status + STATUS_MESSAGES = STATUS_MESSAGES + ERRORS = ERRORS + + async def setup(self) -> None: + await self._open() + await self.wait_for_idle(_READY_MASK) + await self.set_temperature(self.preheating_temperature) + + async def seal(self, temperature: int = 160, duration: float = 2.5) -> None: + """Seal a plate. + + Args: + temperature: sealing temperature in degrees C (5..199). + duration: sealing time in seconds (0.5..9.9). + """ + await self._seal(temperature, duration, _READY_MASK) diff --git a/pylabrobot/thermo_fisher/alps/alps5000.py b/pylabrobot/thermo_fisher/alps/alps5000.py new file mode 100644 index 00000000000..a77e19753af --- /dev/null +++ b/pylabrobot/thermo_fisher/alps/alps5000.py @@ -0,0 +1,204 @@ +import asyncio +import enum +import logging +from typing import Dict + +from pylabrobot.thermo_fisher.alps.sealer import ( + ThermoScientificALPSError, + ThermoScientificALPSSealer, +) + +logger = logging.getLogger(__name__) + + +class ALPS5000Status(enum.IntFlag): + """Status byte returned by the ``?`` command (two hex digits). + + ``Ready`` (0x00) is the all-clear value; any other bit is a condition that + must be cleared, or masked when ignorable, before an operation runs. + """ + + Ready = 0x00 + NoFoil = 0x01 + Error = 0x02 + Busy = 0x04 + WaitingForSealTemperature = 0x08 + PlateNotPresent = 0x10 + NotInitialised = 0x20 + ForceSensorActivated = 0x40 + PlatePresent = 0x80 + + +STATUS_MESSAGES: Dict[enum.IntFlag, str] = { + ALPS5000Status.NoFoil: "No foil detected", + ALPS5000Status.Error: "Error", + ALPS5000Status.Busy: "Busy", + ALPS5000Status.WaitingForSealTemperature: "Waiting for seal temperature", + ALPS5000Status.PlateNotPresent: "Plate not present in shuttle", + ALPS5000Status.NotInitialised: "Not initialised", + ALPS5000Status.ForceSensorActivated: "Force sensor is activated", + ALPS5000Status.PlatePresent: "Plate present in shuttle", +} + +# Error codes returned by the ``E`` command. +ERRORS = { + 1: "Down error", + 2: "Up error", + 3: "Shuttle not in", + 4: "Cutter Error", + 5: "Thermocouple error - ambient temperature may be too low", + 6: "Sealer overheating", + 7: "No foil detected", + 8: "No plate detected", + 9: "Force sensor error", + 10: "Engager Error", + 11: "Gripper Error", + 12: "Foil Clamp", + 13: "Dropped Foil", +} + +MIN_SEALING_FORCE = 5 +MAX_SEALING_FORCE = 50 +MIN_FOIL_LENGTH = 119 +MAX_FOIL_LENGTH = 128 +MIN_SEALING_DISTANCE = 10 +MAX_SEALING_DISTANCE = 50 + +# Status bits tolerated while waiting for the sealer to accept an operation. +_SETUP_MASK = ( + ALPS5000Status.WaitingForSealTemperature + | ALPS5000Status.PlateNotPresent + | ALPS5000Status.PlatePresent +) +_READY_MASK = ALPS5000Status.WaitingForSealTemperature | ALPS5000Status.PlatePresent + +# Time the instrument needs to complete its initialisation routine. +_INIT_SECONDS = 10.0 + + +class ThermoScientificALPS5000(ThermoScientificALPSSealer): + """Thermo Scientific ALPS 5000 heat sealer. + + Extends the shared ALPS protocol with initialisation, force-sensor control, + foil length, sealing distance, and shuttle movement: + + :: + + I initialise the instrument; replies ok or err + PS={f:02d} set sealing force, 5..50; replies ok + FS=1 / FS=0 enable / disable the force sensor; replies ok or err + L={n:03d} set foil length, 119..128; replies ok + DO={n:02d} set sealing distance, 10..50; replies ok + SI / SO move the shuttle in / out; replies ok or err + + Not verified: has NOT been tested against hardware in PyLabRobot. A warning + is emitted at setup. + """ + + _HUMAN_READABLE_NAME = "Thermo Scientific ALPS 5000 Heat Sealer" + STATUS = ALPS5000Status + STATUS_MESSAGES = STATUS_MESSAGES + ERRORS = ERRORS + + async def setup(self) -> None: + await self._open() + await self.wait_for_idle(_SETUP_MASK) + await self.initialise() + await asyncio.sleep(_INIT_SECONDS) + await self.wait_for_idle(_SETUP_MASK) + await self.set_temperature(self.preheating_temperature) + + async def seal( + self, + temperature: int = 160, + duration: float = 2.5, + use_force_sensor: bool = False, + ) -> None: + """Seal a plate. + + Args: + temperature: sealing temperature in degrees C (5..199). + duration: sealing time in seconds (0.5..9.9). + use_force_sensor: enable the force sensor for this seal. + """ + logger.info( + "[ALPS5000 %s] sealing at %d C for %.1fs (force sensor %s)", + self.io.port, + temperature, + duration, + "on" if use_force_sensor else "off", + ) + await self.wait_for_idle(_READY_MASK) + await self.set_sealing_time(duration) + await self.set_temperature(temperature) + await self.enable_force_sensor(use_force_sensor) + await self.wait_for_sealing_temperature() + reply = await self.send_command("S") + if reply != "ok": + raise ThermoScientificALPSError(title="Seal command failed", message=f"S returned {reply!r}") + await self.wait_for_idle(_READY_MASK) + + # === Extra commands === + + async def initialise(self) -> None: + """Run the instrument initialisation routine (``I``).""" + reply = await self.send_command("I") + if reply != "ok": + raise ThermoScientificALPSError( + title="Initialisation failed", message=f"I returned {reply!r}" + ) + + async def set_force(self, force: int) -> None: + """Set the sealing force (``PS``, 5..50).""" + if not MIN_SEALING_FORCE <= force <= MAX_SEALING_FORCE: + raise ValueError(f"force must be {MIN_SEALING_FORCE}..{MAX_SEALING_FORCE}") + command = f"PS={force:02d}" + reply = await self.send_command(command) + if reply != "ok": + raise ThermoScientificALPSError( + title="Setting sealing force failed", message=f"force {force} rejected with {reply!r}" + ) + + async def enable_force_sensor(self, enable: bool) -> None: + """Enable or disable the force sensor (``FS=1`` / ``FS=0``).""" + command = "FS=1" if enable else "FS=0" + reply = await self.send_command(command) + if reply != "ok": + raise ThermoScientificALPSError( + title="Setting force sensor failed", message=f"{command} returned {reply!r}" + ) + + async def set_foil_length(self, length: int) -> None: + """Set the foil length (``L``, 119..128).""" + if not MIN_FOIL_LENGTH <= length <= MAX_FOIL_LENGTH: + raise ValueError(f"foil length must be {MIN_FOIL_LENGTH}..{MAX_FOIL_LENGTH}") + command = f"L={length:03d}" + reply = await self.send_command(command) + if reply != "ok": + raise ThermoScientificALPSError( + title="Setting foil length failed", message=f"length {length} rejected with {reply!r}" + ) + + async def set_sealing_distance(self, distance: int) -> None: + """Set the sealing distance (``DO``, 10..50).""" + if not MIN_SEALING_DISTANCE <= distance <= MAX_SEALING_DISTANCE: + raise ValueError(f"distance must be {MIN_SEALING_DISTANCE}..{MAX_SEALING_DISTANCE}") + command = f"DO={distance:02d}" + reply = await self.send_command(command) + if reply != "ok": + raise ThermoScientificALPSError( + title="Setting sealing distance failed", + message=f"distance {distance} rejected with {reply!r}", + ) + + async def shuttle_in(self) -> None: + """Move the shuttle in (``SI``).""" + reply = await self.send_command("SI") + if reply != "ok": + raise ThermoScientificALPSError(title="Shuttle in failed", message=f"SI returned {reply!r}") + + async def shuttle_out(self) -> None: + """Move the shuttle out (``SO``).""" + reply = await self.send_command("SO") + if reply != "ok": + raise ThermoScientificALPSError(title="Shuttle out failed", message=f"SO returned {reply!r}") diff --git a/pylabrobot/thermo_fisher/alps/sealer.py b/pylabrobot/thermo_fisher/alps/sealer.py new file mode 100644 index 00000000000..8a2a6ac5d92 --- /dev/null +++ b/pylabrobot/thermo_fisher/alps/sealer.py @@ -0,0 +1,307 @@ +import abc +import asyncio +import enum +import logging +import time +from typing import Dict, Optional, Type + +from pylabrobot.io.serial import Serial + +logger = logging.getLogger(__name__) + + +class ThermoScientificALPSError(Exception): + """Error raised by a Thermo Scientific ALPS heat sealer.""" + + def __init__( + self, + title: str, + message: Optional[str] = None, + status: Optional[enum.IntFlag] = None, + error_code: Optional[int] = None, + ) -> None: + self.title = title + self.message = message + self.status = status + self.error_code = error_code + + def __str__(self) -> str: + return f"{self.title}: {self.message}" if self.message else self.title + + +class ThermoScientificALPSSealer(abc.ABC): + """Shared base for Thermo Scientific ALPS serial heat sealers (300 / 3000 / 5000). + + Every model speaks the same ASCII-over-RS-232 core: a command is written with + a CR terminator, the device echoes the command in front of its reply, and the + echo is stripped before the reply is parsed (e.g. ``A160`` -> ``A160ok`` -> + ``ok``; ``?`` -> ``?3f`` -> ``3f``). This base implements the transport, the + status/error polling state machine, and the temperature and time commands. + Subclasses supply the model-specific status flags, error table, + human-readable name, ``setup``, ``seal``, and any extra commands. + + Shared commands (ASCII, CR-terminated): + ? read status byte, two hex digits (see the model's STATUS enum) + E read error code, decimal digits (see the model's ERRORS table) + S seal the plate; replies ok or err + A{t:03d} set sealing temperature, degrees C (5..199) + B{t:02d} set sealing time, deciseconds (05..99 = 0.5..9.9 s) + C read sealing temperature setpoint, three digits + D read sealing time setpoint, two digits (deciseconds) + F read current heater temperature, three digits + + Serial settings: 9600 baud, 8 data bits, no parity, 1 stop bit, no handshake, + CR (``\\r``) terminator. + """ + + # Human-readable device name, used for the serial handle. Overridden per model. + _HUMAN_READABLE_NAME: str = "Thermo Scientific ALPS Heat Sealer" + + # Status flag enum returned by ``?``. Overridden per model. + STATUS: Type[enum.IntFlag] + # Description for each status bit, used to explain a not-ready condition. + STATUS_MESSAGES: Dict[enum.IntFlag, str] + # Text for each numeric error code returned by ``E``. + ERRORS: Dict[int, str] + + # The low status bits mean the same thing on every model, so the state machine + # below works against them directly rather than the per-model enum: bit 0x02 is + # the error flag, bit 0x04 is the operation-in-progress flag (named Sealing on + # the 300, Busy on the 3000 and 5000), and bit 0x08 is set while the heater is + # below the setpoint. + _READY = 0x00 + _ERROR = 0x02 + _BUSY = 0x04 + _NOT_AT_SEAL_TEMPERATURE = 0x08 + + MIN_SEALING_TEMPERATURE = 5 + MAX_SEALING_TEMPERATURE = 199 + MIN_SEALING_DURATION = 0.5 + MAX_SEALING_DURATION = 9.9 + + def __init__( + self, + port: str, + timeout: float = 5.0, + settle_time: float = 2.0, + preheating_temperature: int = 100, + ) -> None: + self.timeout = timeout + self.settle_time = settle_time + self.preheating_temperature = preheating_temperature + self.io = Serial( + human_readable_device_name=self._HUMAN_READABLE_NAME, + port=port, + baudrate=9600, + bytesize=8, + parity="N", + stopbits=1, + timeout=0.2, + ) + + # === Subclass contract === + + @abc.abstractmethod + async def setup(self) -> None: + """Open the connection and bring the sealer to a ready, pre-heated state.""" + + @abc.abstractmethod + async def seal(self, temperature: int, duration: float) -> None: + """Seal a plate at the given sealing temperature (C) and duration (s).""" + + async def _open(self) -> None: + """Open the port, wait out the post-open settling, and clear the buffer. + + Emits the not-tested warning: no ALPS sealer driver has been verified + against hardware in PyLabRobot yet. + """ + logger.warning( + "%s has NOT been tested against hardware in PyLabRobot. Please make a PR " + "to remove this message if you have verified it on your hardware.", + type(self).__name__, + ) + await self.io.setup() + # Give a USB-to-serial adapter a moment to settle after the port opens. + await asyncio.sleep(self.settle_time) + await self.io.reset_input_buffer() + + async def stop(self) -> None: + """Close the port.""" + await self.io.stop() + + # === Command layer === + + async def _read_line(self) -> str: + """Read bytes until a CR terminator, skipping stray LF.""" + start = time.time() + buf = bytearray() + while True: + b = await self.io.read(1) + if b == b"": + if time.time() - start > self.timeout: + raise TimeoutError("Timeout while waiting for response from sealer.") + continue + if b == b"\r": + break + if b == b"\n": + continue + buf += b + return buf.decode("ascii", errors="replace") + + async def send_command(self, command: str) -> str: + """Send a command and return the reply with the echoed command stripped. + + Args: + command: command string without the CR terminator, e.g. "A160", "?". + """ + await self.io.reset_input_buffer() + await self.io.write((command + "\r").encode("ascii")) + await asyncio.sleep(0.2) + text = await self._read_line() + return text.lstrip(command) if command else text.strip() + + # === State === + + async def request_status(self) -> enum.IntFlag: + """Read the status byte (``?``).""" + reply = await self.send_command("?") + try: + return self.STATUS(int(reply, 16)) + except ValueError as e: + raise ThermoScientificALPSError(title="Unexpected status reply", message=repr(reply)) from e + + async def request_error_code(self) -> int: + """Read the current error code (``E``).""" + reply = await self.send_command("E") + try: + return int(reply) + except ValueError as e: + raise ThermoScientificALPSError(title="Unexpected error reply", message=repr(reply)) from e + + async def wait_for_idle(self, ignore_mask: Optional[enum.IntFlag] = None) -> enum.IntFlag: + """Wait until the device is Ready, tolerating the bits set in ``ignore_mask``. + + Raises ThermoScientificALPSError if any bit outside ``ignore_mask`` is set; + if the Error bit is set, the error code and text are attached. + """ + mask = self._READY if ignore_mask is None else int(ignore_mask) + + status = await self.request_status() + while status != self._READY: + if status & self._BUSY: + await asyncio.sleep(0.5) + status = await self._wait_for_busy_cleared() + continue + + remaining = status & ~mask + if remaining == self._READY: + break + + description = ", ".join(m for bit, m in self.STATUS_MESSAGES.items() if remaining & bit) + if remaining & self._ERROR: + code = await self.request_error_code() + raise ThermoScientificALPSError( + title=f"Sealer error: {description}", + message=self.ERRORS.get(code), + status=remaining, + error_code=code, + ) + raise ThermoScientificALPSError(title=f"Sealer not ready: {description}", status=remaining) + return status + + async def _wait_for_busy_cleared(self, timeout: float = 60.0) -> enum.IntFlag: + start = time.time() + status = await self.request_status() + while status & self._BUSY: + if time.time() - start > timeout: + raise ThermoScientificALPSError(title="Timeout while waiting for the seal to complete") + await asyncio.sleep(0.5) + status = await self.request_status() + return status + + async def wait_for_sealing_temperature(self, timeout: float = 300.0) -> None: + """Block until the heater reaches the setpoint (NotAtSealTemperature clears).""" + start = time.time() + while await self.request_status() & self._NOT_AT_SEAL_TEMPERATURE: + if time.time() - start > timeout: + raise TimeoutError("Timeout while waiting for sealing temperature.") + await asyncio.sleep(5.0) + + # === Parameters === + + async def set_temperature(self, temperature: int) -> None: + """Set the sealing temperature in degrees C (``A``, 5..199).""" + if not self.MIN_SEALING_TEMPERATURE <= temperature <= self.MAX_SEALING_TEMPERATURE: + raise ValueError( + f"temperature must be {self.MIN_SEALING_TEMPERATURE}..{self.MAX_SEALING_TEMPERATURE} C" + ) + command = f"A{temperature:03d}" + reply = await self.send_command(command) + if reply != "ok": + raise ThermoScientificALPSError( + title="Setting sealing temperature failed", + message=f"temperature {temperature} rejected with {reply!r}", + ) + + async def set_sealing_time(self, seconds: float) -> None: + """Set the sealing time in seconds (``B``, 0.5..9.9).""" + if not self.MIN_SEALING_DURATION <= seconds <= self.MAX_SEALING_DURATION: + raise ValueError(f"time must be {self.MIN_SEALING_DURATION}..{self.MAX_SEALING_DURATION} s") + command = f"B{int(seconds * 10):02d}" + reply = await self.send_command(command) + if reply != "ok": + raise ThermoScientificALPSError( + title="Setting sealing time failed", + message=f"time {seconds} rejected with {reply!r}", + ) + + async def request_sealing_temperature(self) -> int: + """Read the sealing temperature setpoint in degrees C (``C``).""" + reply = await self.send_command("C") + try: + return int(reply) + except ValueError as e: + raise ThermoScientificALPSError( + title="Unexpected temperature reply", message=repr(reply) + ) from e + + async def request_sealing_time(self) -> float: + """Read the sealing time setpoint in seconds (``D``).""" + reply = await self.send_command("D") + try: + return int(reply) / 10.0 + except ValueError as e: + raise ThermoScientificALPSError(title="Unexpected time reply", message=repr(reply)) from e + + async def request_temperature(self) -> int: + """Read the current heater temperature in degrees C (``F``).""" + reply = await self.send_command("F") + try: + return int(reply) + except ValueError as e: + raise ThermoScientificALPSError( + title="Unexpected temperature reply", message=repr(reply) + ) from e + + async def _seal(self, temperature: int, duration: float, ready_mask: enum.IntFlag) -> None: + """Shared seal sequence: wait ready, apply time and temperature, wait for the + setpoint, seal, then wait for the operation to finish. + + ``ready_mask`` is the set of status bits tolerated while waiting for the + device to accept the seal; it differs between models. + """ + logger.info( + "[%s %s] sealing at %d C for %.1fs", + type(self).__name__, + self.io.port, + temperature, + duration, + ) + await self.wait_for_idle(ready_mask) + await self.set_sealing_time(duration) + await self.set_temperature(temperature) + await self.wait_for_sealing_temperature() + reply = await self.send_command("S") + if reply != "ok": + raise ThermoScientificALPSError(title="Seal command failed", message=f"S returned {reply!r}") + await self.wait_for_idle(ready_mask) diff --git a/pylabrobot/thermocycling/__init__.py b/pylabrobot/thermocycling/__init__.py index 084f2aed8d7..9b45a977559 100644 --- a/pylabrobot/thermocycling/__init__.py +++ b/pylabrobot/thermocycling/__init__.py @@ -1,7 +1,10 @@ -from .backend import ThermocyclerBackend -from .chatterbox import ThermocyclerChatterboxBackend -from .opentrons import OpentronsThermocyclerModuleV1, OpentronsThermocyclerModuleV2 -from .opentrons_backend import OpentronsThermocyclerBackend -from .standard import Step -from .thermo_fisher import * -from .thermocycler import Thermocycler +import warnings + +warnings.warn( + "Importing from pylabrobot.thermocycling is deprecated. " + "Use pylabrobot.legacy.thermocycling instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.thermocycling import * # noqa: F401,F403,E402 diff --git a/pylabrobot/tilting/__init__.py b/pylabrobot/tilting/__init__.py index 44f89afcdc8..b59e3045abb 100644 --- a/pylabrobot/tilting/__init__.py +++ b/pylabrobot/tilting/__init__.py @@ -1,4 +1,9 @@ -from .hamilton import HamiltonTiltModule -from .hamilton_backend import HamiltonTiltModuleBackend -from .tilter import Tilter -from .tilter_backend import TilterBackend +import warnings + +warnings.warn( + "Importing from pylabrobot.tilting is deprecated. Use pylabrobot.legacy.tilting instead.", + DeprecationWarning, + stacklevel=2, +) + +from pylabrobot.legacy.tilting import * # noqa: F401,F403,E402 diff --git a/pylabrobot/ufactory/xarm6/__init__.py b/pylabrobot/ufactory/xarm6/__init__.py new file mode 100644 index 00000000000..217124fdbeb --- /dev/null +++ b/pylabrobot/ufactory/xarm6/__init__.py @@ -0,0 +1,2 @@ +from .joints import * +from .xarm6 import * diff --git a/pylabrobot/ufactory/xarm6/joints.py b/pylabrobot/ufactory/xarm6/joints.py new file mode 100644 index 00000000000..0eaa71cb0e4 --- /dev/null +++ b/pylabrobot/ufactory/xarm6/joints.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from enum import IntEnum +from typing import Dict + +from pylabrobot.resources import Coordinate, Rotation + + +class XArm6Axis(IntEnum): + """Joint indices for the xArm 6 robot (1-indexed, matching SDK servo_id).""" + + J1 = 1 + J2 = 2 + J3 = 3 + J4 = 4 + J5 = 5 + J6 = 6 + + +# Joint angles in degrees, keyed by axis number (see the `XArm6Axis` enum). +JointPose = Dict[int, float] + + +@dataclass +class CartesianPose: + """Location and rotation of the gripper.""" + + location: Coordinate + rotation: Rotation diff --git a/pylabrobot/ufactory/xarm6/xarm6.py b/pylabrobot/ufactory/xarm6/xarm6.py new file mode 100644 index 00000000000..3cc2cbb0573 --- /dev/null +++ b/pylabrobot/ufactory/xarm6/xarm6.py @@ -0,0 +1,380 @@ +import asyncio +from typing import Any, List, Optional, Tuple + +from pylabrobot.resources import Coordinate, Rotation +from pylabrobot.ufactory.xarm6.joints import CartesianPose, JointPose + + +class XArm6Error(Exception): + """Error raised when the xArm SDK returns a non-zero code.""" + + def __init__(self, code: int, message: str): + self.code = code + super().__init__(f"XArm6Error {code}: {message}") + + +class XArm6: + """Driver for the UFACTORY xArm 6 robotic arm. + + All lengths are millimeters and all angles are degrees, matching PLR + conventions. + + Args: + ip: IP address of the xArm controller. + tcp_offset: Optional TCP offset (x, y, z, roll, pitch, yaw) for the end + effector, in mm and degrees. + tcp_load: Optional payload config as (mass_kg, [cx, cy, cz]). + gripper_min_mm: Jaw width when fully closed, in mm. + gripper_max_mm: Jaw width when fully open, in mm. + closed_threshold_mm: Width at or below which the gripper counts as closed. + park_location: Where :meth:`park` moves to. Defaults to the SDK home position. + park_rotation: Rotation to hold at ``park_location``. + """ + + _MAX_GRIPPER_UNITS = 850 + + def __init__( + self, + ip: str, + tcp_offset: Optional[Tuple[float, float, float, float, float, float]] = None, + tcp_load: Optional[Tuple[float, List[float]]] = None, + gripper_min_mm: float = 71.0, + gripper_max_mm: float = 150.0, + closed_threshold_mm: float = 73.0, + park_location: Optional[Coordinate] = None, + park_rotation: Optional[Rotation] = None, + ): + super().__init__() + self._ip = ip + self._arm: Any = None + self._tcp_offset = tcp_offset + self._tcp_load = tcp_load + self.gripper_min_mm = gripper_min_mm + self.gripper_max_mm = gripper_max_mm + self.closed_threshold_mm = closed_threshold_mm + self.park_location = park_location + self.park_rotation = park_rotation or Rotation() + + # -- SDK helpers ----------------------------------------------------------- + + async def _call_sdk(self, func, *args, op: str = "", num_retries: int = 0, **kwargs): + """Run a synchronous xArm SDK call in a thread, check the return code, and + return any data payload. + + The xArm SDK uses two return conventions: command methods (``set_position``, + ``set_gripper_position`` etc.) return a single ``int`` status code, while + query methods (``get_position``, ``get_servo_angle`` etc.) return a + ``(code, data)`` tuple. This helper handles both: it raises + :class:`XArm6Error` on a non-zero code and returns the data payload + (unwrapped) for query methods, or ``None`` for command methods. + + If ``num_retries`` > 0, failed attempts trigger :meth:`clear_errors` and + the call is retried up to that many additional times before propagating + the last error. + + Args: + func: Bound xArm SDK method to invoke. + op: Short operation name used in error messages. + num_retries: Number of retry attempts after :meth:`clear_errors` on + failure. Zero means "no retry". + *args, **kwargs: Forwarded to ``func``. + """ + code: Any = None + for attempt in range(num_retries + 1): + if attempt > 0: + await self.clear_errors() + result = await asyncio.to_thread(func, *args, **kwargs) + if isinstance(result, (tuple, list)): + code, *data_parts = result + else: + code, data_parts = result, [] + if code is None or code == 0: + if not data_parts: + return None + return data_parts[0] if len(data_parts) == 1 else tuple(data_parts) + raise XArm6Error(code, f"Failed during {op}" if op else "SDK call failed") + + # -- Lifecycle ------------------------------------------------------------- + + async def clear_errors(self) -> None: + """Clear errors/warnings and re-enable the robot for motion. + + This runs the full recovery sequence: clean errors, clean warnings, + re-enable motion, set position control mode, and set ready state. + Call this when the robot enters an error/protection state (e.g. code 9). + """ + await self._call_sdk(self._arm.clean_error, op="clean_error") + await self._call_sdk(self._arm.clean_warn, op="clean_warn") + await self._call_sdk(self._arm.motion_enable, True, op="motion_enable") + await self._call_sdk(self._arm.set_mode, 0, op="set_mode") + await self._call_sdk(self._arm.set_state, 0, op="set_state") + + async def setup(self, skip_gripper_init: bool = False) -> None: + """Connect to the xArm and initialize for position control. + + Args: + skip_gripper_init: If True, skip gripper mode/enable during setup. + """ + # xarm-python-sdk ships no stubs; when the xarm extra is absent the module is + # missing outright, so both codes can fire depending on the environment. + from xarm.wrapper import XArmAPI # type: ignore[import-not-found,import-untyped] + + self._arm = XArmAPI(self._ip) + await self.clear_errors() + + if self._tcp_offset is not None: + await self._call_sdk(self._arm.set_tcp_offset, list(self._tcp_offset), op="set_tcp_offset") + if self._tcp_load is not None: + await self._call_sdk( + self._arm.set_tcp_load, self._tcp_load[0], self._tcp_load[1], op="set_tcp_load" + ) + + if not skip_gripper_init: + await self._call_sdk(self._arm.set_gripper_mode, 1, op="set_gripper_mode") + await self._call_sdk(self._arm.set_gripper_enable, True, op="set_gripper_enable") + + # Re-assert position control mode after gripper init (gripper mode change + # can reset the arm state on some firmware versions) + await self._call_sdk(self._arm.set_mode, 0, op="set_mode") + await self._call_sdk(self._arm.set_state, 0, op="set_state") + + async def stop(self) -> None: + """Disconnect from the xArm.""" + if self._arm is not None: + await self._call_sdk(self._arm.disconnect, op="disconnect") + self._arm = None + + # -- Conversion helpers ---------------------------------------------------- + + def _mm_to_gripper_units(self, width_mm: float) -> int: + clamped = max(self.gripper_min_mm, min(self.gripper_max_mm, width_mm)) + fraction = (clamped - self.gripper_min_mm) / (self.gripper_max_mm - self.gripper_min_mm) + return int(round(fraction * self._MAX_GRIPPER_UNITS)) + + def _gripper_units_to_mm(self, units: int) -> float: + fraction = units / self._MAX_GRIPPER_UNITS + return self.gripper_min_mm + fraction * (self.gripper_max_mm - self.gripper_min_mm) + + async def _set_gripper_units(self, units: int) -> None: + await self._call_sdk( + self._arm.set_gripper_position, + units, + wait=True, + speed=0, + op="set_gripper_position", + ) + + # -- Gripper --------------------------------------------------------------- + + @property + def min_gripper_width(self) -> float: + return self.gripper_min_mm + + @property + def max_gripper_width(self) -> float: + return self.gripper_max_mm + + async def move_gripper(self, width: float, force_sensing: bool = False) -> None: + """Move the bio-gripper jaws to ``width`` mm. + + The xArm bio-gripper is position-controlled; ``force_sensing`` has no + effect on the SDK call. + """ + await self._set_gripper_units(self._mm_to_gripper_units(width)) + + async def is_gripper_closed(self) -> bool: + """Return True if the gripper width is at or below ``closed_threshold_mm``.""" + units = await self._call_sdk(self._arm.get_gripper_position, op="get_gripper_position") + return self._gripper_units_to_mm(int(units)) <= self.closed_threshold_mm + + # -- Arm ------------------------------------------------------------------- + + async def halt(self) -> None: + """Emergency stop all motion.""" + await self._call_sdk(self._arm.emergency_stop, op="emergency_stop") + + async def park(self) -> None: + """Move to ``park_location`` if set, otherwise to the SDK home position. + + If the SDK fails on the first ``move_gohome`` call, errors are cleared and + the call is retried once automatically. + """ + if self.park_location is not None: + await self.move_to_location(self.park_location, self.park_rotation) + return + await self._call_sdk( + self._arm.move_gohome, + speed=50, + mvacc=5000, + wait=True, + op="move_gohome", + num_retries=1, + ) + + async def request_gripper_pose(self) -> CartesianPose: + """Get the current gripper location and rotation.""" + pose = await self._call_sdk(self._arm.get_position, op="get_position") + return CartesianPose( + location=Coordinate(x=pose[0], y=pose[1], z=pose[2]), + rotation=Rotation(x=pose[3], y=pose[4], z=pose[5]), + ) + + # -- Cartesian motion ------------------------------------------------------ + + async def move_to_location( + self, + location: Coordinate, + rotation: Rotation, + speed: float = 100.0, + mvacc: float = 2000.0, + ) -> None: + """Move to a Cartesian location with the given rotation. + + Args: + location: Target location. + rotation: Target rotation. + speed: Cartesian move speed in mm/s. + mvacc: Cartesian move acceleration in mm/s^2. + """ + await self._call_sdk( + self._arm.set_position, + x=location.x, + y=location.y, + z=location.z, + roll=rotation.x, + pitch=rotation.y, + yaw=rotation.z, + speed=speed, + mvacc=mvacc, + wait=True, + op="set_position", + ) + + async def pick_up_at_location( + self, + location: Coordinate, + rotation: Rotation, + resource_width: float, + speed: float = 100.0, + mvacc: float = 2000.0, + ) -> None: + """Move to ``location`` and close the gripper to ``resource_width``. + + Args: + location: Target location. + rotation: Target rotation. + resource_width: Width to close the gripper to, in mm. + speed: Cartesian move speed in mm/s. + mvacc: Cartesian move acceleration in mm/s^2. + """ + await self.move_to_location(location, rotation, speed=speed, mvacc=mvacc) + await self.move_gripper(width=resource_width, force_sensing=True) + + async def drop_at_location( + self, + location: Coordinate, + rotation: Rotation, + resource_width: float, + speed: float = 100.0, + mvacc: float = 2000.0, + ) -> None: + """Move to ``location`` and fully open the gripper. + + Args: + location: Target location. + rotation: Target rotation. + resource_width: Width of the resource being released, in mm. The gripper + opens fully regardless. + speed: Cartesian move speed in mm/s. + mvacc: Cartesian move acceleration in mm/s^2. + """ + await self.move_to_location(location, rotation, speed=speed, mvacc=mvacc) + await self._set_gripper_units(self._MAX_GRIPPER_UNITS) + + # -- Joint motion ---------------------------------------------------------- + + async def move_to_joint_position( + self, + position: JointPose, + speed: float = 50.0, + mvacc: float = 500.0, + ) -> None: + """Move to the specified joint angles. + + Missing joint indices are filled in from the current joint position. + + Args: + position: Target joint angles in degrees, keyed by axis number. + speed: Joint move speed in deg/s. + mvacc: Joint move acceleration in deg/s^2. + """ + current_angles = await self._call_sdk(self._arm.get_servo_angle, op="get_servo_angle") + angles = list(current_angles) + for axis, value in position.items(): + angles[int(axis) - 1] = value + await self._call_sdk( + self._arm.set_servo_angle, + angle=angles, + speed=speed, + mvacc=mvacc, + wait=True, + op="set_servo_angle", + ) + + async def request_joint_position(self) -> JointPose: + """Get current joint angles as ``{1: j1_deg, 2: j2_deg, ...}``.""" + angles = await self._call_sdk(self._arm.get_servo_angle, op="get_servo_angle") + return {i + 1: angles[i] for i in range(6)} + + async def pick_up_at_joint_position( + self, + position: JointPose, + resource_width: float, + speed: float = 50.0, + mvacc: float = 500.0, + ) -> None: + """Move to the joint target and close the gripper to ``resource_width``. + + Args: + position: Target joint angles in degrees, keyed by axis number. + resource_width: Width to close the gripper to, in mm. + speed: Joint move speed in deg/s. + mvacc: Joint move acceleration in deg/s^2. + """ + await self.move_to_joint_position(position, speed=speed, mvacc=mvacc) + await self.move_gripper(width=resource_width, force_sensing=True) + + async def drop_at_joint_position( + self, + position: JointPose, + resource_width: float, + speed: float = 50.0, + mvacc: float = 500.0, + ) -> None: + """Move to the joint target and fully open the gripper. + + Args: + position: Target joint angles in degrees, keyed by axis number. + resource_width: Width of the resource being released, in mm. The gripper + opens fully regardless. + speed: Joint move speed in deg/s. + mvacc: Joint move acceleration in deg/s^2. + """ + await self.move_to_joint_position(position, speed=speed, mvacc=mvacc) + await self._set_gripper_units(self._MAX_GRIPPER_UNITS) + + # -- Freedrive ------------------------------------------------------------- + + async def start_freedrive_mode(self, free_axes: List[int]) -> None: + """Enter freedrive (manual teaching) mode. + + The xArm SDK only supports freeing all axes at once, so ``free_axes`` has + no effect. + """ + await self._call_sdk(self._arm.set_mode, 2, op="set_mode") + await self._call_sdk(self._arm.set_state, 0, op="set_state") + + async def stop_freedrive_mode(self) -> None: + """Exit freedrive mode and return to position control.""" + await self._call_sdk(self._arm.set_mode, 0, op="set_mode") + await self._call_sdk(self._arm.set_state, 0, op="set_state") diff --git a/pylabrobot/ufactory/xarm6/xarm6_tests.py b/pylabrobot/ufactory/xarm6/xarm6_tests.py new file mode 100644 index 00000000000..62b5a3e92f1 --- /dev/null +++ b/pylabrobot/ufactory/xarm6/xarm6_tests.py @@ -0,0 +1,365 @@ +import sys +import types +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.resources import Coordinate +from pylabrobot.resources.rotation import Rotation +from pylabrobot.ufactory.xarm6.xarm6 import XArm6, XArm6Error + + +def _install_mock_xarm(mock_arm: MagicMock) -> MagicMock: + mock_xarm = types.ModuleType("xarm") + mock_wrapper = types.ModuleType("xarm.wrapper") + mock_wrapper.XArmAPI = MagicMock(return_value=mock_arm) # type: ignore[attr-defined] + mock_xarm.wrapper = mock_wrapper # type: ignore[attr-defined] + sys.modules["xarm"] = mock_xarm + sys.modules["xarm.wrapper"] = mock_wrapper + return mock_wrapper.XArmAPI # type: ignore[attr-defined,no-any-return] + + +class TestXArm6SDK(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.mock_arm = MagicMock() + self.mock_arm.clean_error.return_value = 0 + self.mock_arm.clean_warn.return_value = 0 + self.mock_arm.motion_enable.return_value = 0 + self.mock_arm.set_mode.return_value = 0 + self.mock_arm.set_state.return_value = 0 + self.mock_arm.set_tcp_offset.return_value = 0 + self.mock_arm.set_tcp_load.return_value = 0 + self.mock_arm.set_gripper_mode.return_value = 0 + self.mock_arm.set_gripper_enable.return_value = 0 + self.mock_arm.disconnect.return_value = None + self.mock_arm.get_position.return_value = [0, [100, 200, 300, 180, 0, 90]] + self.mock_arm.set_position.return_value = 0 + + self.MockXArmAPI = _install_mock_xarm(self.mock_arm) + + self.driver = XArm6(ip="192.168.1.113") + await self.driver.setup() + + async def asyncTearDown(self): + sys.modules.pop("xarm", None) + sys.modules.pop("xarm.wrapper", None) + + async def test_setup(self): + self.MockXArmAPI.assert_called_once_with("192.168.1.113") + self.mock_arm.clean_error.assert_called_once() + self.mock_arm.clean_warn.assert_called_once() + self.mock_arm.motion_enable.assert_called_once_with(True) + self.mock_arm.set_mode.assert_called_with(0) + self.mock_arm.set_state.assert_called_with(0) + self.mock_arm.set_gripper_mode.assert_called_once_with(1) + self.mock_arm.set_gripper_enable.assert_called_once_with(True) + + async def test_setup_with_tcp_offset(self): + driver = XArm6(ip="192.168.1.113", tcp_offset=(0, 0, 50, 0, 0, 0)) + await driver.setup() + self.mock_arm.set_tcp_offset.assert_called_once_with([0, 0, 50, 0, 0, 0]) + + async def test_setup_skip_gripper_init(self): + driver = XArm6(ip="192.168.1.113") + self.mock_arm.set_gripper_mode.reset_mock() + self.mock_arm.set_gripper_enable.reset_mock() + await driver.setup(skip_gripper_init=True) + self.mock_arm.set_gripper_mode.assert_not_called() + self.mock_arm.set_gripper_enable.assert_not_called() + + async def test_stop(self): + await self.driver.stop() + self.mock_arm.disconnect.assert_called_once() + self.assertIsNone(self.driver._arm) + + async def test_call_sdk_command_success(self): + result = await self.driver._call_sdk( + self.mock_arm.set_position, x=1, y=2, z=3, op="set_position" + ) + self.assertIsNone(result) + self.mock_arm.set_position.assert_called_with(x=1, y=2, z=3) + + async def test_call_sdk_command_failure_raises(self): + self.mock_arm.set_position.return_value = -2 + with self.assertRaises(XArm6Error) as ctx: + await self.driver._call_sdk(self.mock_arm.set_position, op="set_position") + self.assertEqual(ctx.exception.code, -2) + + async def test_call_sdk_query_unwraps_data(self): + pose = await self.driver._call_sdk(self.mock_arm.get_position, op="get_position") + self.assertEqual(pose, [100, 200, 300, 180, 0, 90]) + + async def test_call_sdk_query_failure_raises(self): + self.mock_arm.get_position.return_value = [-1, None] + with self.assertRaises(XArm6Error) as ctx: + await self.driver._call_sdk(self.mock_arm.get_position, op="get_position") + self.assertEqual(ctx.exception.code, -1) + + async def test_call_sdk_ignores_none_return(self): + self.mock_arm.emergency_stop.return_value = None + result = await self.driver._call_sdk(self.mock_arm.emergency_stop, op="emergency_stop") + self.assertIsNone(result) + + async def test_call_sdk_retries_after_clear_errors(self): + self.mock_arm.move_gohome.side_effect = [9, 0] + self.mock_arm.clean_error.reset_mock() + await self.driver._call_sdk( + self.mock_arm.move_gohome, speed=50, op="move_gohome", num_retries=1 + ) + # Called twice (once failing, once succeeding after clear_errors). + self.assertEqual(self.mock_arm.move_gohome.call_count, 2) + self.mock_arm.clean_error.assert_called_once() + + async def test_call_sdk_reraises_if_all_retries_fail(self): + self.mock_arm.move_gohome.side_effect = [9, 9] + with self.assertRaises(XArm6Error) as ctx: + await self.driver._call_sdk(self.mock_arm.move_gohome, op="move_gohome", num_retries=1) + self.assertEqual(ctx.exception.code, 9) + self.assertEqual(self.mock_arm.move_gohome.call_count, 2) + + async def test_call_sdk_no_retry_by_default(self): + self.mock_arm.move_gohome.return_value = 9 + with self.assertRaises(XArm6Error): + await self.driver._call_sdk(self.mock_arm.move_gohome, op="move_gohome") + self.assertEqual(self.mock_arm.move_gohome.call_count, 1) + + async def test_call_sdk_multi_retry(self): + self.mock_arm.move_gohome.side_effect = [9, 9, 0] + await self.driver._call_sdk(self.mock_arm.move_gohome, op="move_gohome", num_retries=2) + self.assertEqual(self.mock_arm.move_gohome.call_count, 3) + + async def test_clear_errors_sequence(self): + self.mock_arm.clean_error.reset_mock() + self.mock_arm.clean_warn.reset_mock() + self.mock_arm.motion_enable.reset_mock() + await self.driver.clear_errors() + self.mock_arm.clean_error.assert_called_once() + self.mock_arm.clean_warn.assert_called_once() + self.mock_arm.motion_enable.assert_called_once_with(True) + + +class TestXArm6Motion(unittest.IsolatedAsyncioTestCase): + def _make_device(self, **kwargs) -> XArm6: + """Build an XArm6 sharing the mock arm and the _call_sdk recorder.""" + device = XArm6(ip="192.168.1.113", **kwargs) + device._arm = self.arm + device._call_sdk = self.call_sdk # type: ignore[method-assign] + device.clear_errors = AsyncMock() # type: ignore[method-assign] + return device + + def setUp(self): + self.arm = MagicMock() + + sdk_returns = { + self.arm.get_position: [100, 200, 300, 180, 0, 90], + self.arm.get_servo_angle: [10, 20, 30, 40, 50, 60], + self.arm.get_gripper_position: 850, + } + + async def call_sdk(func, *args, op="", num_retries=0, **kwargs): + return sdk_returns.get(func) + + self.call_sdk = AsyncMock(side_effect=call_sdk) + self.device = self._make_device() + + def _sdk_calls_for(self, func) -> list: + return [c for c in self.call_sdk.call_args_list if c.args and c.args[0] is func] + + # -- Gripper --------------------------------------------------------------- + + async def test_move_gripper_mm_to_units(self): + # Default range is 71..150 mm mapped to 0..850 units, so 85 mm → ~151 units. + await self.device.move_gripper(width=85, force_sensing=False) + calls = self._sdk_calls_for(self.arm.set_gripper_position) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0].args[1], 151) + self.assertEqual(calls[0].kwargs["wait"], True) + self.assertEqual(calls[0].kwargs["speed"], 0) + + async def test_move_gripper_midpoint(self): + # Midpoint of the 71..150 range is 110.5 mm → 425 units. + await self.device.move_gripper(width=110.5, force_sensing=False) + calls = self._sdk_calls_for(self.arm.set_gripper_position) + self.assertEqual(calls[0].args[1], 425) + + async def test_move_gripper_force_sensing(self): + await self.device.move_gripper(width=71, force_sensing=True) + calls = self._sdk_calls_for(self.arm.set_gripper_position) + self.assertEqual(calls[0].args[1], 0) + + async def test_move_gripper_clamped_high(self): + await self.device.move_gripper(width=200, force_sensing=False) + calls = self._sdk_calls_for(self.arm.set_gripper_position) + self.assertEqual(calls[0].args[1], 850) + + async def test_move_gripper_clamped_low(self): + await self.device.move_gripper(width=-5, force_sensing=False) + calls = self._sdk_calls_for(self.arm.set_gripper_position) + self.assertEqual(calls[0].args[1], 0) + + async def test_is_gripper_closed_true(self): + async def call_sdk(func, *args, op="", num_retries=0, **kwargs): + return 5 + + self.device._call_sdk = AsyncMock(side_effect=call_sdk) # type: ignore[method-assign] + self.assertTrue(await self.device.is_gripper_closed()) + + async def test_is_gripper_closed_false(self): + async def call_sdk(func, *args, op="", num_retries=0, **kwargs): + return 500 + + self.device._call_sdk = AsyncMock(side_effect=call_sdk) # type: ignore[method-assign] + self.assertFalse(await self.device.is_gripper_closed()) + + # -- Base arm -------------------------------------------------------------- + + async def test_halt(self): + await self.device.halt() + self.assertEqual(len(self._sdk_calls_for(self.arm.emergency_stop)), 1) + + async def test_park_default_home_uses_retry(self): + await self.device.park() + calls = self._sdk_calls_for(self.arm.move_gohome) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0].kwargs["num_retries"], 1) + self.assertEqual(len(self._sdk_calls_for(self.arm.set_position)), 0) + + async def test_park_with_location(self): + device = self._make_device( + park_location=Coordinate(x=250, y=0, z=300), + park_rotation=Rotation(x=180, y=0, z=0), + ) + await device.park() + self.assertEqual(len(self._sdk_calls_for(self.arm.move_gohome)), 0) + set_pos_calls = self._sdk_calls_for(self.arm.set_position) + self.assertEqual(len(set_pos_calls), 1) + self.assertEqual(set_pos_calls[0].kwargs["x"], 250) + self.assertEqual(set_pos_calls[0].kwargs["y"], 0) + self.assertEqual(set_pos_calls[0].kwargs["z"], 300) + + async def test_request_gripper_pose(self): + location = await self.device.request_gripper_pose() + self.assertEqual(location.location.x, 100) + self.assertEqual(location.location.y, 200) + self.assertEqual(location.location.z, 300) + self.assertEqual(location.rotation.x, 180) + self.assertEqual(location.rotation.y, 0) + self.assertEqual(location.rotation.z, 90) + + # -- Cartesian motion ------------------------------------------------------ + + async def test_move_to_location_defaults(self): + await self.device.move_to_location(Coordinate(x=300, y=100, z=200), Rotation(x=180, y=0, z=0)) + calls = self._sdk_calls_for(self.arm.set_position) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0].kwargs["x"], 300) + self.assertEqual(calls[0].kwargs["y"], 100) + self.assertEqual(calls[0].kwargs["z"], 200) + self.assertEqual(calls[0].kwargs["roll"], 180) + self.assertEqual(calls[0].kwargs["pitch"], 0) + self.assertEqual(calls[0].kwargs["yaw"], 0) + self.assertEqual(calls[0].kwargs["speed"], 100.0) + self.assertEqual(calls[0].kwargs["mvacc"], 2000.0) + self.assertEqual(calls[0].kwargs["wait"], True) + + async def test_move_to_location_with_motion_profile(self): + await self.device.move_to_location( + Coordinate(x=0, y=0, z=0), + Rotation(), + speed=250, + mvacc=3500, + ) + calls = self._sdk_calls_for(self.arm.set_position) + self.assertEqual(calls[0].kwargs["speed"], 250) + self.assertEqual(calls[0].kwargs["mvacc"], 3500) + + async def test_pick_up_at_location_move_then_close(self): + loc = Coordinate(x=300, y=100, z=50) + rot = Rotation(x=180, y=0, z=0) + await self.device.pick_up_at_location(loc, rot, resource_width=80) + + mcalls = self._sdk_calls_for(self.arm.set_position) + self.assertEqual(len(mcalls), 1) + self.assertEqual(mcalls[0].kwargs["z"], 50) + + grip_calls = self._sdk_calls_for(self.arm.set_gripper_position) + self.assertEqual(len(grip_calls), 1) + # 80 mm in 71..150 mm range → (80-71)/(150-71) * 850 ≈ 97 units. + self.assertEqual(grip_calls[0].args[1], 97) + + async def test_drop_at_location_move_then_open_max(self): + loc = Coordinate(x=300, y=100, z=50) + rot = Rotation(x=180, y=0, z=0) + await self.device.drop_at_location(loc, rot, resource_width=80) + + mcalls = self._sdk_calls_for(self.arm.set_position) + self.assertEqual(len(mcalls), 1) + self.assertEqual(mcalls[0].kwargs["z"], 50) + + grip_calls = self._sdk_calls_for(self.arm.set_gripper_position) + self.assertEqual(len(grip_calls), 1) + self.assertEqual(grip_calls[0].args[1], 850) # SDK max + + # -- Joints ---------------------------------------------------------------- + + async def test_request_joint_position(self): + result = await self.device.request_joint_position() + self.assertEqual(result, {1: 10, 2: 20, 3: 30, 4: 40, 5: 50, 6: 60}) + + async def test_move_to_joint_position_partial(self): + await self.device.move_to_joint_position({1: 45, 3: -90}) + self.assertEqual(len(self._sdk_calls_for(self.arm.get_servo_angle)), 1) + set_calls = self._sdk_calls_for(self.arm.set_servo_angle) + self.assertEqual(len(set_calls), 1) + self.assertEqual(set_calls[0].kwargs["angle"], [45, 20, -90, 40, 50, 60]) + self.assertEqual(set_calls[0].kwargs["speed"], 50.0) + self.assertEqual(set_calls[0].kwargs["mvacc"], 500.0) + self.assertEqual(set_calls[0].kwargs["wait"], True) + + async def test_move_to_joint_position_with_motion_profile(self): + await self.device.move_to_joint_position( + {1: 0}, + speed=120, + mvacc=900, + ) + set_calls = self._sdk_calls_for(self.arm.set_servo_angle) + self.assertEqual(set_calls[0].kwargs["speed"], 120) + self.assertEqual(set_calls[0].kwargs["mvacc"], 900) + + async def test_pick_up_at_joint_position(self): + await self.device.pick_up_at_joint_position({1: 0, 2: 0}, resource_width=80) + self.assertEqual(len(self._sdk_calls_for(self.arm.set_servo_angle)), 1) + grip_calls = self._sdk_calls_for(self.arm.set_gripper_position) + self.assertEqual(len(grip_calls), 1) + self.assertEqual(grip_calls[0].args[1], 97) + + async def test_drop_at_joint_position(self): + await self.device.drop_at_joint_position({1: 0, 2: 0}, resource_width=80) + self.assertEqual(len(self._sdk_calls_for(self.arm.set_servo_angle)), 1) + grip_calls = self._sdk_calls_for(self.arm.set_gripper_position) + self.assertEqual(len(grip_calls), 1) + self.assertEqual(grip_calls[0].args[1], 850) + + # -- Freedrive ------------------------------------------------------------- + + async def test_start_freedrive_mode(self): + await self.device.start_freedrive_mode(free_axes=[0]) + mode_calls = self._sdk_calls_for(self.arm.set_mode) + state_calls = self._sdk_calls_for(self.arm.set_state) + self.assertEqual(mode_calls[0].args[1], 2) + self.assertEqual(state_calls[0].args[1], 0) + + async def test_stop_freedrive_mode(self): + await self.device.stop_freedrive_mode() + mode_calls = self._sdk_calls_for(self.arm.set_mode) + state_calls = self._sdk_calls_for(self.arm.set_state) + self.assertEqual(mode_calls[0].args[1], 0) + self.assertEqual(state_calls[0].args[1], 0) + + # -- Custom configuration -------------------------------------------------- + + async def test_custom_gripper_range(self): + device = self._make_device(gripper_min_mm=50.0, gripper_max_mm=100.0) + await device.move_gripper(width=75.0, force_sensing=False) + calls = self._sdk_calls_for(self.arm.set_gripper_position) + # Midpoint of 50..100 mm range → 0.5 * 850 = 425 units. + self.assertEqual(calls[0].args[1], 425) diff --git a/pylabrobot/visualizer/lib.js b/pylabrobot/visualizer/lib.js index 01ef98e04f1..d6efa43f995 100644 --- a/pylabrobot/visualizer/lib.js +++ b/pylabrobot/visualizer/lib.js @@ -750,6 +750,12 @@ var resourceSnapshots = {}; // name -> serialized resource data var rootResource = null; // the root resource for fit-to-viewport +// Find the deck by type rather than a hardcoded "deck" key: robust to the deck's name and to the +// resource not yet being registered (returns undefined instead of throwing). +function getDeck() { + return Object.values(resources).find((r) => r instanceof Deck); +} + function fitToViewport() { if (!rootResource || !stage) return; const padding = 40; @@ -783,11 +789,6 @@ var frameImages = []; let frameInterval = 8; var _recordingTimer = null; -// The deck resource, found by type rather than a hardcoded name (decks may be named anything). -function getDeck() { - return Object.values(resources).find((r) => r instanceof Deck); -} - function getSnappingResourceAndLocationAndSnappingBox(resourceToSnap, x, y) { // Return the snapping resource that the given point is within, or undefined if there is no such resource. // A snapping resource is a spot within a plate/tip carrier or the OT deck. @@ -856,29 +857,27 @@ function getSnappingResourceAndLocationAndSnappingBox(resourceToSnap, x, y) { } } - // Check if the resource is in the OT Deck. The slots are ResourceHolder children of the deck, so - // their positions and sizes come from the serialized resource rather than being duplicated here. + // Check if the resource is in the OT Deck. if (deck.constructor.name === "OTDeck") { - for (const holder of deck.children) { - if (!holder.location || !(holder.name && /slot_\d+$/.test(holder.name))) { - continue; - } - const siteX = deck.location.x + holder.location.x; - const siteY = deck.location.y + holder.location.y; + const siteWidth = 128.0; + const siteHeight = 86.0; + + for (let i = 0; i < otDeckSiteLocations.length; i++) { + let siteLocation = otDeckSiteLocations[i]; if ( - x > siteX && - x < siteX + holder.size_x && - y > siteY && - y < siteY + holder.size_y + x > deck.location.x + siteLocation.x && + x < deck.location.x + siteLocation.x + siteWidth && + y > deck.location.y + siteLocation.y && + y < deck.location.y + siteLocation.y + siteHeight ) { return { resource: deck, - location: { x: holder.location.x, y: holder.location.y }, + location: { x: siteLocation.x, y: siteLocation.y }, snappingBox: { - x: siteX, - y: siteY, - width: holder.size_x, - height: holder.size_y, + x: deck.location.x + siteLocation.x, + y: deck.location.y + siteLocation.y, + width: siteWidth, + height: siteHeight, }, }; } @@ -1555,6 +1554,21 @@ class VantageDeck extends Deck { } } +const otDeckSiteLocations = [ + { x: 0.0, y: 0.0 }, + { x: 132.5, y: 0.0 }, + { x: 265.0, y: 0.0 }, + { x: 0.0, y: 90.5 }, + { x: 132.5, y: 90.5 }, + { x: 265.0, y: 90.5 }, + { x: 0.0, y: 181.0 }, + { x: 132.5, y: 181.0 }, + { x: 265.0, y: 181.0 }, + { x: 0.0, y: 271.5 }, + { x: 132.5, y: 271.5 }, + { x: 265.0, y: 271.5 }, +]; + class OTDeck extends Deck { constructor(resourceData) { super(resourceData, undefined); @@ -1563,7 +1577,7 @@ class OTDeck extends Deck { drawMainShape() { // The slot rectangles are drawn by the ResourceHolder children; the main shape is just the deck // border. The slot-number labels are added in draw() so they sit on top of the holders. - let group = new Konva.Group({}); + const group = new Konva.Group({}); // Opaque footprint fill, drawn first (behind the slot holders) so the deck paints its own // surface rather than relying on the global background; otherwise the gaps around and between @@ -2865,7 +2879,7 @@ function buildSingleArm(armData, anchorDropdown, armId) { attrs.push({ key: "resource_name", value: armData.resource_name }); attrs.push({ key: "resource_type", value: armData.resource_type || "Unknown" }); attrs.push({ key: "direction", value: armData.direction || "?" }); - attrs.push({ key: "pickup_distance_from_top", value: (armData.pickup_distance_from_top || 0) + " mm" }); + attrs.push({ key: "pickup_distance_from_bottom", value: (armData.pickup_distance_from_bottom || 0) + " mm" }); attrs.push({ key: "size", value: (armData.size_x || "?") + " × " + (armData.size_y || "?") + " × " + (armData.size_z || "?") + " mm" }); if (armData.num_items_x) attrs.push({ key: "wells", value: (armData.num_items_x * (armData.num_items_y || 1)) }); } diff --git a/pylabrobot/visualizer/visualizer.py b/pylabrobot/visualizer/visualizer.py index 05c5aa81511..99c980ac35d 100644 --- a/pylabrobot/visualizer/visualizer.py +++ b/pylabrobot/visualizer/visualizer.py @@ -25,7 +25,7 @@ from pylabrobot.__version__ import STANDARD_FORM_JSON_VERSION from pylabrobot.resources import Resource -logger = logging.getLogger("pylabrobot") +logger = logging.getLogger(__name__) @functools.lru_cache(maxsize=None) diff --git a/pyproject.toml b/pyproject.toml index 717e28ccb6d..06d86e3aef2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,9 +19,10 @@ hid = ["hid"] modbus = ["pymodbus>=3.0.0,<3.7.0"] opentrons = ["opentrons-http-api-client==0.2.1"] sila = ["zeroconf>=0.131.0", "grpcio"] -microscopy = ["numpy>=1.26", "opencv-python"] -pico = ["PyLabRobot[microscopy,sila]"] -all = ["PyLabRobot[serial,usb,ftdi,hid,modbus,websockets,visualizer,opentrons,sila]"] +cytation-microscopy = ["numpy>=1.26", "opencv-python", "PyGObject"] +pico = ["PyLabRobot[sila]", "opencv-python", "numpy"] +xarm = ["xarm-python-sdk"] +all = ["PyLabRobot[serial,usb,ftdi,hid,modbus,websockets,visualizer,opentrons,sila,pico,xarm]"] test = [ "pytest", "pytest-timeout",